diff --git a/.env.example b/.env.example index 991a06ff..ec89fa7d 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ MAIN_VITE_API_URL=API_URL MAIN_VITE_AUTH_URL=AUTH_URL -MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY RENDERER_VITE_INTERCOM_APP_ID=YOUR_APP_ID diff --git a/postinstall.cjs b/postinstall.cjs index 25d27c0a..8d70fe71 100644 --- a/postinstall.cjs +++ b/postinstall.cjs @@ -2,6 +2,7 @@ const { default: axios } = require("axios"); const util = require("node:util"); const fs = require("node:fs"); const path = require("node:path"); +const { spawnSync } = require("node:child_process"); const exec = util.promisify(require("node:child_process").exec); @@ -46,4 +47,76 @@ const downloadLudusavi = async () => { }); }; +const downloadAria2WindowsAndLinux = async () => { + if (fs.existsSync("aria2")) { + console.log("Aria2 already exists, skipping download..."); + return; + } + + const file = + process.platform === "win32" + ? "aria2-1.37.0-win-64bit-build1.zip" + : "aria2-1.37.0-1-x86_64.pkg.tar.zst"; + + const downloadUrl = + process.platform === "win32" + ? `https://github.com/aria2/aria2/releases/download/release-1.37.0/${file}` + : "https://archlinux.org/packages/extra/x86_64/aria2/download/"; + + console.log(`Downloading ${file}...`); + + const response = await axios.get(downloadUrl, { responseType: "stream" }); + + const stream = response.data.pipe(fs.createWriteStream(file)); + + stream.on("finish", async () => { + console.log(`Downloaded ${file}, extracting...`); + + if (process.platform === "win32") { + await exec(`npx extract-zip ${file}`); + console.log("Extracted. Renaming folder..."); + + fs.renameSync(file.replace(".zip", ""), "aria2"); + } else { + await exec(`tar --zstd -xvf ${file} usr/bin/aria2c`); + console.log("Extracted. Copying binary file..."); + fs.mkdirSync("aria2"); + fs.copyFileSync("usr/bin/aria2c", "aria2/aria2c"); + fs.rmSync("usr", { recursive: true }); + } + + console.log(`Extracted ${file}, removing compressed downloaded file...`); + fs.rmSync(file); + }); +}; + +const copyAria2Macos = async () => { + console.log("Checking if aria2 is installed..."); + + const isAria2Installed = spawnSync("which", ["aria2c"]).status; + + if (isAria2Installed != 0) { + console.log("Please install aria2"); + console.log("brew install aria2"); + return; + } + + console.log("Copying aria2 binary..."); + fs.mkdirSync("aria2"); + await exec(`cp $(which aria2c) aria2/aria2c`); +}; + +if (process.platform === "win32") { + fs.copyFileSync( + "node_modules/ps-list/vendor/fastlist-0.3.0-x64.exe", + "fastlist.exe" + ); +} + +if (process.platform == "darwin") { + copyAria2Macos(); +} else { + downloadAria2WindowsAndLinux(); +} + downloadLudusavi(); diff --git a/requirements.txt b/requirements.txt index b8a15cdc..ffdfb59b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ cx_Logging; sys_platform == 'win32' pywin32; sys_platform == 'win32' psutil Pillow +flask +aria2p diff --git a/src/main/events/auth/sign-out.ts b/src/main/events/auth/sign-out.ts index 52fdf7a3..67692707 100644 --- a/src/main/events/auth/sign-out.ts +++ b/src/main/events/auth/sign-out.ts @@ -1,10 +1,5 @@ import { registerEvent } from "../register-event"; -import { - DownloadManager, - HydraApi, - PythonInstance, - gamesPlaytime, -} from "@main/services"; +import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services"; import { dataSource } from "@main/data-source"; import { DownloadQueue, Game, UserAuth, UserSubscription } from "@main/entity"; @@ -32,7 +27,8 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => { DownloadManager.cancelDownload(); /* Disconnects libtorrent */ - PythonInstance.killTorrent(); + // TODO + // TorrentDownloader.killTorrent(); HydraApi.handleSignOut(); diff --git a/src/main/events/library/close-game.ts b/src/main/events/library/close-game.ts index 9c431d06..62f932cd 100644 --- a/src/main/events/library/close-game.ts +++ b/src/main/events/library/close-game.ts @@ -1,6 +1,6 @@ import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; -import { PythonInstance, logger } from "@main/services"; +import { logger } from "@main/services"; import sudo from "sudo-prompt"; import { app } from "electron"; @@ -16,7 +16,8 @@ const closeGame = async ( _event: Electron.IpcMainInvokeEvent, gameId: number ) => { - const processes = await PythonInstance.getProcessList(); + // const processes = await PythonInstance.getProcessList(); + const processes = []; const game = await gameRepository.findOne({ where: { id: gameId, isDeleted: false }, }); diff --git a/src/main/events/profile/process-profile-image.ts b/src/main/events/profile/process-profile-image.ts index f6d68088..485635dc 100644 --- a/src/main/events/profile/process-profile-image.ts +++ b/src/main/events/profile/process-profile-image.ts @@ -1,11 +1,11 @@ import { registerEvent } from "../register-event"; -import { PythonInstance } from "@main/services"; const processProfileImage = async ( _event: Electron.IpcMainInvokeEvent, path: string ) => { - return PythonInstance.processProfileImage(path); + return path; + // return PythonInstance.processProfileImage(path); }; registerEvent("processProfileImage", processProfileImage); diff --git a/src/main/events/user-preferences/authenticate-real-debrid.ts b/src/main/events/user-preferences/authenticate-real-debrid.ts index 01705db7..e2bbc6c9 100644 --- a/src/main/events/user-preferences/authenticate-real-debrid.ts +++ b/src/main/events/user-preferences/authenticate-real-debrid.ts @@ -1,4 +1,4 @@ -import { RealDebridClient } from "@main/services/real-debrid"; +import { RealDebridClient } from "@main/services/download/real-debrid"; import { registerEvent } from "../register-event"; const authenticateRealDebrid = async ( diff --git a/src/main/index.ts b/src/main/index.ts index 0c20d2e1..ca49a9fb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,12 +5,14 @@ import path from "node:path"; import url from "node:url"; import fs from "node:fs"; import { electronApp, optimizer } from "@electron-toolkit/utils"; -import { logger, PythonInstance, WindowManager } from "@main/services"; +import { logger, WindowManager } from "@main/services"; import { dataSource } from "@main/data-source"; import resources from "@locales"; import { userPreferencesRepository } from "@main/repository"; import { knexClient, migrationConfig } from "./knex-client"; import { databaseDirectory } from "./constants"; +import { PythonRPC } from "./services/python-rpc"; +import { Aria2 } from "./services/aria2"; const { autoUpdater } = updater; @@ -146,7 +148,8 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { /* Disconnects libtorrent */ - PythonInstance.kill(); + PythonRPC.kill(); + Aria2.kill(); }); app.on("activate", () => { diff --git a/src/main/main.ts b/src/main/main.ts index 69bc62e0..f28a77fa 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,21 +1,20 @@ -import { - DownloadManager, - Ludusavi, - PythonInstance, - startMainLoop, -} from "./services"; +import { DownloadManager, Ludusavi, startMainLoop } from "./services"; import { downloadQueueRepository, userPreferencesRepository, } from "./repository"; import { UserPreferences } from "./entity"; -import { RealDebridClient } from "./services/real-debrid"; +import { RealDebridClient } from "./services/download/real-debrid"; import { HydraApi } from "./services/hydra-api"; import { uploadGamesBatch } from "./services/library-sync"; +import { PythonRPC } from "./services/python-rpc"; +import { Aria2 } from "./services/aria2"; const loadState = async (userPreferences: UserPreferences | null) => { import("./events"); + Aria2.spawn(); + if (userPreferences?.realDebridApiToken) { RealDebridClient.authorize(userPreferences?.realDebridApiToken); } @@ -35,11 +34,14 @@ const loadState = async (userPreferences: UserPreferences | null) => { }, }); - if (nextQueueItem?.game.status === "active") { - DownloadManager.startDownload(nextQueueItem.game); - } else { - PythonInstance.spawn(); - } + PythonRPC.spawn(); + // start download + + // if (nextQueueItem?.game.status === "active") { + // DownloadManager.startDownload(nextQueueItem.game); + // } else { + // PythonInstance.spawn(); + // } startMainLoop(); }; diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index b5508cce..1b417cbe 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -1,42 +1,120 @@ import { Game } from "@main/entity"; import { Downloader } from "@shared"; -import { PythonInstance } from "./python-instance"; import { WindowManager } from "../window-manager"; -import { downloadQueueRepository, gameRepository } from "@main/repository"; +import { + downloadQueueRepository, + gameRepository, + userPreferencesRepository, +} from "@main/repository"; import { publishDownloadCompleteNotification } from "../notifications"; -import { RealDebridDownloader } from "./real-debrid-downloader"; import type { DownloadProgress } from "@types"; import { GofileApi, QiwiApi } from "../hosters"; -import { GenericHttpDownloader } from "./generic-http-downloader"; -import { In, Not } from "typeorm"; -import path from "path"; -import fs from "fs"; +import { PythonRPC } from "../python-rpc"; +import { + LibtorrentPayload, + LibtorrentStatus, + PauseDownloadPayload, +} from "./types"; +import { calculateETA } from "./helpers"; +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; +import { RealDebridClient } from "./real-debrid"; export class DownloadManager { - private static currentDownloader: Downloader | null = null; private static downloadingGameId: number | null = null; - public static async watchDownloads() { - let status: DownloadProgress | null = null; + private static async getDownloadStatus() { + const response = await PythonRPC.rpc.get( + "/status" + ); - if (this.currentDownloader === Downloader.Torrent) { - status = await PythonInstance.getStatus(); - } else if (this.currentDownloader === Downloader.RealDebrid) { - status = await RealDebridDownloader.getStatus(); - } else { - status = await GenericHttpDownloader.getStatus(); + if (response.data === null || !this.downloadingGameId) return null; + + const gameId = this.downloadingGameId; + + try { + const { + progress, + numPeers, + numSeeds, + downloadSpeed, + bytesDownloaded, + fileSize, + folderName, + status, + } = response.data; + + const isDownloadingMetadata = + status === LibtorrentStatus.DownloadingMetadata; + + const isCheckingFiles = status === LibtorrentStatus.CheckingFiles; + + if (!isDownloadingMetadata && !isCheckingFiles) { + const update: QueryDeepPartialEntity = { + bytesDownloaded, + fileSize, + progress, + status: "active", + }; + + await gameRepository.update( + { id: gameId }, + { + ...update, + folderName, + } + ); + } + + if (progress === 1 && !isCheckingFiles) { + const userPreferences = await userPreferencesRepository.findOneBy({ + id: 1, + }); + + if (userPreferences?.seedAfterDownloadComplete) { + gameRepository.update( + { id: gameId }, + { status: "seeding", shouldSeed: true } + ); + } else { + gameRepository.update( + { id: gameId }, + { status: "complete", shouldSeed: false } + ); + + this.pauseSeeding(gameId); + } + + this.downloadingGameId = -1; + } + + return { + numPeers, + numSeeds, + downloadSpeed, + timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed), + isDownloadingMetadata, + isCheckingFiles, + progress, + gameId, + } as DownloadProgress; + } catch (err) { + return null; } + } + + public static async watchDownloads() { + const status = await this.getDownloadStatus(); + + // // status = await RealDebridDownloader.getStatus(); if (status) { const { gameId, progress } = status; - const game = await gameRepository.findOne({ where: { id: gameId, isDeleted: false }, }); if (WindowManager.mainWindow && game) { WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); - WindowManager.mainWindow.webContents.send( "on-download-progress", JSON.parse( @@ -47,12 +125,9 @@ export class DownloadManager { ) ); } - if (progress === 1 && game) { publishDownloadCompleteNotification(game); - await downloadQueueRepository.delete({ game }); - const [nextQueueItem] = await downloadQueueRepository.find({ order: { id: "DESC", @@ -61,7 +136,6 @@ export class DownloadManager { game: true, }, }); - if (nextQueueItem) { this.resumeDownload(nextQueueItem.game); } @@ -70,88 +144,80 @@ export class DownloadManager { } public static async getSeedStatus() { - const gamesToSeed = await gameRepository.find({ - where: { shouldSeed: true, isDeleted: false }, - }); - - if (gamesToSeed.length === 0) return; - - const seedStatus = await PythonInstance.getSeedStatus(); - - if (seedStatus.length === 0) { - for (const game of gamesToSeed) { - if (game.uri && game.downloadPath) { - await this.resumeSeeding(game.id, game.uri, game.downloadPath); - } - } - } - - const gameIds = seedStatus.map((status) => status.gameId); - - for (const gameId of gameIds) { - const game = await gameRepository.findOne({ - where: { id: gameId }, - }); - - if (game) { - const isNotDeleted = fs.existsSync( - path.join(game.downloadPath!, game.folderName!) - ); - - if (!isNotDeleted) { - await this.pauseSeeding(game.id); - - await gameRepository.update(game.id, { - status: "complete", - shouldSeed: false, - }); - - WindowManager.mainWindow?.webContents.send("on-hard-delete"); - } - } - } - - const updateList = await gameRepository.find({ - where: { - id: In(gameIds), - status: Not(In(["complete", "seeding"])), - shouldSeed: true, - isDeleted: false, - }, - }); - - if (updateList.length > 0) { - await gameRepository.update( - { id: In(updateList.map((game) => game.id)) }, - { status: "seeding" } - ); - } - - WindowManager.mainWindow?.webContents.send( - "on-seeding-status", - JSON.parse(JSON.stringify(seedStatus)) - ); + // const gamesToSeed = await gameRepository.find({ + // where: { shouldSeed: true, isDeleted: false }, + // }); + // if (gamesToSeed.length === 0) return; + // const seedStatus = await PythonRPC.rpc + // .get("/seed-status") + // .then((results) => { + // if (results === null) return []; + // return results.data; + // }); + // if (!seedStatus.length === 0) { + // for (const game of gamesToSeed) { + // if (game.uri && game.downloadPath) { + // await this.resumeSeeding(game.id, game.uri, game.downloadPath); + // } + // } + // } + // const gameIds = seedStatus.map((status) => status.gameId); + // for (const gameId of gameIds) { + // const game = await gameRepository.findOne({ + // where: { id: gameId }, + // }); + // if (game) { + // const isNotDeleted = fs.existsSync( + // path.join(game.downloadPath!, game.folderName!) + // ); + // if (!isNotDeleted) { + // await this.pauseSeeding(game.id); + // await gameRepository.update(game.id, { + // status: "complete", + // shouldSeed: false, + // }); + // WindowManager.mainWindow?.webContents.send("on-hard-delete"); + // } + // } + // } + // const updateList = await gameRepository.find({ + // where: { + // id: In(gameIds), + // status: Not(In(["complete", "seeding"])), + // shouldSeed: true, + // isDeleted: false, + // }, + // }); + // if (updateList.length > 0) { + // await gameRepository.update( + // { id: In(updateList.map((game) => game.id)) }, + // { status: "seeding" } + // ); + // } + // WindowManager.mainWindow?.webContents.send( + // "on-seeding-status", + // JSON.parse(JSON.stringify(seedStatus)) + // ); } static async pauseSeeding(gameId: number) { - await PythonInstance.pauseSeeding(gameId); + // await TorrentDownloader.pauseSeeding(gameId); } static async resumeSeeding(gameId: number, magnet: string, savePath: string) { - await PythonInstance.resumeSeeding(gameId, magnet, savePath); + // await TorrentDownloader.resumeSeeding(gameId, magnet, savePath); } static async pauseDownload() { - if (this.currentDownloader === Downloader.Torrent) { - await PythonInstance.pauseDownload(); - } else if (this.currentDownloader === Downloader.RealDebrid) { - await RealDebridDownloader.pauseDownload(); - } else { - await GenericHttpDownloader.pauseDownload(); - } + await PythonRPC.rpc + .post("/action", { + action: "pause", + game_id: this.downloadingGameId, + } as PauseDownloadPayload) + .catch(() => {}); WindowManager.mainWindow?.setProgressBar(-1); - this.currentDownloader = null; + this.downloadingGameId = null; } @@ -160,16 +226,13 @@ export class DownloadManager { } static async cancelDownload(gameId = this.downloadingGameId!) { - if (this.currentDownloader === Downloader.Torrent) { - PythonInstance.cancelDownload(gameId); - } else if (this.currentDownloader === Downloader.RealDebrid) { - RealDebridDownloader.cancelDownload(gameId); - } else { - GenericHttpDownloader.cancelDownload(gameId); - } + await PythonRPC.rpc.post("/action", { + action: "cancel", + game_id: gameId, + }); WindowManager.mainWindow?.setProgressBar(-1); - this.currentDownloader = null; + this.downloadingGameId = null; } @@ -181,34 +244,57 @@ export class DownloadManager { const token = await GofileApi.authorize(); const downloadLink = await GofileApi.getDownloadLink(id!); - GenericHttpDownloader.startDownload(game, downloadLink, { - Cookie: `accountToken=${token}`, + await PythonRPC.rpc.post("/action", { + action: "start", + game_id: game.id, + url: downloadLink, + save_path: game.downloadPath, + header: `Cookie: accountToken=${token}`, }); break; } case Downloader.PixelDrain: { const id = game!.uri!.split("/").pop(); - await GenericHttpDownloader.startDownload( - game, - `https://pixeldrain.com/api/file/${id}?download` - ); + await PythonRPC.rpc.post("/action", { + action: "start", + game_id: game.id, + url: `https://pixeldrain.com/api/file/${id}?download`, + save_path: game.downloadPath, + }); break; } case Downloader.Qiwi: { const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!); - await GenericHttpDownloader.startDownload(game, downloadUrl); + await PythonRPC.rpc.post("/action", { + action: "start", + game_id: game.id, + url: downloadUrl, + save_path: game.downloadPath, + }); break; } case Downloader.Torrent: - PythonInstance.startDownload(game); + await PythonRPC.rpc.post("/action", { + action: "start", + game_id: game.id, + url: game.uri, + save_path: game.downloadPath, + }); break; - case Downloader.RealDebrid: - RealDebridDownloader.startDownload(game); + case Downloader.RealDebrid: { + const downloadUrl = await RealDebridClient.getDownloadUrl(game.uri!); + + await PythonRPC.rpc.post("/action", { + action: "start", + game_id: game.id, + url: downloadUrl, + save_path: game.downloadPath, + }); + } } - this.currentDownloader = game.downloader; this.downloadingGameId = game.id; } } diff --git a/src/main/services/download/generic-http-downloader.ts b/src/main/services/download/generic-http-downloader.ts deleted file mode 100644 index 055c8561..00000000 --- a/src/main/services/download/generic-http-downloader.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Game } from "@main/entity"; -import { gameRepository } from "@main/repository"; -import { calculateETA } from "./helpers"; -import { DownloadProgress } from "@types"; -import { HttpDownload } from "./http-download"; - -export class GenericHttpDownloader { - public static downloads = new Map(); - public static downloadingGame: Game | null = null; - - public static async getStatus() { - if (this.downloadingGame) { - const download = this.downloads.get(this.downloadingGame.id)!; - const status = download.getStatus(); - - if (status) { - const progress = - Number(status.completedLength) / Number(status.totalLength); - - await gameRepository.update( - { id: this.downloadingGame!.id }, - { - bytesDownloaded: Number(status.completedLength), - fileSize: Number(status.totalLength), - progress, - status: "active", - folderName: status.folderName, - } - ); - - const result = { - numPeers: 0, - numSeeds: 0, - downloadSpeed: status.downloadSpeed, - timeRemaining: calculateETA( - status.totalLength, - status.completedLength, - status.downloadSpeed - ), - isDownloadingMetadata: false, - isCheckingFiles: false, - progress, - gameId: this.downloadingGame!.id, - } as DownloadProgress; - - if (progress === 1) { - this.downloads.delete(this.downloadingGame.id); - this.downloadingGame = null; - } - - return result; - } - } - - return null; - } - - static async pauseDownload() { - if (this.downloadingGame) { - const httpDownload = this.downloads.get(this.downloadingGame!.id!); - - if (httpDownload) { - await httpDownload.pauseDownload(); - } - - this.downloadingGame = null; - } - } - - static async startDownload( - game: Game, - downloadUrl: string, - headers?: Record - ) { - this.downloadingGame = game; - - if (this.downloads.has(game.id)) { - await this.resumeDownload(game.id!); - return; - } - - const httpDownload = new HttpDownload( - game.downloadPath!, - downloadUrl, - headers - ); - - httpDownload.startDownload(); - - this.downloads.set(game.id!, httpDownload); - } - - static async cancelDownload(gameId: number) { - const httpDownload = this.downloads.get(gameId); - - if (httpDownload) { - await httpDownload.cancelDownload(); - this.downloads.delete(gameId); - } - } - - static async resumeDownload(gameId: number) { - const httpDownload = this.downloads.get(gameId); - - if (httpDownload) { - await httpDownload.resumeDownload(); - } - } -} diff --git a/src/main/services/download/http-download.ts b/src/main/services/download/http-download.ts deleted file mode 100644 index 4f6c31a9..00000000 --- a/src/main/services/download/http-download.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { WindowManager } from "../window-manager"; -import path from "node:path"; - -export class HttpDownload { - private downloadItem: Electron.DownloadItem; - - constructor( - private downloadPath: string, - private downloadUrl: string, - private headers?: Record - ) {} - - public getStatus() { - return { - completedLength: this.downloadItem.getReceivedBytes(), - totalLength: this.downloadItem.getTotalBytes(), - downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(), - folderName: this.downloadItem.getFilename(), - }; - } - - async cancelDownload() { - this.downloadItem.cancel(); - } - - async pauseDownload() { - this.downloadItem.pause(); - } - - async resumeDownload() { - this.downloadItem.resume(); - } - - async startDownload() { - return new Promise((resolve) => { - const options = this.headers ? { headers: this.headers } : {}; - WindowManager.mainWindow?.webContents.downloadURL( - this.downloadUrl, - options - ); - - WindowManager.mainWindow?.webContents.session.once( - "will-download", - (_event, item, _webContents) => { - this.downloadItem = item; - - item.setSavePath(path.join(this.downloadPath, item.getFilename())); - - resolve(null); - } - ); - }); - } -} diff --git a/src/main/services/download/index.ts b/src/main/services/download/index.ts index 26d21799..b8396b66 100644 --- a/src/main/services/download/index.ts +++ b/src/main/services/download/index.ts @@ -1,2 +1 @@ export * from "./download-manager"; -export * from "./python-instance"; diff --git a/src/main/services/download/python-instance.ts b/src/main/services/download/python-instance.ts deleted file mode 100644 index 69b4472d..00000000 --- a/src/main/services/download/python-instance.ts +++ /dev/null @@ -1,236 +0,0 @@ -import cp from "node:child_process"; - -import { Game } from "@main/entity"; -import { - RPC_PASSWORD, - RPC_PORT, - startTorrentClient as startRPCClient, -} from "./torrent-client"; -import { gameRepository, userPreferencesRepository } from "@main/repository"; -import type { DownloadProgress } from "@types"; -import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; -import { calculateETA } from "./helpers"; -import axios from "axios"; -import { - CancelDownloadPayload, - StartDownloadPayload, - PauseDownloadPayload, - LibtorrentStatus, - LibtorrentPayload, - ProcessPayload, - PauseSeedingPayload, - ResumeSeedingPayload, -} from "./types"; -import { pythonInstanceLogger as logger } from "../logger"; - -export class PythonInstance { - private static pythonProcess: cp.ChildProcess | null = null; - private static downloadingGameId = -1; - - private static rpc = axios.create({ - baseURL: `http://localhost:${RPC_PORT}`, - headers: { - "x-hydra-rpc-password": RPC_PASSWORD, - }, - }); - - public static spawn(args?: StartDownloadPayload) { - logger.log("spawning python process with args:", args); - this.pythonProcess = startRPCClient(args); - } - - public static kill() { - if (this.pythonProcess) { - logger.log("killing python process"); - this.pythonProcess.kill(); - this.pythonProcess = null; - this.downloadingGameId = -1; - } - } - - public static killTorrent() { - if (this.pythonProcess) { - logger.log("killing torrent in python process"); - this.rpc.post("/action", { action: "kill-torrent" }); - this.downloadingGameId = -1; - } - } - - public static async getProcessList() { - return ( - (await this.rpc.get("/process-list")).data || [] - ); - } - - public static async getStatus() { - if (this.downloadingGameId === -1) return null; - - const response = await this.rpc.get("/status"); - - if (response.data === null) return null; - - try { - const { - progress, - numPeers, - numSeeds, - downloadSpeed, - bytesDownloaded, - fileSize, - folderName, - status, - gameId, - } = response.data; - - this.downloadingGameId = gameId; - - const isDownloadingMetadata = - status === LibtorrentStatus.DownloadingMetadata; - - const isCheckingFiles = status === LibtorrentStatus.CheckingFiles; - - if (!isDownloadingMetadata && !isCheckingFiles) { - const update: QueryDeepPartialEntity = { - bytesDownloaded, - fileSize, - progress, - status: "active", - }; - - await gameRepository.update( - { id: gameId }, - { - ...update, - folderName, - } - ); - } - - if (progress === 1 && !isCheckingFiles) { - const userPreferences = await userPreferencesRepository.findOneBy({ - id: 1, - }); - - if (userPreferences?.seedAfterDownloadComplete) { - gameRepository.update( - { id: gameId }, - { status: "seeding", shouldSeed: true } - ); - } else { - gameRepository.update( - { id: gameId }, - { status: "complete", shouldSeed: false } - ); - - this.pauseSeeding(gameId); - } - - this.downloadingGameId = -1; - } - - return { - numPeers, - numSeeds, - downloadSpeed, - timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed), - isDownloadingMetadata, - isCheckingFiles, - progress, - gameId, - } as DownloadProgress; - } catch (err) { - return null; - } - } - - public static async getSeedStatus() { - const response = await this.rpc.get( - "/seed-status" - ); - - if (response.data === null) return []; - - return response.data; - } - - static async pauseSeeding(gameId: number) { - await this.rpc - .post("/action", { - action: "pause-seeding", - game_id: gameId, - } as PauseSeedingPayload) - .catch(() => {}); - } - - static async resumeSeeding(gameId: number, magnet: string, savePath: string) { - await this.rpc - .post("/action", { - action: "resume-seeding", - game_id: gameId, - magnet, - save_path: savePath, - } as ResumeSeedingPayload) - .catch(() => {}); - } - - static async pauseDownload() { - await this.rpc - .post("/action", { - action: "pause", - game_id: this.downloadingGameId, - } as PauseDownloadPayload) - .catch(() => {}); - - this.downloadingGameId = -1; - } - - static async startDownload(game: Game) { - if (!this.pythonProcess) { - this.spawn({ - game_id: game.id, - magnet: game.uri!, - save_path: game.downloadPath!, - }); - } else { - await this.rpc - .post("/action", { - action: "start", - game_id: game.id, - magnet: game.uri, - save_path: game.downloadPath, - } as StartDownloadPayload) - .catch(this.handleRpcError); - } - - this.downloadingGameId = game.id; - } - - static async cancelDownload(gameId: number) { - await this.rpc - .post("/action", { - action: "cancel", - game_id: gameId, - } as CancelDownloadPayload) - .catch(() => {}); - - this.downloadingGameId = -1; - } - - static async processProfileImage(imagePath: string) { - return this.rpc - .post<{ imagePath: string; mimeType: string }>("/profile-image", { - image_path: imagePath, - }) - .then((response) => response.data); - } - - private static async handleRpcError(_error: unknown) { - await this.rpc.get("/healthcheck").catch(() => { - logger.error( - "RPC healthcheck failed. Killing process and starting again" - ); - this.kill(); - this.spawn(); - }); - } -} diff --git a/src/main/services/download/real-debrid-downloader.ts b/src/main/services/download/real-debrid-downloader.ts deleted file mode 100644 index 2818644a..00000000 --- a/src/main/services/download/real-debrid-downloader.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Game } from "@main/entity"; -import { RealDebridClient } from "../real-debrid"; -import { HttpDownload } from "./http-download"; -import { GenericHttpDownloader } from "./generic-http-downloader"; - -export class RealDebridDownloader extends GenericHttpDownloader { - private static realDebridTorrentId: string | null = null; - - private static async getRealDebridDownloadUrl() { - if (this.realDebridTorrentId) { - let torrentInfo = await RealDebridClient.getTorrentInfo( - this.realDebridTorrentId - ); - - if (torrentInfo.status === "waiting_files_selection") { - await RealDebridClient.selectAllFiles(this.realDebridTorrentId); - - torrentInfo = await RealDebridClient.getTorrentInfo( - this.realDebridTorrentId - ); - } - - const { links, status } = torrentInfo; - - if (status === "downloaded") { - const [link] = links; - - const { download } = await RealDebridClient.unrestrictLink(link); - return decodeURIComponent(download); - } - - return null; - } - - if (this.downloadingGame?.uri) { - const { download } = await RealDebridClient.unrestrictLink( - this.downloadingGame?.uri - ); - - return decodeURIComponent(download); - } - - return null; - } - - static async startDownload(game: Game) { - if (this.downloads.has(game.id)) { - await this.resumeDownload(game.id!); - this.downloadingGame = game; - return; - } - - if (game.uri?.startsWith("magnet:")) { - this.realDebridTorrentId = await RealDebridClient.getTorrentId( - game!.uri! - ); - } - - this.downloadingGame = game; - - const downloadUrl = await this.getRealDebridDownloadUrl(); - - if (downloadUrl) { - this.realDebridTorrentId = null; - - const httpDownload = new HttpDownload(game.downloadPath!, downloadUrl); - httpDownload.startDownload(); - - this.downloads.set(game.id!, httpDownload); - } - } -} diff --git a/src/main/services/download/torrent-client.ts b/src/main/services/download/torrent-client.ts deleted file mode 100644 index 2a16acad..00000000 --- a/src/main/services/download/torrent-client.ts +++ /dev/null @@ -1,77 +0,0 @@ -import path from "node:path"; -import cp from "node:child_process"; -import crypto from "node:crypto"; -import fs from "node:fs"; -import { app, dialog } from "electron"; -import type { StartDownloadPayload } from "./types"; -import { Readable } from "node:stream"; -import { pythonInstanceLogger as logger } from "../logger"; - -const binaryNameByPlatform: Partial> = { - darwin: "hydra-download-manager", - linux: "hydra-download-manager", - win32: "hydra-download-manager.exe", -}; - -export const BITTORRENT_PORT = "5881"; -export const RPC_PORT = "8084"; -export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex"); - -const logStderr = (readable: Readable | null) => { - if (!readable) return; - - readable.setEncoding("utf-8"); - readable.on("data", logger.log); -}; - -export const startTorrentClient = (args?: StartDownloadPayload) => { - const commonArgs = [ - BITTORRENT_PORT, - RPC_PORT, - RPC_PASSWORD, - args ? encodeURIComponent(JSON.stringify(args)) : "", - ]; - - 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(); - } - - const childProcess = cp.spawn(binaryPath, commonArgs, { - windowsHide: true, - stdio: ["inherit", "inherit"], - }); - - logStderr(childProcess.stderr); - - return childProcess; - } else { - const scriptPath = path.join( - __dirname, - "..", - "..", - "torrent-client", - "main.py" - ); - - const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], { - stdio: ["inherit", "inherit"], - }); - - logStderr(childProcess.stderr); - - return childProcess; - } -}; diff --git a/src/main/services/download/types.ts b/src/main/services/download/types.ts index 2d992735..65beb72f 100644 --- a/src/main/services/download/types.ts +++ b/src/main/services/download/types.ts @@ -1,9 +1,3 @@ -export interface StartDownloadPayload { - game_id: number; - magnet: string; - save_path: string; -} - export interface PauseDownloadPayload { game_id: number; } diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index f642f43b..93ccc74e 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -30,7 +30,7 @@ export class HydraApi { private static instance: AxiosInstance; private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes - private static readonly ADD_LOG_INTERCEPTOR = true; + private static readonly ADD_LOG_INTERCEPTOR = false; private static secondsToMilliseconds = (seconds: number) => seconds * 1000; diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 498159c9..5aaf5322 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -1,7 +1,6 @@ export * from "./logger"; export * from "./steam"; export * from "./steam-250"; -export * from "./steam-grid"; export * from "./window-manager"; export * from "./download"; export * from "./process-watcher"; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 51b39bbe..770a688c 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -3,7 +3,7 @@ import { gameRepository } from "@main/repository"; import { WindowManager } from "./window-manager"; import { createGame, updateGamePlaytime } from "./library-sync"; import type { GameRunning } from "@types"; -import { PythonInstance } from "./download"; +// import { PythonInstance } from "./download"; import { Game } from "@main/entity"; export const gamesPlaytime = new Map< @@ -14,69 +14,7 @@ export const gamesPlaytime = new Map< const TICKS_TO_UPDATE_API = 120; let currentTick = 1; -export const watchProcesses = async () => { - const games = await gameRepository.find({ - where: { - executablePath: Not(IsNull()), - isDeleted: false, - }, - }); - - if (games.length === 0) return; - const processes = await PythonInstance.getProcessList(); - - const processSet = new Set(processes.map((process) => process.exe)); - - for (const game of games) { - const executablePath = game.executablePath!; - - const gameProcess = processSet.has(executablePath); - - if (gameProcess) { - if (gamesPlaytime.has(game.id)) { - onTickGame(game); - } else { - onOpenGame(game); - } - } else if (gamesPlaytime.has(game.id)) { - onCloseGame(game); - } - } - - currentTick++; - - if (WindowManager.mainWindow) { - const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => { - return { - id: entry[0], - sessionDurationInMillis: performance.now() - entry[1].firstTick, - }; - }); - - WindowManager.mainWindow.webContents.send( - "on-games-running", - gamesRunning as Pick[] - ); - } -}; - -function onOpenGame(game: Game) { - const now = performance.now(); - - gamesPlaytime.set(game.id, { - lastTick: now, - firstTick: now, - lastSyncTick: now, - }); - - if (game.remoteId) { - updateGamePlaytime(game, 0, new Date()).catch(() => {}); - } else { - createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {}); - } -} - -function onTickGame(game: Game) { +const onGameTick = (game: Game) => { const now = performance.now(); const gamePlaytime = gamesPlaytime.get(game.id)!; @@ -110,7 +48,23 @@ function onTickGame(game: Game) { }) .catch(() => {}); } -} +}; + +const onOpenGame = (game: Game) => { + const now = performance.now(); + + gamesPlaytime.set(game.id, { + lastTick: now, + firstTick: now, + lastSyncTick: now, + }); + + if (game.remoteId) { + updateGamePlaytime(game, 0, new Date()).catch(() => {}); + } else { + createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {}); + } +}; const onCloseGame = (game: Game) => { const gamePlaytime = gamesPlaytime.get(game.id)!; @@ -126,3 +80,50 @@ const onCloseGame = (game: Game) => { createGame(game).catch(() => {}); } }; + +export const watchProcesses = async () => { + const games = await gameRepository.find({ + where: { + executablePath: Not(IsNull()), + isDeleted: false, + }, + }); + + if (games.length === 0) return; + // const processes = await PythonInstance.getProcessList(); + const processes = []; + + const processSet = new Set(processes.map((process) => process.exe)); + + for (const game of games) { + const executablePath = game.executablePath!; + + const gameProcess = processSet.has(executablePath); + + if (gameProcess) { + if (gamesPlaytime.has(game.id)) { + onGameTick(game); + } else { + onOpenGame(game); + } + } else if (gamesPlaytime.has(game.id)) { + onCloseGame(game); + } + } + + currentTick++; + + if (WindowManager.mainWindow) { + const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => { + return { + id: entry[0], + sessionDurationInMillis: performance.now() - entry[1].firstTick, + }; + }); + + WindowManager.mainWindow.webContents.send( + "on-games-running", + gamesRunning as Pick[] + ); + } +}; diff --git a/src/main/services/real-debrid.ts b/src/main/services/real-debrid.ts deleted file mode 100644 index 26ba4c79..00000000 --- a/src/main/services/real-debrid.ts +++ /dev/null @@ -1,86 +0,0 @@ -import axios, { AxiosInstance } from "axios"; -import parseTorrent from "parse-torrent"; -import type { - RealDebridAddMagnet, - RealDebridTorrentInfo, - RealDebridUnrestrictLink, - RealDebridUser, -} from "@types"; - -export class RealDebridClient { - private static instance: AxiosInstance; - private static baseURL = "https://api.real-debrid.com/rest/1.0"; - - static authorize(apiToken: string) { - this.instance = axios.create({ - baseURL: this.baseURL, - headers: { - Authorization: `Bearer ${apiToken}`, - }, - }); - } - - static async addMagnet(magnet: string) { - const searchParams = new URLSearchParams({ magnet }); - - const response = await this.instance.post( - "/torrents/addMagnet", - searchParams.toString() - ); - - return response.data; - } - - static async getTorrentInfo(id: string) { - const response = await this.instance.get( - `/torrents/info/${id}` - ); - return response.data; - } - - static async getUser() { - const response = await this.instance.get(`/user`); - return response.data; - } - - static async selectAllFiles(id: string) { - const searchParams = new URLSearchParams({ files: "all" }); - - return this.instance.post( - `/torrents/selectFiles/${id}`, - searchParams.toString() - ); - } - - static async unrestrictLink(link: string) { - const searchParams = new URLSearchParams({ link }); - - const response = await this.instance.post( - "/unrestrict/link", - searchParams.toString() - ); - - return response.data; - } - - private static async getAllTorrentsFromUser() { - const response = - await this.instance.get("/torrents"); - - return response.data; - } - - static async getTorrentId(magnetUri: string) { - const userTorrents = await RealDebridClient.getAllTorrentsFromUser(); - - const { infoHash } = await parseTorrent(magnetUri); - const userTorrent = userTorrents.find( - (userTorrent) => userTorrent.hash === infoHash - ); - - if (userTorrent) return userTorrent.id; - - const torrent = await RealDebridClient.addMagnet(magnetUri); - return torrent.id; - } -} diff --git a/src/main/services/steam-grid.ts b/src/main/services/steam-grid.ts deleted file mode 100644 index 540e5857..00000000 --- a/src/main/services/steam-grid.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { GameShop } from "@types"; -import axios from "axios"; - -export interface SteamGridResponse { - success: boolean; - data: { - id: number; - }; -} - -export interface SteamGridGameResponse { - data: { - platforms: { - steam: { - metadata: { - clienticon: string; - }; - }; - }; - }; -} - -export const getSteamGridData = async ( - objectId: string, - path: string, - shop: GameShop, - params: Record = {} -): Promise => { - const searchParams = new URLSearchParams(params); - - if (!import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY) { - throw new Error("MAIN_VITE_STEAMGRIDDB_API_KEY is not set"); - } - - const response = await axios.get( - `https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectId}?${searchParams.toString()}`, - { - headers: { - Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`, - }, - } - ); - - return response.data; -}; - -export const getSteamGridGameById = async ( - id: number -): Promise => { - const response = await axios.get( - `https://www.steamgriddb.com/api/public/game/${id}`, - { - headers: { - Referer: "https://www.steamgriddb.com/", - }, - } - ); - - return response.data; -}; - -export const getSteamGameClientIcon = async (objectId: string) => { - const { - data: { id: steamGridGameId }, - } = await getSteamGridData(objectId, "games", "steam"); - - const steamGridGame = await getSteamGridGameById(steamGridGameId); - return steamGridGame.data.platforms.steam.metadata.clienticon; -}; diff --git a/src/main/vite-env.d.ts b/src/main/vite-env.d.ts index 86aa9d33..141cc94a 100644 --- a/src/main/vite-env.d.ts +++ b/src/main/vite-env.d.ts @@ -1,7 +1,6 @@ /// interface ImportMetaEnv { - readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string; readonly MAIN_VITE_API_URL: string; readonly MAIN_VITE_ANALYTICS_API_URL: string; readonly MAIN_VITE_AUTH_URL: string; diff --git a/src/renderer/index.html b/src/renderer/index.html index ffc16195..bfc3a206 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,45 +6,8 @@ Hydra - - -
diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index b0124a50..5c5a121a 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -127,7 +127,7 @@ export default function Downloads() {