diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 743c12dc..9a274ced 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,8 +1,6 @@ name: Lint -on: - push: - branches: "**" +on: [pull_request, push] jobs: lint: 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/README.ru.md b/README.ru.md index 5c0a5c6d..78220ad4 100644 --- a/README.ru.md +++ b/README.ru.md @@ -89,7 +89,7 @@ ### Способы внести свой вклад - Перевод: Мы хотим, чтобы Hydra была доступна как можно большему количеству людей. Не стесняйтесь помогать переводить на новые языки или обновлять и улучшать те, которые уже доступны в Hydra. -- Код: Hydra создан с использованием TypeScript, Electron и немного Python. Если хотите внести свой вклад, присоединяйтесь к нашему серверу [Telegram](https://t.me/hydralauncher)! +- Код: Hydra создан с использованием TypeScript, Electron и немного Python. Если хотите внести свой вклад, присоединяйтесь к нашему каналу [Telegram](https://t.me/hydralauncher)! ### Структура проекта diff --git a/README.uk-UA.md b/README.uk-UA.md index 82ae7261..3b77009a 100644 --- a/README.uk-UA.md +++ b/README.uk-UA.md @@ -64,7 +64,7 @@ ## Встановлення -Follow the steps below to install: +Щоб встановити, виконайте наведені нижче кроки: 1. Завантажте останню версію Hydra зі сторінки [Releases](https://github.com/hydralauncher/hydra/releases/latest). - Завантажте лише .exe, якщо ви хочете встановити Hydra на Windows. @@ -76,9 +76,9 @@ Follow the steps below to install: ### Приєднуйтесь до нашого Telegram -Ми зосереджуємо наші дискусії на нашому сервері [Telegram](https://t.me/hydralauncher). +Ми зосереджуємо наші дискусії на нашому каналі [Telegram](https://t.me/hydralauncher). -1. Приєднуйтесь до нашого сервера +1. Приєднуйтесь до нашого канала 2. Перейдіть на канал ролей і виберіть роль Співробітник 3. Заходьте на dev-канал, спілкуйтеся з нами та діліться своїми ідеями. @@ -92,8 +92,8 @@ Follow the steps below to install: ### Як ви можете зробити свій внесок -- Translation: We want Hydra to be available to as many people as possible. Feel free to help translate to new languages or update and improve the ones that are already available on Hydra. -- Code: Hydra is built with Typescript, Electron and a little bit of Python. If you want to contribute, join our Telegram! +- Переклад: Ми хочемо, щоб Hydra була доступна якомога більшій кількості людей. Не соромтеся допомагати перекладати на нові мови або оновлювати і покращувати ті, які вже доступні на Hydra. +- Код: Hydra створена за допомогою Typescript, Electron і трохи Python. Якщо ви хочете зробити свій внесок, приєднуйтесь до нашого Telegram! ### Структура проекту 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 && } -