diff --git a/README.md b/README.md index d2da61c6..43dc01c5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
- [](https://hydralauncher.site) +[](https://hydralauncher.site)

Hydra Launcher

@@ -10,15 +10,15 @@ Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.

- [![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions) - [![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases) - - [![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md) - [![en](https://img.shields.io/badge/lang-en-red.svg)](README.md) - [![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md) - [![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md) - - ![Hydra Catalogue](./docs/screenshot.png) +[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions) +[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases) + +[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md) +[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md) +[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md) +[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md) + +![Hydra Catalogue](./docs/screenshot.png)
diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 733dcb89..4368de53 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"), + "@shared": resolve("src/shared"), }, }, plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin], @@ -46,6 +47,7 @@ export default defineConfig(({ mode }) => { alias: { "@renderer": resolve("src/renderer/src"), "@locales": resolve("src/locales"), + "@shared": resolve("src/shared"), }, }, plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin], diff --git a/package.json b/package.json index 55b64d05..2ecde1b8 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,10 @@ "better-sqlite3": "^9.5.0", "check-disk-space": "^3.4.0", "classnames": "^2.5.1", + "color": "^4.2.3", "color.js": "^1.2.0", "date-fns": "^3.6.0", + "easydl": "^1.1.1", "fetch-cookie": "^3.0.1", "flexsearch": "^0.7.43", "i18next": "^23.11.2", @@ -51,6 +53,7 @@ "jsdom": "^24.0.0", "lodash-es": "^4.17.21", "lottie-react": "^2.4.0", + "node-7z-archive": "^1.1.7", "parse-torrent": "^11.0.16", "ps-list": "^8.1.1", "react-i18next": "^14.1.0", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index f0b17cdc..0674d1b5 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -24,7 +24,7 @@ "github": "Contribute on GitHub" }, "header": { - "search": "Search", + "search": "Search games", "home": "Home", "catalogue": "Catalogue", "downloads": "Downloads", @@ -87,8 +87,7 @@ "change": "Change", "repacks_modal_description": "Choose the repack you want to download", "downloads_path": "Downloads path", - "select_folder_hint": "To change the default folder, access the", - "settings": "Settings", + "select_folder_hint": "To change the default folder, go to the <0>Settings", "download_now": "Download now", "installation_instructions": "Installation Instructions", "installation_instructions_description": "Additional steps are required to install this game", @@ -128,7 +127,9 @@ "remove_from_list": "Remove", "delete_modal_title": "Are you sure?", "delete_modal_description": "This will remove all the installation files from your computer", - "install": "Install" + "install": "Install", + "real_debrid": "Real Debrid", + "torrent": "Torrent" }, "settings": { "downloads_path": "Downloads path", @@ -138,9 +139,15 @@ "enable_repack_list_notifications": "When a new repack is added", "telemetry": "Telemetry", "telemetry_description": "Enable anonymous usage statistics", + "real_debrid_api_token_description": "Real Debrid API token", + "quit_app_instead_hiding": "Quit Hydra instead of minimizing to tray", + "launch_with_system": "Launch Hydra on system start-up", + "general": "General", "behavior": "Behavior", - "quit_app_instead_hiding": "Close app instead of minimizing to tray", - "launch_with_system": "Launch app on system start-up" + "enable_real_debrid": "Enable Real Debrid", + "real_debrid": "Real Debrid", + "real_debrid_api_token_hint": "You can get your API key <0>here.", + "save_changes": "Save changes" }, "notifications": { "download_complete": "Download complete", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index d00ca555..dda53065 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -24,7 +24,7 @@ "github": "Contribua no GitHub" }, "header": { - "search": "Buscar", + "search": "Buscar jogos", "catalogue": "Catálogo", "downloads": "Downloads", "search_results": "Resultados da busca", @@ -83,8 +83,7 @@ "change": "Mudar", "repacks_modal_description": "Escolha o repack do jogo que deseja baixar", "downloads_path": "Diretório do download", - "select_folder_hint": "Para trocar a pasta padrão, acesse as ", - "settings": "Configurações do Hydra", + "select_folder_hint": "Para trocar a pasta padrão, acesse a <0>Tela de Configurações", "download_now": "Baixe agora", "installation_instructions": "Instruções de Instalação", "installation_instructions_description": "Passos adicionais são necessários para instalar esse jogo", @@ -134,9 +133,14 @@ "enable_repack_list_notifications": "Quando a lista de repacks for atualizada", "telemetry": "Telemetria", "telemetry_description": "Habilitar estatísticas de uso anônimas", - "behavior": "Comportamento", "quit_app_instead_hiding": "Fechar o aplicativo em vez de minimizá-lo", - "launch_with_system": "Iniciar aplicativo na inicialização do sistema" + "launch_with_system": "Iniciar aplicativo na inicialização do sistema", + "general": "Geral", + "behavior": "Comportamento", + "enable_real_debrid": "Habilitar Real Debrid", + "real_debrid": "Real Debrid", + "real_debrid_api_token_hint": "Você pode obter sua chave de API <0>aqui.", + "save_changes": "Salvar mudanças" }, "notifications": { "download_complete": "Download concluído", diff --git a/src/main/constants.ts b/src/main/constants.ts index 39da625b..a229cb31 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( @@ -50,7 +41,5 @@ export const databasePath = path.join( "hydra.db" ); -export const imageCachePath = path.join(app.getPath("userData"), ".imagecache"); - export const INSTALLATION_ID_LENGTH = 6; export const ACTIVATION_KEY_MULTIPLIER = 7; diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts index 25ca7495..6280930b 100644 --- a/src/main/entity/game.entity.ts +++ b/src/main/entity/game.entity.ts @@ -7,9 +7,11 @@ import { OneToOne, JoinColumn, } from "typeorm"; -import type { GameShop } from "@types"; import { Repack } from "./repack.entity"; +import type { GameShop } from "@types"; +import { Downloader, GameStatus } from "@shared"; + @Entity("game") export class Game { @PrimaryGeneratedColumn() @@ -40,8 +42,14 @@ export class Game { shop: GameShop; @Column("text", { nullable: true }) - status: string | null; + status: GameStatus | null; + @Column("int", { default: Downloader.Torrent }) + downloader: Downloader; + + /** + * Progress is a float between 0 and 1 + */ @Column("float", { default: 0 }) progress: number; diff --git a/src/main/entity/user-preferences.entity.ts b/src/main/entity/user-preferences.entity.ts index 9d2e35ce..38334efc 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/catalogue/get-catalogue.ts b/src/main/events/catalogue/get-catalogue.ts index 3e802c92..cc93abda 100644 --- a/src/main/events/catalogue/get-catalogue.ts +++ b/src/main/events/catalogue/get-catalogue.ts @@ -8,42 +8,35 @@ import { requestSteam250 } from "@main/services"; const repacks = stateManager.getValue("repacks"); -interface GetStringForLookup { - (index: number): string; -} +const getStringForLookup = (index: number): string => { + const repack = repacks[index]; + const formatter = + repackerFormatter[repack.repacker as keyof typeof repackerFormatter]; + + return formatName(formatter(repack.title)); +}; + +const resultSize = 12; const getCatalogue = async ( _event: Electron.IpcMainInvokeEvent, category: CatalogueCategory ) => { - const getStringForLookup = (index: number): string => { - const repack = repacks[index]; - const formatter = - repackerFormatter[repack.repacker as keyof typeof repackerFormatter]; - - return formatName(formatter(repack.title)); - }; - if (!repacks.length) return []; - const resultSize = 12; - if (category === "trending") { return getTrendingCatalogue(resultSize); - } else { - return getRecentlyAddedCatalogue( - resultSize, - resultSize, - getStringForLookup - ); } + + return getRecentlyAddedCatalogue(resultSize); }; const getTrendingCatalogue = async ( resultSize: number ): Promise => { const results: CatalogueEntry[] = []; - const trendingGames = await requestSteam250("/30day"); + const trendingGames = await requestSteam250("/90day"); + for ( let i = 0; i < trendingGames.length && results.length < resultSize; @@ -51,7 +44,7 @@ const getTrendingCatalogue = async ( ) { if (!trendingGames[i]) continue; - const { title, objectID } = trendingGames[i]; + const { title, objectID } = trendingGames[i]!; const repacks = searchRepacks(title); if (title && repacks.length) { @@ -69,11 +62,8 @@ const getTrendingCatalogue = async ( }; const getRecentlyAddedCatalogue = async ( - resultSize: number, - requestSize: number, - getStringForLookup: GetStringForLookup + resultSize: number ): Promise => { - let lookupRequest = []; const results: CatalogueEntry[] = []; for (let i = 0; results.length < resultSize; i++) { @@ -84,15 +74,7 @@ const getRecentlyAddedCatalogue = async ( continue; } - lookupRequest.push(searchGames({ query: stringForLookup })); - - if (lookupRequest.length < requestSize) { - continue; - } - - const games = (await Promise.all(lookupRequest)).map((value) => - value.at(0) - ); + const games = searchGames({ query: stringForLookup }); for (const game of games) { const isAlreadyIncluded = results.some( @@ -105,7 +87,6 @@ const getRecentlyAddedCatalogue = async ( results.push(game); } - lookupRequest = []; } return results.slice(0, resultSize); diff --git a/src/main/events/helpers/generate-lutris-yaml.ts b/src/main/events/helpers/generate-lutris-yaml.ts index 75c9786b..f47a2a68 100644 --- a/src/main/events/helpers/generate-lutris-yaml.ts +++ b/src/main/events/helpers/generate-lutris-yaml.ts @@ -28,8 +28,8 @@ export const generateYML = (game: Game) => { { task: { executable: path.join( - game.downloadPath, - game.folderName, + game.downloadPath!, + game.folderName!, "setup.exe" ), name: "wineexec", diff --git a/src/main/events/library/close-game.ts b/src/main/events/library/close-game.ts index d549f3b7..77613e21 100644 --- a/src/main/events/library/close-game.ts +++ b/src/main/events/library/close-game.ts @@ -10,7 +10,9 @@ const closeGame = async ( gameId: number ) => { const processes = await getProcesses(); - const game = await gameRepository.findOne({ where: { id: gameId } }); + const game = await gameRepository.findOne({ + where: { id: gameId, isDeleted: false }, + }); if (!game) return false; diff --git a/src/main/events/library/delete-game-folder.ts b/src/main/events/library/delete-game-folder.ts index c8821415..264a652a 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 "@shared"; import { gameRepository } from "@main/repository"; import { getDownloadsPath } from "../helpers/get-downloads-path"; @@ -11,11 +11,12 @@ import { registerEvent } from "../register-event"; const deleteGameFolder = async ( _event: Electron.IpcMainInvokeEvent, gameId: number -) => { +): Promise => { const game = await gameRepository.findOne({ where: { id: gameId, status: GameStatus.Cancelled, + isDeleted: false, }, }); @@ -37,7 +38,8 @@ const deleteGameFolder = async ( logger.error(error); reject(); } - resolve(null); + + resolve(); } ); }); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index be7eb84f..2910d528 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 "@shared"; import { sortBy } from "lodash-es"; const getLibrary = async () => diff --git a/src/main/events/library/open-game-installer.ts b/src/main/events/library/open-game-installer.ts index 796e063b..2621d1f1 100644 --- a/src/main/events/library/open-game-installer.ts +++ b/src/main/events/library/open-game-installer.ts @@ -13,13 +13,15 @@ const openGameInstaller = async ( _event: Electron.IpcMainInvokeEvent, gameId: number ) => { - const game = await gameRepository.findOne({ where: { id: gameId } }); + const game = await gameRepository.findOne({ + where: { id: gameId, isDeleted: false }, + }); if (!game) return true; const gamePath = path.join( game.downloadPath ?? (await getDownloadsPath()), - game.folderName + game.folderName! ); if (!fs.existsSync(gamePath)) { diff --git a/src/main/events/library/remove-game.ts b/src/main/events/library/remove-game.ts index d571e821..f207aea9 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 "@shared"; const removeGame = async ( _event: Electron.IpcMainInvokeEvent, diff --git a/src/main/events/misc/show-open-dialog.ts b/src/main/events/misc/show-open-dialog.ts index baa6a016..b107409a 100644 --- a/src/main/events/misc/show-open-dialog.ts +++ b/src/main/events/misc/show-open-dialog.ts @@ -7,8 +7,10 @@ const showOpenDialog = async ( options: Electron.OpenDialogOptions ) => { if (WindowManager.mainWindow) { - dialog.showOpenDialog(WindowManager.mainWindow, options); + return dialog.showOpenDialog(WindowManager.mainWindow, options); } + + throw new Error("Main window is not available"); }; registerEvent(showOpenDialog, { diff --git a/src/main/events/torrenting/cancel-game-download.ts b/src/main/events/torrenting/cancel-game-download.ts index 77e633b0..d7603c76 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 { DownloadManager } from "@main/services"; +import { GameStatus } from "@shared"; const cancelGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -13,17 +14,20 @@ const cancelGameDownload = async ( const game = await gameRepository.findOne({ where: { 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( @@ -41,7 +45,6 @@ const cancelGameDownload = async ( game.status !== GameStatus.Paused && game.status !== GameStatus.Seeding ) { - writePipe.write({ action: "cancel" }); 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..bdc8bf41 100644 --- a/src/main/events/torrenting/pause-game-download.ts +++ b/src/main/events/torrenting/pause-game-download.ts @@ -1,14 +1,15 @@ -import { WindowManager, writePipe } from "@main/services"; - import { registerEvent } from "../register-event"; -import { GameStatus } from "../../constants"; import { gameRepository } from "../../repository"; import { In } from "typeorm"; +import { DownloadManager, WindowManager } from "@main/services"; +import { GameStatus } from "@shared"; const pauseGameDownload = async ( _event: Electron.IpcMainInvokeEvent, gameId: number ) => { + DownloadManager.pauseDownload(); + await gameRepository .update( { @@ -22,10 +23,7 @@ const pauseGameDownload = async ( { status: GameStatus.Paused } ) .then((result) => { - if (result.affected) { - writePipe.write({ action: "pause" }); - WindowManager.mainWindow?.setProgressBar(-1); - } + if (result.affected) 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..59ea9c4c 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 { DownloadManager } from "@main/services"; +import { GameStatus } from "@shared"; const resumeGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -12,23 +12,18 @@ const resumeGameDownload = async ( const game = await gameRepository.findOne({ where: { id: gameId, + isDeleted: false, }, relations: { repack: true }, }); if (!game) return; - - writePipe.write({ action: "pause" }); + DownloadManager.pauseDownload(); 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, - }); + DownloadManager.resumeDownload(gameId); await gameRepository.update( { @@ -44,7 +39,7 @@ const resumeGameDownload = async ( await gameRepository.update( { id: game.id }, { - status: GameStatus.DownloadingMetadata, + status: GameStatus.Downloading, downloadPath: downloadsPath, } ); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 8a42ef70..42ad2e84 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -1,12 +1,17 @@ -import { getSteamGameIconUrl, writePipe } from "@main/services"; -import { gameRepository, repackRepository } from "@main/repository"; -import { GameStatus } from "@main/constants"; +import { getSteamGameIconUrl } from "@main/services"; +import { + gameRepository, + repackRepository, + userPreferencesRepository, +} from "@main/repository"; import { registerEvent } from "../register-event"; import type { GameShop } from "@types"; import { getFileBase64 } from "@main/helpers"; import { In } from "typeorm"; +import { DownloadManager } from "@main/services"; +import { Downloader, GameStatus } from "@shared"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -16,6 +21,14 @@ const startGameDownload = async ( gameShop: GameShop, downloadPath: string ) => { + const userPreferences = await userPreferencesRepository.findOne({ + where: { id: 1 }, + }); + + const downloader = userPreferences?.realDebridApiToken + ? Downloader.RealDebrid + : Downloader.Torrent; + const [game, repack] = await Promise.all([ gameRepository.findOne({ where: { @@ -29,13 +42,8 @@ const startGameDownload = async ( }), ]); - if (!repack) return; - - if (game?.status === GameStatus.Downloading) { - return; - } - - writePipe.write({ action: "pause" }); + if (!repack || game?.status === GameStatus.Downloading) return; + DownloadManager.pauseDownload(); await gameRepository.update( { @@ -56,17 +64,13 @@ const startGameDownload = async ( { status: GameStatus.DownloadingMetadata, downloadPath: downloadPath, + downloader, repack: { id: repackId }, isDeleted: false, } ); - writePipe.write({ - action: "start", - game_id: game.id, - magnet: repack.magnet, - save_path: downloadPath, - }); + DownloadManager.downloadGame(game.id); game.status = GameStatus.DownloadingMetadata; @@ -78,18 +82,14 @@ const startGameDownload = async ( title, iconUrl, objectID, + downloader, shop: gameShop, - status: GameStatus.DownloadingMetadata, - downloadPath: downloadPath, + status: GameStatus.Downloading, + downloadPath, repack: { id: repackId }, }); - writePipe.write({ - action: "start", - game_id: createdGame.id, - magnet: repack.magnet, - save_path: downloadPath, - }); + DownloadManager.downloadGame(createdGame.id); const { repack: _, ...rest } = createdGame; diff --git a/src/main/events/user-preferences/update-user-preferences.ts b/src/main/events/user-preferences/update-user-preferences.ts index 000eca7b..89622166 100644 --- a/src/main/events/user-preferences/update-user-preferences.ts +++ b/src/main/events/user-preferences/update-user-preferences.ts @@ -2,11 +2,16 @@ import { userPreferencesRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import type { UserPreferences } from "@types"; +import { RealDebridClient } from "@main/services/real-debrid"; const updateUserPreferences = async ( _event: Electron.IpcMainInvokeEvent, preferences: Partial ) => { + if (preferences.realDebridApiToken) { + RealDebridClient.authorize(preferences.realDebridApiToken); + } + await userPreferencesRepository.upsert( { id: 1, diff --git a/src/main/main.ts b/src/main/main.ts index ae591720..ab7a5003 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,14 +1,13 @@ import { stateManager } from "./state-manager"; -import { GameStatus, repackers } from "./constants"; +import { repackers } from "./constants"; import { getNewGOGGames, getNewRepacksFromCPG, getNewRepacksFromUser, getNewRepacksFromXatab, getNewRepacksFromOnlineFix, - readPipe, startProcessWatcher, - writePipe, + DownloadManager, } from "./services"; import { gameRepository, @@ -17,42 +16,16 @@ import { steamGameRepository, userPreferencesRepository, } from "./repository"; -import { TorrentClient } from "./services/torrent-client"; -import { Repack } from "./entity"; +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 { RealDebridClient } from "./services/real-debrid"; startProcessWatcher(); -TorrentClient.startTorrentClient(writePipe.socketPath, readPipe.socketPath); - -Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(async () => { - const game = await gameRepository.findOne({ - where: { - status: In([ - GameStatus.Downloading, - GameStatus.DownloadingMetadata, - GameStatus.CheckingFiles, - ]), - }, - relations: { repack: true }, - }); - - if (game) { - writePipe.write({ - action: "start", - game_id: game.id, - magnet: game.repack.magnet, - save_path: game.downloadPath, - }); - } - - readPipe.socket?.on("data", (data) => { - TorrentClient.onSocketData(data); - }); -}); - const track1337xUsers = async (existingRepacks: Repack[]) => { for (const repacker of repackers) { await getNewRepacksFromUser( @@ -62,11 +35,7 @@ const track1337xUsers = async (existingRepacks: Repack[]) => { } }; -const checkForNewRepacks = async () => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); - +const checkForNewRepacks = async (userPreferences: UserPreferences | null) => { const existingRepacks = stateManager.getValue("repacks"); Promise.allSettled([ @@ -104,7 +73,7 @@ const checkForNewRepacks = async () => { }); }; -const loadState = async () => { +const loadState = async (userPreferences: UserPreferences | null) => { const [friendlyNames, repacks, steamGames] = await Promise.all([ repackerFriendlyNameRepository.find(), repackRepository.find({ @@ -124,6 +93,33 @@ const loadState = async () => { stateManager.setValue("steamGames", steamGames); import("./events"); + + if (userPreferences?.realDebridApiToken) + await RealDebridClient.authorize(userPreferences?.realDebridApiToken); + + const game = await gameRepository.findOne({ + where: { + status: In([ + GameStatus.Downloading, + GameStatus.DownloadingMetadata, + GameStatus.CheckingFiles, + ]), + isDeleted: false, + }, + relations: { repack: true }, + }); + + await TorrentDownloader.startClient(); + + if (game) { + DownloadManager.resumeDownload(game.id); + } }; -loadState().then(() => checkForNewRepacks()); +userPreferencesRepository + .findOne({ + where: { id: 1 }, + }) + .then((userPreferences) => { + loadState(userPreferences).then(() => checkForNewRepacks(userPreferences)); + }); diff --git a/src/main/services/download-manager.ts b/src/main/services/download-manager.ts new file mode 100644 index 00000000..e345835a --- /dev/null +++ b/src/main/services/download-manager.ts @@ -0,0 +1,76 @@ +import { gameRepository } from "@main/repository"; + +import type { Game } from "@main/entity"; +import { Downloader } from "@shared"; + +import { writePipe } from "./fifo"; +import { RealDebridDownloader } from "./downloaders"; + +export class DownloadManager { + private static gameDownloading: Game; + + static async getGame(gameId: number) { + return gameRepository.findOne({ + where: { id: gameId, isDeleted: false }, + relations: { + repack: true, + }, + }); + } + + static async cancelDownload() { + if ( + this.gameDownloading && + this.gameDownloading.downloader === Downloader.Torrent + ) { + writePipe.write({ action: "cancel" }); + } else { + RealDebridDownloader.destroy(); + } + } + + static async pauseDownload() { + if ( + this.gameDownloading && + this.gameDownloading.downloader === Downloader.Torrent + ) { + writePipe.write({ action: "pause" }); + } else { + RealDebridDownloader.destroy(); + } + } + + static async resumeDownload(gameId: number) { + const game = await this.getGame(gameId); + + 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!); + } + + this.gameDownloading = game!; + } + + static async downloadGame(gameId: number) { + const game = await this.getGame(gameId); + + 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!); + } + + this.gameDownloading = game!; + } +} diff --git a/src/main/services/downloaders/downloader.ts b/src/main/services/downloaders/downloader.ts new file mode 100644 index 00000000..14440676 --- /dev/null +++ b/src/main/services/downloaders/downloader.ts @@ -0,0 +1,85 @@ +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 new file mode 100644 index 00000000..cd742107 --- /dev/null +++ b/src/main/services/downloaders/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..8a44f934 --- /dev/null +++ b/src/main/services/downloaders/real-debrid.downloader.ts @@ -0,0 +1,115 @@ +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 new file mode 100644 index 00000000..0590f6bf --- /dev/null +++ b/src/main/services/downloaders/torrent.downloader.ts @@ -0,0 +1,160 @@ +import path from "node:path"; +import cp from "node:child_process"; +import fs from "node:fs"; +import * as Sentry from "@sentry/electron/main"; +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, + }); + } catch (err) { + Sentry.captureException(err); + } 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, + }); + return; + } + + 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/index.ts b/src/main/services/index.ts index 2544c6f4..4b13d38d 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -6,6 +6,7 @@ export * from "./steam-grid"; export * from "./update-resolver"; export * from "./window-manager"; export * from "./fifo"; -export * from "./torrent-client"; +export * from "./downloaders"; +export * from "./download-manager"; export * from "./how-long-to-beat"; export * from "./process-watcher"; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 1c5383de..16646934 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -16,6 +16,7 @@ export const startProcessWatcher = async () => { const games = await gameRepository.find({ where: { executablePath: Not(IsNull()), + isDeleted: false, }, }); diff --git a/src/main/services/real-debrid.ts b/src/main/services/real-debrid.ts new file mode 100644 index 00000000..44798062 --- /dev/null +++ b/src/main/services/real-debrid.ts @@ -0,0 +1,102 @@ +import { Game } from "@main/entity"; +import type { + RealDebridAddMagnet, + RealDebridTorrentInfo, + RealDebridUnrestrictLink, +} from "./real-debrid.types"; +import axios, { AxiosInstance } from "axios"; + +const base = "https://api.real-debrid.com/rest/1.0"; + +export class RealDebridClient { + private static instance: AxiosInstance; + + static async addMagnet(magnet: string) { + const searchParams = new URLSearchParams(); + searchParams.append("magnet", magnet); + + const response = await this.instance.post( + "/torrents/addMagnet", + searchParams.toString() + ); + + return response.data; + } + + static async getInfo(id: string) { + const response = await this.instance.get( + `/torrents/info/${id}` + ); + return response.data; + } + + static async selectAllFiles(id: string) { + const searchParams = new URLSearchParams(); + searchParams.append("files", "all"); + + await this.instance.post( + `/torrents/selectFiles/${id}`, + searchParams.toString() + ); + } + + static async unrestrictLink(link: string) { + const searchParams = new URLSearchParams(); + searchParams.append("link", link); + + const response = await this.instance.post( + "/unrestrict/link", + searchParams.toString() + ); + + return response.data; + } + + static async getAllTorrentsFromUser() { + const response = + await this.instance.get("/torrents"); + + return response.data; + } + + static extractSHA1FromMagnet(magnet: string) { + return magnet.match(/btih:([0-9a-fA-F]*)/)?.[1].toLowerCase(); + } + + static async getDownloadUrl(game: Game) { + const torrents = await RealDebridClient.getAllTorrentsFromUser(); + const hash = RealDebridClient.extractSHA1FromMagnet(game!.repack.magnet); + let torrent = torrents.find((t) => t.hash === hash); + + if (!torrent) { + const magnet = await RealDebridClient.addMagnet(game!.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]); + + if (!download) { + throw new Error("Torrent not cached on Real Debrid"); + } + + return download; + } + + throw new Error(); + } + + static async authorize(apiToken: string) { + this.instance = axios.create({ + baseURL: base, + headers: { + Authorization: `Bearer ${apiToken}`, + }, + }); + } +} diff --git a/src/main/services/real-debrid.types.ts b/src/main/services/real-debrid.types.ts new file mode 100644 index 00000000..6707641f --- /dev/null +++ b/src/main/services/real-debrid.types.ts @@ -0,0 +1,51 @@ +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: string; // 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[]; + 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/repack-tracker/1337x.ts b/src/main/services/repack-tracker/1337x.ts index 8573079b..5e6ae527 100644 --- a/src/main/services/repack-tracker/1337x.ts +++ b/src/main/services/repack-tracker/1337x.ts @@ -33,9 +33,9 @@ const getTorrentDetails = async (path: string) => { return { magnet: $a?.href, - fileSize: $totalSize.querySelector("span").textContent ?? undefined, + fileSize: $totalSize.querySelector("span")!.textContent, uploadDate: formatUploadDate( - $dateUploaded.querySelector("span").textContent! + $dateUploaded.querySelector("span")!.textContent! ), }; }; @@ -65,8 +65,7 @@ export const getTorrentListLastPage = async (user: string) => { export const extractTorrentsFromDocument = async ( page: number, user: string, - document: Document, - existingRepacks: Repack[] = [] + document: Document ) => { const $trs = Array.from(document.querySelectorAll("tbody tr")); @@ -78,24 +77,13 @@ export const extractTorrentsFromDocument = async ( const url = $name.href; const title = $name.textContent ?? ""; - if (existingRepacks.some((repack) => repack.title === title)) { - return { - title, - magnet: "", - fileSize: null, - uploadDate: null, - repacker: user, - page, - }; - } - const details = await getTorrentDetails(url); return { title, magnet: details.magnet, - fileSize: details.fileSize ?? null, - uploadDate: details.uploadDate ?? null, + fileSize: details.fileSize ?? "N/A", + uploadDate: details.uploadDate ?? new Date(), repacker: user, page, }; @@ -114,13 +102,11 @@ export const getNewRepacksFromUser = async ( const repacks = await extractTorrentsFromDocument( page, user, - window.document, - existingRepacks + window.document ); const newRepacks = repacks.filter( (repack) => - repack.uploadDate && !existingRepacks.some( (existingRepack) => existingRepack.title === repack.title ) diff --git a/src/main/services/repack-tracker/cpg-repacks.ts b/src/main/services/repack-tracker/cpg-repacks.ts index 2b939d08..d1ba6cc4 100644 --- a/src/main/services/repack-tracker/cpg-repacks.ts +++ b/src/main/services/repack-tracker/cpg-repacks.ts @@ -4,6 +4,7 @@ import { Repack } from "@main/entity"; import { requestWebPage, savePage } from "./helpers"; import { logger } from "../logger"; +import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; export const getNewRepacksFromCPG = async ( existingRepacks: Repack[] = [], @@ -13,11 +14,11 @@ export const getNewRepacksFromCPG = async ( const { window } = new JSDOM(data); - const repacks = []; + const repacks: QueryDeepPartialEntity[] = []; try { Array.from(window.document.querySelectorAll(".post")).forEach(($post) => { - const $title = $post.querySelector(".entry-title"); + const $title = $post.querySelector(".entry-title")!; const uploadDate = $post.querySelector("time")?.getAttribute("datetime"); const $downloadInfo = Array.from( @@ -31,26 +32,25 @@ export const getNewRepacksFromCPG = async ( $a.textContent?.startsWith("Magent") ); - const fileSize = $downloadInfo.textContent + const fileSize = ($downloadInfo?.textContent ?? "") .split("Download link => ") .at(1); repacks.push({ - title: $title.textContent, + title: $title.textContent!, fileSize: fileSize ?? "N/A", - magnet: $magnet.href, + magnet: $magnet!.href, repacker: "CPG", page, - uploadDate: new Date(uploadDate), + uploadDate: uploadDate ? new Date(uploadDate) : new Date(), }); }); - } catch (err) { - logger.error(err.message, { method: "getNewRepacksFromCPG" }); + } catch (err: unknown) { + logger.error((err as Error).message, { method: "getNewRepacksFromCPG" }); } const newRepacks = repacks.filter( (repack) => - repack.uploadDate && !existingRepacks.some( (existingRepack) => existingRepack.title === repack.title ) diff --git a/src/main/services/repack-tracker/gog.ts b/src/main/services/repack-tracker/gog.ts index 00c78e36..aa22ee5c 100644 --- a/src/main/services/repack-tracker/gog.ts +++ b/src/main/services/repack-tracker/gog.ts @@ -16,14 +16,14 @@ const getGOGGame = async (url: string) => { const $em = window.document.querySelector( "p:not(.lightweight-accordion *) em" - ); - const fileSize = $em.textContent.split("Size: ").at(1); + )!; + const fileSize = $em.textContent!.split("Size: ").at(1); const $downloadButton = window.document.querySelector( ".download-btn:not(.lightweight-accordion *)" ) as HTMLAnchorElement; const { searchParams } = new URL($downloadButton.href); - const magnet = Buffer.from(searchParams.get("url"), "base64").toString( + const magnet = Buffer.from(searchParams.get("url")!, "base64").toString( "utf-8" ); @@ -50,10 +50,10 @@ export const getNewGOGGames = async (existingRepacks: Repack[] = []) => { const $lis = Array.from($ul.querySelectorAll("li")); for (const $li of $lis) { - const $a = $li.querySelector("a"); + const $a = $li.querySelector("a")!; const href = $a.href; - const title = $a.textContent.trim(); + const title = $a.textContent!.trim(); const gameExists = existingRepacks.some( (existingRepack) => existingRepack.title === title diff --git a/src/main/services/repack-tracker/online-fix.ts b/src/main/services/repack-tracker/online-fix.ts index a473679f..e73c6cc6 100644 --- a/src/main/services/repack-tracker/online-fix.ts +++ b/src/main/services/repack-tracker/online-fix.ts @@ -13,6 +13,9 @@ import { ru } from "date-fns/locale"; import { onlinefixFormatter } from "@main/helpers"; import makeFetchCookie from "fetch-cookie"; import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; +import { formatBytes } from "@shared"; + +const ONLINE_FIX_URL = "https://online-fix.me/"; export const getNewRepacksFromOnlineFix = async ( existingRepacks: Repack[] = [], @@ -27,14 +30,14 @@ export const getNewRepacksFromOnlineFix = async ( const http = makeFetchCookie(fetch, cookieJar); if (page === 1) { - await http("https://online-fix.me/"); + await http(ONLINE_FIX_URL); const preLogin = ((await http("https://online-fix.me/engine/ajax/authtoken.php", { method: "GET", headers: { "X-Requested-With": "XMLHttpRequest", - Referer: "https://online-fix.me/", + Referer: ONLINE_FIX_URL, }, }).then((res) => res.json())) as { field: string; @@ -50,11 +53,11 @@ export const getNewRepacksFromOnlineFix = async ( [preLogin.field]: preLogin.value, }); - await http("https://online-fix.me/", { + await http(ONLINE_FIX_URL, { method: "POST", headers: { - Referer: "https://online-fix.me", - Origin: "https://online-fix.me", + Referer: ONLINE_FIX_URL, + Origin: ONLINE_FIX_URL, "Content-Type": "application/x-www-form-urlencoded", }, body: params.toString(), @@ -149,13 +152,8 @@ export const getNewRepacksFromOnlineFix = async ( const torrentSizeInBytes = torrent.length; if (!torrentSizeInBytes) return; - const fileSizeFormatted = - torrentSizeInBytes >= 1024 ** 3 - ? `${(torrentSizeInBytes / 1024 ** 3).toFixed(1)}GBs` - : `${(torrentSizeInBytes / 1024 ** 2).toFixed(1)}MBs`; - repacks.push({ - fileSize: fileSizeFormatted, + fileSize: formatBytes(torrentSizeInBytes), magnet: magnetLink, page: 1, repacker: "onlinefix", diff --git a/src/main/services/repack-tracker/xatab.ts b/src/main/services/repack-tracker/xatab.ts index df075e88..1c43327b 100644 --- a/src/main/services/repack-tracker/xatab.ts +++ b/src/main/services/repack-tracker/xatab.ts @@ -7,6 +7,8 @@ import { requestWebPage, savePage } from "./helpers"; import createWorker from "@main/workers/torrent-parser.worker?nodeWorker"; import { toMagnetURI } from "parse-torrent"; import type { Instance } from "parse-torrent"; +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; +import { formatBytes } from "@shared"; const worker = createWorker({}); @@ -23,10 +25,9 @@ const formatXatabDate = (str: string) => { return date; }; -const formatXatabDownloadSize = (str: string) => - str.replace(",", ".").replace(/Гб/g, "GB").replace(/Мб/g, "MB"); - -const getXatabRepack = (url: string) => { +const getXatabRepack = ( + url: string +): Promise<{ fileSize: string; magnet: string; uploadDate: Date }> => { return new Promise((resolve) => { (async () => { const data = await requestWebPage(url); @@ -34,7 +35,6 @@ const getXatabRepack = (url: string) => { const { document } = window; const $uploadDate = document.querySelector(".entry__date"); - const $size = document.querySelector(".entry__info-size"); const $downloadButton = document.querySelector( ".download-torrent" @@ -42,17 +42,13 @@ const getXatabRepack = (url: string) => { if (!$downloadButton) throw new Error("Download button not found"); - const onMessage = (torrent: Instance) => { + worker.once("message", (torrent: Instance) => { resolve({ - fileSize: formatXatabDownloadSize($size.textContent).toUpperCase(), + fileSize: formatBytes(torrent.length ?? 0), magnet: toMagnetURI(torrent), - uploadDate: formatXatabDate($uploadDate.textContent), + uploadDate: formatXatabDate($uploadDate!.textContent!), }); - - worker.removeListener("message", onMessage); - }; - - worker.once("message", onMessage); + }); })(); }); }; @@ -65,7 +61,7 @@ export const getNewRepacksFromXatab = async ( const { window } = new JSDOM(data); - const repacks = []; + const repacks: QueryDeepPartialEntity[] = []; for (const $a of Array.from( window.document.querySelectorAll(".entry__title a") @@ -74,7 +70,7 @@ export const getNewRepacksFromXatab = async ( const repack = await getXatabRepack(($a as HTMLAnchorElement).href); repacks.push({ - title: $a.textContent, + title: $a.textContent!, repacker: "Xatab", ...repack, page, diff --git a/src/main/services/steam-grid.ts b/src/main/services/steam-grid.ts index 9e2ce9d8..9cb51d73 100644 --- a/src/main/services/steam-grid.ts +++ b/src/main/services/steam-grid.ts @@ -1,3 +1,4 @@ +import axios from "axios"; import { getSteamAppAsset } from "@main/helpers"; export interface SteamGridResponse { @@ -27,33 +28,35 @@ export const getSteamGridData = async ( ): Promise => { const searchParams = new URLSearchParams(params); - const response = await fetch( + if (!import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY) { + throw new Error("STEAMGRIDDB_API_KEY is not set"); + } + + const response = await axios.get( `https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`, { - method: "GET", headers: { Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`, }, } ); - return response.json(); + return response.data; }; export const getSteamGridGameById = async ( id: number ): Promise => { - const response = await fetch( + const response = await axios.get( `https://www.steamgriddb.com/api/public/game/${id}`, { - method: "GET", headers: { Referer: "https://www.steamgriddb.com/", }, } ); - return response.json(); + return response.data; }; export const getSteamGameIconUrl = async (objectID: string) => { diff --git a/src/main/services/torrent-client.ts b/src/main/services/torrent-client.ts deleted file mode 100644 index 2743c82a..00000000 --- a/src/main/services/torrent-client.ts +++ /dev/null @@ -1,169 +0,0 @@ -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 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"; - -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 TorrentClient { - public static startTorrentClient( - writePipePath: string, - readPipePath: string - ) { - const commonArgs = [BITTORRENT_PORT, writePipePath, readPipePath]; - - 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, - }); - return; - } - - const scriptPath = path.join( - __dirname, - "..", - "..", - "torrent-client", - "main.py" - ); - - cp.spawn("python3", [scriptPath, ...commonArgs], { - stdio: "inherit", - }); - } - - private static getTorrentStateName(state: TorrentState) { - if (state === TorrentState.CheckingFiles) return "checking_files"; - if (state === TorrentState.Downloading) return "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; - } - - public static async onSocketData(data: Buffer) { - const message = Buffer.from(data).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; - } - - await gameRepository.update({ id: payload.gameId }, updatePayload); - - const game = await gameRepository.findOne({ - where: { id: payload.gameId }, - 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({ ...payload, game })) - ); - } - } catch (err) { - Sentry.captureException(err); - } - } -} diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index f810acd5..cf846daf 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -105,7 +105,7 @@ export class WindowManager { tray.setToolTip("Hydra"); tray.setContextMenu(contextMenu); - if (process.platform === "win32") { + if (process.platform === "win32" || process.platform === "linux") { tray.addListener("click", () => { if (this.mainWindow) { if (WindowManager.mainWindow?.isMinimized()) diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index d5331336..266cf97c 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -20,6 +20,7 @@ import { setRepackersFriendlyNames, toggleDraggingDisabled, } from "@renderer/features"; +import { GameStatusHelper } from "@shared"; document.body.classList.add(themeClass); @@ -31,7 +32,7 @@ export function App({ children }: AppProps) { const contentRef = useRef(null); const { updateLibrary } = useLibrary(); - const { clearDownload, addPacket } = useDownload(); + const { clearDownload, setLastPacket } = useDownload(); const dispatch = useAppDispatch(); @@ -57,20 +58,20 @@ export function App({ children }: AppProps) { useEffect(() => { const unsubscribe = window.electron.onDownloadProgress( (downloadProgress) => { - if (downloadProgress.game.progress === 1) { + if (GameStatusHelper.isReady(downloadProgress.game.status)) { clearDownload(); updateLibrary(); return; } - addPacket(downloadProgress); + setLastPacket(downloadProgress); } ); return () => { unsubscribe(); }; - }, [clearDownload, addPacket, updateLibrary]); + }, [clearDownload, setLastPacket, updateLibrary]); const handleSearch = useCallback( (query: string) => { diff --git a/src/renderer/src/assets/discord-icon.svg b/src/renderer/src/assets/discord-icon.svg deleted file mode 100644 index 2fba46cd..00000000 --- a/src/renderer/src/assets/discord-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/renderer/src/assets/telegram-icon.svg b/src/renderer/src/assets/telegram-icon.svg index 35521851..962ab45f 100644 --- a/src/renderer/src/assets/telegram-icon.svg +++ b/src/renderer/src/assets/telegram-icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/renderer/src/assets/x-icon.svg b/src/renderer/src/assets/x-icon.svg index f594427b..c394d154 100644 --- a/src/renderer/src/assets/x-icon.svg +++ b/src/renderer/src/assets/x-icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/renderer/src/components/bottom-panel/bottom-panel.tsx b/src/renderer/src/components/bottom-panel/bottom-panel.tsx index 993d6aa5..6cce070e 100644 --- a/src/renderer/src/components/bottom-panel/bottom-panel.tsx +++ b/src/renderer/src/components/bottom-panel/bottom-panel.tsx @@ -7,13 +7,17 @@ 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, isDownloading } = useDownload(); + const { game, progress, downloadSpeed, eta } = useDownload(); + + const isGameDownloading = + game && GameStatusHelper.isDownloading(game.status ?? null); const [version, setVersion] = useState(""); @@ -22,11 +26,11 @@ export function BottomPanel() { }, []); const status = useMemo(() => { - if (isDownloading && game) { - if (game.status === "downloading_metadata") + if (isGameDownloading) { + 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, @@ -41,13 +45,13 @@ export function BottomPanel() { } return t("no_downloads_in_progress"); - }, [t, game, progress, eta, isDownloading, downloadSpeed]); + }, [t, isGameDownloading, game, progress, eta, downloadSpeed]); return (
{status} - + v{version} "{VERSION_CODENAME}"
diff --git a/src/renderer/src/components/button/button.css.ts b/src/renderer/src/components/button/button.css.ts index 2cc19776..de808ad8 100644 --- a/src/renderer/src/components/button/button.css.ts +++ b/src/renderer/src/components/button/button.css.ts @@ -19,6 +19,7 @@ const base = style({ ":disabled": { opacity: vars.opacity.disabled, pointerEvents: "none", + cursor: "not-allowed", }, }); diff --git a/src/renderer/src/components/button/button.tsx b/src/renderer/src/components/button/button.tsx index 41b58367..66a67889 100644 --- a/src/renderer/src/components/button/button.tsx +++ b/src/renderer/src/components/button/button.tsx @@ -17,9 +17,9 @@ export function Button({ }: ButtonProps) { return ( diff --git a/src/renderer/src/components/checkbox-field/checkbox-field.tsx b/src/renderer/src/components/checkbox-field/checkbox-field.tsx index bb81a910..9a7e71d5 100644 --- a/src/renderer/src/components/checkbox-field/checkbox-field.tsx +++ b/src/renderer/src/components/checkbox-field/checkbox-field.tsx @@ -24,7 +24,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) { /> {props.checked && } -