diff --git a/.gitignore b/.gitignore index 675a83ee..95835897 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules hydra-download-manager fastlist.exe +unrar.wasm __pycache__ dist out diff --git a/README.md b/README.md index 81738c2d..2323c2d6 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,13 @@ yarn make Null + + + lilezek +
+ Null +
+ JackEnx @@ -118,21 +125,14 @@ yarn make
Magrid
- + + fhilipecrash
Fhilipe Coelho
- - - - - jps14 -
- José Luís -
@@ -141,6 +141,13 @@ yarn make Null + + + jps14 +
+ José Luís +
+ pmenta @@ -161,15 +168,15 @@ yarn make
Guilherme Viana
- + + eltociear
Ikko Eltociear Ashimine
- - + Netflixyapp diff --git a/electron-builder.yml b/electron-builder.yml index 06f6a5c5..83c7c80a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -6,6 +6,7 @@ extraResources: - hydra-download-manager - hydra.db - fastlist.exe + - unrar.wasm files: - "!**/.vscode/*" - "!src/*" diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 733dcb89..5140f366 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -31,6 +31,7 @@ export default defineConfig(({ mode }) => { "@main": resolve("src/main"), "@locales": resolve("src/locales"), "@resources": resolve("resources"), + "@globals": resolve("src/globals"), }, }, plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin], @@ -46,6 +47,7 @@ export default defineConfig(({ mode }) => { alias: { "@renderer": resolve("src/renderer/src"), "@locales": resolve("src/locales"), + "@globals": resolve("src/globals"), }, }, plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin], diff --git a/package.json b/package.json index 7ba9bdf4..678d1bb0 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "classnames": "^2.5.1", "color.js": "^1.2.0", "date-fns": "^3.6.0", + "electron-dl-manager": "^3.0.0", "fetch-cookie": "^3.0.1", "flexsearch": "^0.7.43", "i18next": "^23.11.2", @@ -50,6 +51,7 @@ "jsdom": "^24.0.0", "lodash-es": "^4.17.21", "lottie-react": "^2.4.0", + "node-unrar-js": "^2.0.2", "parse-torrent": "^11.0.16", "ps-list": "^8.1.1", "react-i18next": "^14.1.0", diff --git a/postinstall.cjs b/postinstall.cjs index 8ca8f101..d2c91bca 100644 --- a/postinstall.cjs +++ b/postinstall.cjs @@ -6,3 +6,5 @@ if (process.platform === "win32") { "fastlist.exe" ); } + +fs.copyFileSync("node_modules/node-unrar-js/esm/js/unrar.wasm", "unrar.wasm"); diff --git a/src/globals.ts b/src/globals.ts new file mode 100644 index 00000000..3ce216a8 --- /dev/null +++ b/src/globals.ts @@ -0,0 +1,25 @@ +export enum GameStatus { + Seeding = "seeding", + Downloading = "downloading", + Paused = "paused", + CheckingFiles = "checking_files", + DownloadingMetadata = "downloading_metadata", + Cancelled = "cancelled", + Finished = "finished", + Decompressing = "decompressing", +} + +export namespace GameStatus { + export const isDownloading = (status: GameStatus | null) => + status === GameStatus.Downloading || + status === GameStatus.DownloadingMetadata || + status === GameStatus.CheckingFiles; + + export const isVerifying = (status: GameStatus | null) => + GameStatus.DownloadingMetadata == status || + GameStatus.CheckingFiles == status || + GameStatus.Decompressing == status; + + export const isReady = (status: GameStatus | null) => + status === GameStatus.Finished || status === GameStatus.Seeding; +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b6d1b882..d8182cd3 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -137,6 +137,7 @@ "enable_repack_list_notifications": "When a new repack is added", "telemetry": "Telemetry", "telemetry_description": "Enable anonymous usage statistics", + "real_debrid_api_token_description": "(Optional) Real Debrid API token", "behavior": "Behavior", "quit_app_instead_hiding": "Close app instead of minimizing to tray" }, diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 1a8f5d48..380f7849 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -69,7 +69,7 @@ "remove_from_library": "Eliminar de la biblioteca", "no_downloads": "No hay descargas disponibles", "next_suggestion": "Siguiente sugerencia", - "play_time": "Jugado por {{cantidad}}", + "play_time": "Jugado {{amount}}", "install": "Instalar", "play": "Jugar", "not_played_yet": "Aún no has jugado a {{title}}", @@ -130,7 +130,8 @@ "enable_download_notifications": "Cuando se completa una descarga", "enable_repack_list_notifications": "Cuando se añade un repack nuevo", "telemetry": "Telemetría", - "telemetry_description": "Habilitar recopilación de datos de manera anónima" + "telemetry_description": "Habilitar recopilación de datos de manera anónima", + "real_debrid_api_token_description": "(Opcional) Real Debrid API token" }, "notifications": { "download_complete": "Descarga completada", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 2e17f492..5cc7632b 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -115,7 +115,8 @@ "enable_download_notifications": "Quand un téléchargement est terminé", "enable_repack_list_notifications": "Quand un nouveau repack est ajouté", "telemetry": "Télémétrie", - "telemetry_description": "Activer les statistiques d'utilisation anonymes" + "telemetry_description": "Activer les statistiques d'utilisation anonymes", + "real_debrid_api_token_description": "(Facultatif) Real Debrid API token" }, "notifications": { "download_complete": "Téléchargement terminé", diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 8a370fb2..61a86b6e 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -124,7 +124,8 @@ "enable_download_notifications": "Amikor egy letöltés befejeződik", "enable_repack_list_notifications": "Amikor egy új repack hozzáadásra kerül", "telemetry": "Telemetria", - "telemetry_description": "Névtelen felhasználási statisztikák engedélyezése" + "telemetry_description": "Névtelen felhasználási statisztikák engedélyezése", + "real_debrid_api_token_description": "(Választható) Real Debrid API token" }, "notifications": { "download_complete": "Letöltés befejeződött", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index b4ff3723..ca2a83f5 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -136,7 +136,8 @@ "enable_download_notifications": "Quando un download è completo", "enable_repack_list_notifications": "Quando viene aggiunto un nuovo repack", "telemetry": "Telemetria", - "telemetry_description": "Abilita statistiche di utilizzo anonime" + "telemetry_description": "Abilita statistiche di utilizzo anonime", + "real_debrid_api_token_description": "(Facoltativo) Real Debrid API token" }, "notifications": { "download_complete": "Download completato", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index fce8eb36..a5260614 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -133,6 +133,7 @@ "enable_repack_list_notifications": "Quando a lista de repacks for atualizada", "telemetry": "Telemetria", "telemetry_description": "Habilitar estatísticas de uso anônimas", + "real_debrid_api_token_description": "(Opcional) Real Debrid API token", "behavior": "Comportamento", "quit_app_instead_hiding": "Fechar o aplicativo em vez de minimizá-lo" }, diff --git a/src/main/constants.ts b/src/main/constants.ts index 39da625b..b1b9bcb5 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -33,15 +33,6 @@ export const months = [ "Dec", ]; -export enum GameStatus { - Seeding = "seeding", - Downloading = "downloading", - Paused = "paused", - CheckingFiles = "checking_files", - DownloadingMetadata = "downloading_metadata", - Cancelled = "cancelled", -} - export const defaultDownloadsPath = app.getPath("downloads"); export const databasePath = path.join( diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts index 25ca7495..51e976fa 100644 --- a/src/main/entity/game.entity.ts +++ b/src/main/entity/game.entity.ts @@ -9,6 +9,7 @@ import { } from "typeorm"; import type { GameShop } from "@types"; import { Repack } from "./repack.entity"; +import { GameStatus } from "@globals"; @Entity("game") export class Game { @@ -33,6 +34,9 @@ export class Game { @Column("text", { nullable: true }) executablePath: string | null; + @Column("text", { nullable: true }) + rarPath: string | null; + @Column("int", { default: 0 }) playTimeInMilliseconds: number; @@ -40,14 +44,20 @@ export class Game { shop: GameShop; @Column("text", { nullable: true }) - status: string | null; + status: GameStatus | null; + /** + * Progress is a float between 0 and 1 + */ @Column("float", { default: 0 }) progress: number; @Column("float", { default: 0 }) fileVerificationProgress: number; + @Column("float", { default: 0 }) + decompressionProgress: number; + @Column("int", { default: 0 }) bytesDownloaded: number; diff --git a/src/main/entity/user-preferences.entity.ts b/src/main/entity/user-preferences.entity.ts index 501bd77d..58cce5ce 100644 --- a/src/main/entity/user-preferences.entity.ts +++ b/src/main/entity/user-preferences.entity.ts @@ -17,6 +17,9 @@ export class UserPreferences { @Column("text", { default: "en" }) language: string; + @Column("text", { nullable: true }) + realDebridApiToken: string | null; + @Column("boolean", { default: false }) downloadNotificationsEnabled: boolean; diff --git a/src/main/events/library/delete-game-folder.ts b/src/main/events/library/delete-game-folder.ts index c8821415..e913a23a 100644 --- a/src/main/events/library/delete-game-folder.ts +++ b/src/main/events/library/delete-game-folder.ts @@ -1,7 +1,7 @@ import path from "node:path"; import fs from "node:fs"; -import { GameStatus } from "@main/constants"; +import { GameStatus } from "@globals"; import { gameRepository } from "@main/repository"; import { getDownloadsPath } from "../helpers/get-downloads-path"; diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index be7eb84f..1e74ad81 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -1,8 +1,8 @@ import { gameRepository } from "@main/repository"; -import { GameStatus } from "@main/constants"; import { searchRepacks } from "../helpers/search-games"; import { registerEvent } from "../register-event"; +import { GameStatus } from "@globals"; import { sortBy } from "lodash-es"; const getLibrary = async () => diff --git a/src/main/events/library/remove-game.ts b/src/main/events/library/remove-game.ts index d571e821..6e2ee785 100644 --- a/src/main/events/library/remove-game.ts +++ b/src/main/events/library/remove-game.ts @@ -1,6 +1,6 @@ import { registerEvent } from "../register-event"; import { gameRepository } from "../../repository"; -import { GameStatus } from "@main/constants"; +import { GameStatus } from "@globals"; const removeGame = async ( _event: Electron.IpcMainInvokeEvent, diff --git a/src/main/events/torrenting/cancel-game-download.ts b/src/main/events/torrenting/cancel-game-download.ts index 77e633b0..73ae0bb6 100644 --- a/src/main/events/torrenting/cancel-game-download.ts +++ b/src/main/events/torrenting/cancel-game-download.ts @@ -1,10 +1,11 @@ -import { GameStatus } from "@main/constants"; import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; -import { WindowManager, writePipe } from "@main/services"; +import { WindowManager } from "@main/services"; import { In } from "typeorm"; +import { Downloader } from "@main/services/downloaders/downloader"; +import { GameStatus } from "@globals"; const cancelGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -19,6 +20,8 @@ const cancelGameDownload = async ( GameStatus.CheckingFiles, GameStatus.Paused, GameStatus.Seeding, + GameStatus.Finished, + GameStatus.Decompressing, ]), }, }); @@ -41,7 +44,7 @@ const cancelGameDownload = async ( game.status !== GameStatus.Paused && game.status !== GameStatus.Seeding ) { - writePipe.write({ action: "cancel" }); + Downloader.cancelDownload(); if (result.affected) WindowManager.mainWindow?.setProgressBar(-1); } }); diff --git a/src/main/events/torrenting/pause-game-download.ts b/src/main/events/torrenting/pause-game-download.ts index 943bea37..3d486562 100644 --- a/src/main/events/torrenting/pause-game-download.ts +++ b/src/main/events/torrenting/pause-game-download.ts @@ -1,9 +1,10 @@ -import { WindowManager, writePipe } from "@main/services"; +import { WindowManager } from "@main/services"; import { registerEvent } from "../register-event"; -import { GameStatus } from "../../constants"; import { gameRepository } from "../../repository"; import { In } from "typeorm"; +import { Downloader } from "@main/services/downloaders/downloader"; +import { GameStatus } from "@globals"; const pauseGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -23,7 +24,7 @@ const pauseGameDownload = async ( ) .then((result) => { if (result.affected) { - writePipe.write({ action: "pause" }); + Downloader.pauseDownload(); WindowManager.mainWindow?.setProgressBar(-1); } }); diff --git a/src/main/events/torrenting/resume-game-download.ts b/src/main/events/torrenting/resume-game-download.ts index c1e2e798..03f56a13 100644 --- a/src/main/events/torrenting/resume-game-download.ts +++ b/src/main/events/torrenting/resume-game-download.ts @@ -1,9 +1,9 @@ import { registerEvent } from "../register-event"; -import { GameStatus } from "../../constants"; import { gameRepository } from "../../repository"; import { getDownloadsPath } from "../helpers/get-downloads-path"; import { In } from "typeorm"; -import { writePipe } from "@main/services"; +import { Downloader } from "@main/services/downloaders/downloader"; +import { GameStatus } from "@globals"; const resumeGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -18,17 +18,12 @@ const resumeGameDownload = async ( if (!game) return; - writePipe.write({ action: "pause" }); + Downloader.resumeDownload(); if (game.status === GameStatus.Paused) { const downloadsPath = game.downloadPath ?? (await getDownloadsPath()); - writePipe.write({ - action: "start", - game_id: gameId, - magnet: game.repack.magnet, - save_path: downloadsPath, - }); + Downloader.downloadGame(game, game.repack); await gameRepository.update( { diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 8a42ef70..90716ae3 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -1,12 +1,13 @@ -import { getSteamGameIconUrl, writePipe } from "@main/services"; +import { getSteamGameIconUrl } from "@main/services"; import { gameRepository, repackRepository } from "@main/repository"; -import { GameStatus } from "@main/constants"; import { registerEvent } from "../register-event"; import type { GameShop } from "@types"; import { getFileBase64 } from "@main/helpers"; import { In } from "typeorm"; +import { Downloader } from "@main/services/downloaders/downloader"; +import { GameStatus } from "@globals"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -35,7 +36,7 @@ const startGameDownload = async ( return; } - writePipe.write({ action: "pause" }); + Downloader.pauseDownload(); await gameRepository.update( { @@ -61,12 +62,7 @@ const startGameDownload = async ( } ); - writePipe.write({ - action: "start", - game_id: game.id, - magnet: repack.magnet, - save_path: downloadPath, - }); + Downloader.downloadGame(game, repack); game.status = GameStatus.DownloadingMetadata; @@ -84,12 +80,7 @@ const startGameDownload = async ( repack: { id: repackId }, }); - writePipe.write({ - action: "start", - game_id: createdGame.id, - magnet: repack.magnet, - save_path: downloadPath, - }); + Downloader.downloadGame(createdGame, repack); const { repack: _, ...rest } = createdGame; diff --git a/src/main/main.ts b/src/main/main.ts index ae591720..80f975eb 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,5 +1,5 @@ import { stateManager } from "./state-manager"; -import { GameStatus, repackers } from "./constants"; +import { repackers } from "./constants"; import { getNewGOGGames, getNewRepacksFromCPG, @@ -17,11 +17,13 @@ import { steamGameRepository, userPreferencesRepository, } from "./repository"; -import { TorrentClient } from "./services/torrent-client"; +import { TorrentClient } from "./services/downloaders/torrent-client"; import { Repack } from "./entity"; import { Notification } from "electron"; import { t } from "i18next"; import { In } from "typeorm"; +import { Downloader } from "./services/downloaders/downloader"; +import { GameStatus } from "@globals"; startProcessWatcher(); @@ -40,12 +42,7 @@ Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(async () => { }); if (game) { - writePipe.write({ - action: "start", - game_id: game.id, - magnet: game.repack.magnet, - save_path: game.downloadPath, - }); + Downloader.downloadGame(game, game.repack); } readPipe.socket?.on("data", (data) => { diff --git a/src/main/services/downloaders/downloader.ts b/src/main/services/downloaders/downloader.ts new file mode 100644 index 00000000..da1926f2 --- /dev/null +++ b/src/main/services/downloaders/downloader.ts @@ -0,0 +1,214 @@ +import { Game, Repack } from "@main/entity"; +import { writePipe } from "../fifo"; +import { gameRepository, userPreferencesRepository } from "@main/repository"; +import { RealDebridClient } from "./real-debrid"; +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; +import { t } from "i18next"; +import { Notification } from "electron"; +import { WindowManager } from "../window-manager"; +import { TorrentUpdate } from "./torrent-client"; +import { HTTPDownloader } from "./http-downloader"; +import { Unrar } from "../unrar"; +import { GameStatus } from "@globals"; +import path from "node:path"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import { app } from "electron"; + +interface DownloadStatus { + numPeers: number; + numSeeds: number; + downloadSpeed: number; + timeRemaining: number; +} + +export class Downloader { + private static lastHttpDownloader: HTTPDownloader | null = null; + + static async usesRealDebrid() { + const userPreferences = await userPreferencesRepository.findOne({ + where: { id: 1 }, + }); + return userPreferences!.realDebridApiToken !== null; + } + + static async cancelDownload() { + if (!(await this.usesRealDebrid())) { + writePipe.write({ action: "cancel" }); + } else { + if (this.lastHttpDownloader) { + this.lastHttpDownloader.cancel(); + } + } + } + + static async pauseDownload() { + if (!(await this.usesRealDebrid())) { + writePipe.write({ action: "pause" }); + } else { + if (this.lastHttpDownloader) { + this.lastHttpDownloader.pause(); + } + } + } + + static async resumeDownload() { + if (!(await this.usesRealDebrid())) { + writePipe.write({ action: "pause" }); + } else { + if (this.lastHttpDownloader) { + this.lastHttpDownloader.resume(); + } + } + } + + static async downloadGame(game: Game, repack: Repack) { + if (!(await this.usesRealDebrid())) { + writePipe.write({ + action: "start", + game_id: game.id, + magnet: repack.magnet, + save_path: game.downloadPath, + }); + } else { + try { + // Lets try first to find the torrent on RealDebrid + const torrents = await RealDebridClient.getAllTorrents(); + const hash = RealDebridClient.extractSHA1FromMagnet(repack.magnet); + let torrent = torrents.find((t) => t.hash === hash); + + if (!torrent) { + // Torrent is missing, lets add it + const magnet = await RealDebridClient.addMagnet(repack.magnet); + if (magnet && magnet.id) { + await RealDebridClient.selectAllFiles(magnet.id); + torrent = await RealDebridClient.getInfo(magnet.id); + } + } + + if (torrent) { + const { links } = torrent; + const { download } = await RealDebridClient.unrestrictLink(links[0]); + this.lastHttpDownloader = new HTTPDownloader(); + this.lastHttpDownloader.download( + download, + game.downloadPath!, + game.id + ); + } + } catch (e) { + console.error(e); + } + } + } + + static async updateGameProgress( + gameId: number, + gameUpdate: QueryDeepPartialEntity, + downloadStatus: DownloadStatus + ) { + await gameRepository.update({ id: gameId }, gameUpdate); + + const game = await gameRepository.findOne({ + where: { id: gameId }, + relations: { repack: true }, + }); + + if ( + game?.progress === 1 && + gameUpdate.status !== GameStatus.Decompressing + ) { + const userPreferences = await userPreferencesRepository.findOne({ + where: { id: 1 }, + }); + + if (userPreferences?.downloadNotificationsEnabled) { + const iconPath = await this.createTempIcon(game.iconUrl); + + new Notification({ + icon: iconPath, + 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 ( + game && + gameUpdate.decompressionProgress === 0 && + gameUpdate.status === GameStatus.Decompressing + ) { + const unrar = await Unrar.fromFilePath( + game.rarPath!, + path.join(game.downloadPath!, game.folderName!) + ); + unrar.extract(); + this.updateGameProgress( + gameId, + { + decompressionProgress: 1, + status: GameStatus.Finished, + }, + downloadStatus + ); + } + + 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, + }) + ) + ); + } + } + + static getGameProgress(game: Game) { + if (game.status === GameStatus.CheckingFiles) + return game.fileVerificationProgress; + if (game.status === GameStatus.Decompressing) + return game.decompressionProgress; + return game.progress; + } + + private static createTempIcon(encodedIcon: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.randomBytes(16).toString("hex"); + const iconPath = path.join(app.getPath("temp"), `${hash}.png`); + + fs.writeFile( + iconPath, + Buffer.from( + encodedIcon.replace("data:image/jpeg;base64,", ""), + "base64" + ), + (err) => { + if (err) reject(err); + resolve(iconPath); + } + ); + }); + } +} diff --git a/src/main/services/downloaders/http-downloader.ts b/src/main/services/downloaders/http-downloader.ts new file mode 100644 index 00000000..c94a5755 --- /dev/null +++ b/src/main/services/downloaders/http-downloader.ts @@ -0,0 +1,106 @@ +import { Game } from "@main/entity"; +import { ElectronDownloadManager } from "electron-dl-manager"; +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; +import { WindowManager } from "../window-manager"; +import { Downloader } from "./downloader"; +import { GameStatus } from "@globals"; + +function dropExtension(fileName: string) { + return fileName.split(".").slice(0, -1).join("."); +} + +export class HTTPDownloader { + private downloadManager: ElectronDownloadManager; + private downloadId: string | null = null; + + constructor() { + this.downloadManager = new ElectronDownloadManager(); + } + + async download(url: string, destination: string, gameId: number) { + const window = WindowManager.mainWindow; + + this.downloadId = await this.downloadManager.download({ + url, + window: window!, + callbacks: { + onDownloadStarted: async (ev) => { + const updatePayload: QueryDeepPartialEntity = { + status: GameStatus.Downloading, + progress: 0, + bytesDownloaded: 0, + fileSize: ev.item.getTotalBytes(), + rarPath: `${destination}/.rd/${ev.resolvedFilename}`, + folderName: dropExtension(ev.resolvedFilename), + }; + const downloadStatus = { + numPeers: 0, + numSeeds: 0, + downloadSpeed: 0, + timeRemaining: Number.POSITIVE_INFINITY, + }; + await Downloader.updateGameProgress( + gameId, + updatePayload, + downloadStatus + ); + }, + onDownloadCompleted: async (ev) => { + const updatePayload: QueryDeepPartialEntity = { + progress: 1, + decompressionProgress: 0, + bytesDownloaded: ev.item.getReceivedBytes(), + status: GameStatus.Decompressing, + }; + const downloadStatus = { + numPeers: 1, + numSeeds: 1, + downloadSpeed: 0, + timeRemaining: 0, + }; + await Downloader.updateGameProgress( + gameId, + updatePayload, + downloadStatus + ); + }, + onDownloadProgress: async (ev) => { + const updatePayload: QueryDeepPartialEntity = { + progress: ev.percentCompleted / 100, + bytesDownloaded: ev.item.getReceivedBytes(), + }; + const downloadStatus = { + numPeers: 1, + numSeeds: 1, + downloadSpeed: ev.downloadRateBytesPerSecond, + timeRemaining: ev.estimatedTimeRemainingSeconds, + }; + await Downloader.updateGameProgress( + gameId, + updatePayload, + downloadStatus + ); + }, + }, + directory: `${destination}/.rd/`, + }); + } + + pause() { + if (this.downloadId) { + this.downloadManager.pauseDownload(this.downloadId); + } + } + + cancel() { + if (this.downloadId) { + this.downloadManager.cancelDownload(this.downloadId); + } + } + + resume() { + if (this.downloadId) { + this.downloadManager.resumeDownload(this.downloadId); + } + } +} diff --git a/src/main/services/downloaders/real-debrid-types.ts b/src/main/services/downloaders/real-debrid-types.ts new file mode 100644 index 00000000..6a3e7304 --- /dev/null +++ b/src/main/services/downloaders/real-debrid-types.ts @@ -0,0 +1,53 @@ +export interface RealDebridUnrestrictLink { + id: string; + filename: string; + mimeType: string; + filesize: number; + link: string; + host: string; + host_icon: string; + chunks: number; + crc: number; + download: string; + streamable: number; +} + +export interface RealDebridAddMagnet { + id: string; + // URL of the created ressource + uri: string; +} + +export interface RealDebridTorrentInfo { + id: string; + filename: string; + original_filename: string; // Original name of the torrent + hash: string; // SHA1 Hash of the torrent + bytes: number; // Size of selected files only + original_bytes: number; // Total size of the torrent + host: string; // Host main domain + split: number; // Split size of links + progress: number; // Possible values: 0 to 100 + status: "downloaded"; // Current status of the torrent: magnet_error, magnet_conversion, waiting_files_selection, queued, downloading, downloaded, error, virus, compressing, uploading, dead + added: string; // jsonDate + files: [ + { + id: number; + path: string; // Path to the file inside the torrent, starting with "/" + bytes: number; + selected: number; // 0 or 1 + }, + { + id: number; + path: string; // Path to the file inside the torrent, starting with "/" + bytes: number; + selected: number; // 0 or 1 + }, + ]; + links: [ + "string", // Host URL + ]; + ended: string; // !! Only present when finished, jsonDate + speed: number; // !! Only present in "downloading", "compressing", "uploading" status + seeders: number; // !! Only present in "downloading", "magnet_conversion" status +} diff --git a/src/main/services/downloaders/real-debrid.ts b/src/main/services/downloaders/real-debrid.ts new file mode 100644 index 00000000..7f9ccb48 --- /dev/null +++ b/src/main/services/downloaders/real-debrid.ts @@ -0,0 +1,74 @@ +import { userPreferencesRepository } from "@main/repository"; +import { + RealDebridAddMagnet, + RealDebridTorrentInfo, + RealDebridUnrestrictLink, +} from "./real-debrid-types"; + +const base = "https://api.real-debrid.com/rest/1.0"; + +export class RealDebridClient { + static async addMagnet(magnet: string) { + const response = await fetch(`${base}/torrents/addMagnet`, { + method: "POST", + headers: { + Authorization: `Bearer ${await this.getApiToken()}`, + }, + body: `magnet=${encodeURIComponent(magnet)}`, + }); + + return response.json() as Promise; + } + + static async getInfo(id: string) { + const response = await fetch(`${base}/torrents/info/${id}`, { + headers: { + Authorization: `Bearer ${await this.getApiToken()}`, + }, + }); + + return response.json() as Promise; + } + + static async selectAllFiles(id: string) { + await fetch(`${base}/torrents/selectFiles/${id}`, { + method: "POST", + headers: { + Authorization: `Bearer ${await this.getApiToken()}`, + }, + body: "files=all", + }); + } + + static async unrestrictLink(link: string) { + const response = await fetch(`${base}/unrestrict/link`, { + method: "POST", + headers: { + Authorization: `Bearer ${await this.getApiToken()}`, + }, + body: `link=${link}`, + }); + + return response.json() as Promise; + } + + static async getAllTorrents() { + const response = await fetch(`${base}/torrents`, { + headers: { + Authorization: `Bearer ${await this.getApiToken()}`, + }, + }); + + return response.json() as Promise; + } + + static getApiToken() { + return userPreferencesRepository + .findOne({ where: { id: 1 } }) + .then((userPreferences) => userPreferences!.realDebridApiToken); + } + + static extractSHA1FromMagnet(magnet: string) { + return magnet.match(/btih:([0-9a-fA-F]*)/)?.[1].toLowerCase(); + } +} diff --git a/src/main/services/torrent-client.ts b/src/main/services/downloaders/torrent-client.ts similarity index 61% rename from src/main/services/torrent-client.ts rename to src/main/services/downloaders/torrent-client.ts index 2743c82a..f5c5cdd8 100644 --- a/src/main/services/torrent-client.ts +++ b/src/main/services/downloaders/torrent-client.ts @@ -2,13 +2,12 @@ import path from "node:path"; import cp from "node:child_process"; import fs from "node:fs"; import * as Sentry from "@sentry/electron/main"; -import { Notification, app, dialog } from "electron"; +import { app, dialog } from "electron"; import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; import { Game } from "@main/entity"; -import { gameRepository, userPreferencesRepository } from "@main/repository"; -import { t } from "i18next"; -import { WindowManager } from "./window-manager"; +import { Downloader } from "./downloader"; +import { GameStatus } from "@globals"; const binaryNameByPlatform: Partial> = { darwin: "hydra-download-manager", @@ -74,6 +73,7 @@ export class TorrentClient { __dirname, "..", "..", + "..", "torrent-client", "main.py" ); @@ -84,18 +84,13 @@ export class TorrentClient { } private static getTorrentStateName(state: TorrentState) { - if (state === TorrentState.CheckingFiles) return "checking_files"; - if (state === TorrentState.Downloading) return "downloading"; + if (state === TorrentState.CheckingFiles) return GameStatus.CheckingFiles; + if (state === TorrentState.Downloading) return GameStatus.Downloading; if (state === TorrentState.DownloadingMetadata) - return "downloading_metadata"; - if (state === TorrentState.Finished) return "finished"; - if (state === TorrentState.Seeding) return "seeding"; - return ""; - } - - private static getGameProgress(game: Game) { - if (game.status === "checking_files") return game.fileVerificationProgress; - return game.progress; + return GameStatus.DownloadingMetadata; + if (state === TorrentState.Finished) return GameStatus.Finished; + if (state === TorrentState.Seeding) return GameStatus.Seeding; + return null; } public static async onSocketData(data: Buffer) { @@ -126,42 +121,12 @@ export class TorrentClient { updatePayload.progress = payload.progress; } - await gameRepository.update({ id: payload.gameId }, updatePayload); - - const game = await gameRepository.findOne({ - where: { id: payload.gameId }, - relations: { repack: true }, + Downloader.updateGameProgress(payload.gameId, updatePayload, { + numPeers: payload.numPeers, + numSeeds: payload.numSeeds, + downloadSpeed: payload.downloadSpeed, + timeRemaining: payload.timeRemaining, }); - - 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({ ...payload, game })) - ); - } } catch (err) { Sentry.captureException(err); } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 2544c6f4..7db479a0 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -6,6 +6,6 @@ export * from "./steam-grid"; export * from "./update-resolver"; export * from "./window-manager"; export * from "./fifo"; -export * from "./torrent-client"; +export * from "./downloaders/torrent-client"; export * from "./how-long-to-beat"; export * from "./process-watcher"; diff --git a/src/main/services/unrar.ts b/src/main/services/unrar.ts new file mode 100644 index 00000000..992f2377 --- /dev/null +++ b/src/main/services/unrar.ts @@ -0,0 +1,30 @@ +import { Extractor, createExtractorFromFile } from "node-unrar-js"; +import fs from "node:fs"; +import path from "node:path"; +import { app } from "electron"; + +const wasmPath = app.isPackaged + ? path.join(process.resourcesPath, "unrar.wasm") + : path.join(__dirname, "..", "..", "unrar.wasm"); + +const wasmBinary = fs.readFileSync(require.resolve(wasmPath)); + +export class Unrar { + private constructor(private extractor: Extractor) {} + + static async fromFilePath(filePath: string, targetFolder: string) { + const extractor = await createExtractorFromFile({ + filepath: filePath, + targetPath: targetFolder, + wasmBinary, + }); + return new Unrar(extractor); + } + + extract() { + const files = this.extractor.extract().files; + for (const file of files) { + console.log("File:", file.fileHeader.name); + } + } +} diff --git a/src/renderer/src/components/bottom-panel/bottom-panel.tsx b/src/renderer/src/components/bottom-panel/bottom-panel.tsx index 993d6aa5..8a9cc090 100644 --- a/src/renderer/src/components/bottom-panel/bottom-panel.tsx +++ b/src/renderer/src/components/bottom-panel/bottom-panel.tsx @@ -7,6 +7,7 @@ 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 } from "@globals"; export function BottomPanel() { const { t } = useTranslation("bottom_panel"); @@ -23,10 +24,10 @@ export function BottomPanel() { const status = useMemo(() => { if (isDownloading && game) { - if (game.status === "downloading_metadata") + if (game.status === GameStatus.DownloadingMetadata) return t("downloading_metadata", { title: game.title }); - if (game.status === "checking_files") + if (game.status === GameStatus.CheckingFiles) return t("checking_files", { title: game.title, percentage: progress, diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 77842bb3..8fc923e5 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -14,6 +14,7 @@ import DiscordLogo from "@renderer/assets/discord-icon.svg?react"; import XLogo from "@renderer/assets/x-icon.svg?react"; import * as styles from "./sidebar.css"; +import { GameStatus } from "@globals"; const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_INITIAL_WIDTH = 250; @@ -60,9 +61,7 @@ export function Sidebar() { }, [gameDownloading?.id, updateLibrary]); const isDownloading = library.some((game) => - ["downloading", "checking_files", "downloading_metadata"].includes( - game.status - ) + GameStatus.isDownloading(game.status) ); const sidebarRef = useRef(null); @@ -121,15 +120,14 @@ export function Sidebar() { }, [isResizing]); const getGameTitle = (game: Game) => { - if (game.status === "paused") return t("paused", { title: game.title }); + if (game.status === GameStatus.Paused) + return t("paused", { title: game.title }); if (gameDownloading?.id === game.id) { - const isVerifying = ["downloading_metadata", "checking_files"].includes( - gameDownloading?.status - ); + const isVerifying = GameStatus.isVerifying(gameDownloading.status); if (isVerifying) - return t(gameDownloading.status, { + return t(gameDownloading.status!, { title: game.title, percentage: progress, }); @@ -204,7 +202,7 @@ export function Sidebar() { className={styles.menuItem({ active: location.pathname === `/game/${game.shop}/${game.objectID}`, - muted: game.status === "cancelled", + muted: game.status === GameStatus.Cancelled, })} >