Compare commits

..

35 Commits

Author SHA1 Message Date
Jonh Alex
c63fce8edc Merge branch 'main' into error-handling-improvement 2024-10-11 01:59:38 -03:00
Zamitto
d93e234001 chore: update steam-games.json
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
2024-10-08 22:21:04 -03:00
Zamitto
0fdd2797a5 Merge pull request #1049 from Lianela/main
Some checks are pending
Release / build (ubuntu-latest) (push) Waiting to run
Release / build (windows-latest) (push) Waiting to run
fix: fixed some translations
2024-10-08 15:09:16 -03:00
Lianela
4b763173f6 fix: fixed some translations 2024-10-07 22:15:04 -06:00
Jonh Alex
ec3748343d Update hydra-api.ts 2024-10-05 03:49:32 -03:00
Jonh Alex
b4526b6f67 Update register-event.ts 2024-10-05 03:48:46 -03:00
Jonh Alex
a09124e8be Refactor type definitions in hydra-api.ts to replace any with unknown
- Replaced instances of `any` with `unknown` in the `get`, `post`, `put`, `patch`, and `delete` methods for safer type handling.
- Updated parameters like `data` and `params` to use `Record<string, unknown>` for better type safety.
- Improved overall type robustness and eliminated warnings related to the use of `any`.

Contributed by: [Jonhvmp](https://github.com/Jonhvmp)
2024-10-05 03:26:36 -03:00
Jonh Alex
c7f2a861d5 Implement error handling improvements in main.py and torrent_downloader.py
- Added robust try-except blocks for all critical functions in both files.
- Improved initialization error handling for torrent session in torrent_downloader.py.
- Enhanced error logging for better debugging and clearer feedback when exceptions occur.
- Refined torrent action functions (start, pause, cancel, abort) to handle failures gracefully.
- Updated get_download_status to manage missing torrent handles and report issues accurately.

Contributed by: Jonhvmp
2024-10-05 02:54:56 -03:00
Chubby Granny Chaser
37111c11d8 Merge pull request #1008 from hydralauncher/fix/migrate-repacks-from-sqlite-to-dexie
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
Fix/migrate repacks from sqlite to dexie
2024-09-27 23:15:37 +01:00
Chubby Granny Chaser
43e9919b6b fix: adding direct comparison last downloaded option 2024-09-27 03:39:42 +01:00
Chubby Granny Chaser
2aa393967f Merge branch 'fix/migrate-repacks-from-sqlite-to-dexie' of github.com:hydralauncher/hydra into fix/migrate-repacks-from-sqlite-to-dexie 2024-09-27 03:27:55 +01:00
Chubby Granny Chaser
17febcd88a feat: adding notification when all repacks are migrated 2024-09-27 03:27:02 +01:00
Chubby Granny Chaser
41c80daaaa Merge branch 'main' into fix/migrate-repacks-from-sqlite-to-dexie 2024-09-25 19:38:43 +01:00
Zamitto
d57980edc7 Merge pull request #987 from alexhostrup/danish-translation
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
Added a danish README
2024-09-24 14:28:07 -03:00
Zamitto
97dc9b03b0 Merge branch 'main' into danish-translation 2024-09-24 14:19:41 -03:00
Zamitto
fabeedaa8a Merge pull request #1012 from hydralauncher/chore/update-steam-games
Some checks are pending
Release / build (ubuntu-latest) (push) Waiting to run
Release / build (windows-latest) (push) Waiting to run
chore: update steam games
2024-09-23 14:45:02 -03:00
Zamitto
e701a273d8 fix: ui when game has no icon 2024-09-23 14:20:57 -03:00
Zamitto
6c32fdbcbb Merge branch 'main' into chore/update-steam-games 2024-09-23 13:46:47 -03:00
Zamitto
61b2d55d47 Merge branch 'main' into danish-translation 2024-09-23 12:22:41 -03:00
Zamitto
7b5e4459d4 chore: update READMES 2024-09-23 12:16:22 -03:00
Zamitto
5b450db5eb chore: update steam games 2024-09-22 22:01:50 -03:00
Chubby Granny Chaser
d88e06e289 Merge branch 'main' of github.com:hydralauncher/hydra into fix/migrate-repacks-from-sqlite-to-dexie 2024-09-22 21:09:06 +01:00
Zamitto
e77991ea16 Merge pull request #1009 from hydralauncher/chore/update-api-url
Some checks are pending
Release / build (ubuntu-latest) (push) Waiting to run
Release / build (windows-latest) (push) Waiting to run
chore: update api and auth url
2024-09-22 16:42:19 -03:00
Zamitto
5e55c05bd7 chore: i18n for friend code 2024-09-22 16:30:47 -03:00
Zamitto
81e74f068f fix: import correctly vite env 2024-09-22 16:25:16 -03:00
Zamitto
d5a510175f feat: use env to open auth window 2024-09-22 16:21:01 -03:00
Zamitto
2b2f29da61 chore: add envs to pipeline 2024-09-22 16:20:45 -03:00
Chubby Granny Chaser
339dc89702 feat: adding dexie 2024-09-22 17:45:50 +01:00
Chubby Granny Chaser
50028fbfd8 feat: adding dexie 2024-09-22 17:45:01 +01:00
Chubby Granny Chaser
d97c5b894a feat: adding dexie 2024-09-22 17:44:06 +01:00
Chubby Granny Chaser
f860439fb5 feat: adding dexie 2024-09-22 17:43:05 +01:00
Chubby Granny Chaser
ddd6ff7dbe Merge branch 'main' of github.com:hydralauncher/hydra into feature/adding-flame-animation 2024-09-21 21:19:08 +01:00
Chubby Granny Chaser
849b6de6bc feat: adding dexie 2024-09-21 21:19:00 +01:00
Alexander Hostrup
f8f2124cec Forgot something 2024-09-18 18:08:30 +02:00
Alexander Hostrup
3833e11e98 Added a danish README 2024-09-18 18:04:32 +02:00
74 changed files with 1147 additions and 823 deletions

View File

@@ -1,4 +1,5 @@
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
MAIN_VITE_API_URL=API_URL
MAIN_VITE_AUTH_URL=AUTH_URL
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN
SENTRY_AUTH_TOKEN=

View File

@@ -41,6 +41,7 @@ jobs:
yarn build:linux
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -50,6 +51,7 @@ jobs:
run: yarn build:win
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -43,6 +43,7 @@ jobs:
yarn build:linux
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -52,6 +53,7 @@ jobs:
run: yarn build:win
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra - гэта гульнявы лаўнчар з уласным убудаваным кліентам BitTorrent і самастойным scraper`ам для рэпакаў.</strong>
<strong>Hydra - гэта гульнявы лаўнчар з уласным убудаваным кліентам BitTorrent.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
@@ -140,9 +141,8 @@ pip install -r requirements.txt
## Пераменныя асяроддзі
Вам спатрэбіцца ключ API SteamGridDB, каб атрымаць значкі гульняў пры ўсталёўкі.
Калі вы жадаеце выкарыстоўваць onlinefix у якасці рэпака, вам трэба дадаць вашыя ўліковыя дадзеныя ў файл .env.
Як толькі вы атрымаеце ключ, вы зможаце скапіяваць або пераназваць файл `.env.example` у `.env` і змясціць у яго `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Як толькі вы атрымаеце ключ, вы зможаце скапіяваць або пераназваць файл `.env.example` у `.env` і змясціць у яго `STEAMGRIDDB_API_KEY`.
## Запуск

View File

@@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Katalog](./docs/screenshot.png)

186
README.da.md Normal file
View File

@@ -0,0 +1,186 @@
<br>
<div align="center">
[<img src="./resources/icon.png" width="144"/>](https://hydralauncher.site)
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra er en spil launcher med sin egen indbyggede bittorrent klient.</strong>
</p>
[![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)
[![be](https://img.shields.io/badge/lang-be-orange)](README.be.md)
[![es](https://img.shields.io/badge/lang-es-red)](README.es.md)
[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md)
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
![Hydra Catalogue](./docs/screenshot.png)
</div>
## Indholdsfortegnelse
- [Indholdsfortegnelse](#indholdsfortegnelse)
- [Om](#om)
- [Funktioner](#funktioner)
- [Installation](#installation)
- [Bidrag](#-bidrag)
- [Bliv medlem af vores Telegram kanal](#-join-our-telegram)
- [Fork og klon dit repo](#fork-and-clone-your-repository)
- [Måder du kan bidrage](#ways-you-can-contribute)
- [Projekt Struktur](#project-structure)
- [Byg fra kildekode](#build-from-source)
- [Installér Node.js](#install-nodejs)
- [Installér Yarn](#install-yarn)
- [Installér Node Afhængigheder](#install-node-dependencies)
- [Installér Python 3.9](#install-python-39)
- [Installér Python Afhængigheder](#install-python-dependencies)
- [Miljøvariabler](#environment-variables)
- [Køre](#running)
- [Bygge](#build)
- [Bygge bittorrent klienten](#build-the-bittorrent-client)
- [Bygge Electron applikationen](#build-the-electron-application)
- [Bidragere](#contributors)
- [Licens](#license)
## Om
**Hydra** er en **Spil Launcher** med sin egen indbyggede **BitTorrent Klient**.
<br>
Launcheren er skrevet i TypeScript (Electron) og Python, som håndterer torrenting system ved brug af libtorrent.
## Funktioner
- Sin egen indbyggede bittorrent klient
- How Long To Beat (HLTB) integration på spil siden
- Downloadsti tilpasning
- Windows og Linux understøttelse
- Konstant opdateret
- Og mere ...
## Installation
Følg trinene her under for at installere:
1. Download den seneste version af Hydra fra [Releases](https://github.com/hydralauncher/hydra/releases/latest) siden.
- Download kun .exe hvis du vil installere Hydra på Windows.
- Download .deb, .rpm eller .zip hvis du vil installere Hydra på Linux. (afhænger af din Linux distro)
2. Kør den downloadede fil.
3. Nyd Hydra!
## <a name="bidrag"> Bidrag
### <a name="join-our-telegram"></a> Bliv medlem af vores Telegram kanal
Vi holder vores diskusioner i vores [Telegram](https://t.me/hydralauncher) kanal.
### Fork og klon dit repo
1. Fork repoet [(klik her for at forke nu)](https://github.com/hydralauncher/hydra/fork)
2. Klon din forkede kode `git clone https://github.com/dit_brugernavn/hydra`
3. Lav en ny branch
4. Skub dine commits
5. Indsend en ny Pull Request
### Måder du kan bidrage
- Oversættelse: Vi vil gerne have at Hydra er tilgængeligt for så mange folk som overhovedet muligt. Du er velkommen til at hjælpe med at oversætte til nye sprog eller at opdatere og forbedre de sprog som allerede er tilgængelige i Hydra.
- Kode: Hydra er lavet med Typescript, Electron og en lille smule Python. Hvis du har lyst til at bidrage, kan du blive medlem af vores [Telegram](https://t.me/hydralauncher) kanal! (Alt kommunikation foregår hovedsageligt på Engelsk, Brasiliansk eller Russisk)
### Projekt struktur
- torrent-client: Vi bruger libtorrent, et Python bibliotek, til at administrere torrent downloads
- src/renderer: UI'en i applikationen
- src/main: her har vi al logikken
## Byg fra kildekode
### Installér Node.js
Vær sikker på at du har Node.js installeret på din maskine. Hvis ikke, kan du downloade og installere det fra [nodejs.org](https://nodejs.org/).
### Installér Yarn
Yarn er et pakkehåndteringsprogram til Node.js. Hvis du ikke har installeret Yarn endnu, så kan du gøre det ved at følge instruktionerne på [yarnpkg.com](https://classic.yarnpkg.com/lang/en/docs/install/).
### Installér Node Afhængigheder
Navigér til projekt mappen og installér Node afhængighederne ved bruge af Yarn:
```bash
cd hydra
yarn
```
### Installér Python 3.9
Vær sikker på at du har Python 3.9 installeret på din maskine. Du kan downloade og installere det her: [python.org](https://www.python.org/downloads/release/python-3913/).
### Installér Python Afhængigheder
Installér de påkrævede Python afhængigheder ved brug af pip:
```bash
pip install -r requirements.txt
```
## Miljøvariabler
Du får brug for en SteamGridDB API nøgle for at kunne hente spil ikonerne under installationen.
Når du har det, kan du kopiere og omdøbe `.env.example` filen til `.env` og indsætte nøglen som `STEAMGRIDDB_API_KEY`.
## Køre
Når alt er sat op, kan du køre den følgende kommando for at starte både Electron processen og bittorrent klienten:
```bash
yarn dev
```
## Bygge
### Byg bittorrent klienten
Byg bittorrent klienten ved brug af følgende kommando:
```bash
python torrent-client/setup.py build
```
### Byg Electron applikationen
Byg Electron applikationen ved brug af følgende kommando:
På Windows:
```bash
yarn build:win
```
På Linux:
```bash
yarn build:linux
```
## Bidragere
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hydralauncher/hydra" />
</a>
## Licens
Hydra benytter sig af [MIT Licensen](LICENSE).

View File

@@ -23,6 +23,7 @@
[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md)
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Katalog](./docs/screenshot.png)

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra es un launcher de juegos con su propio cliente de bittorrent y gestor propio de repacks.</strong>
<strong>Hydra es un launcher de juegos con su propio cliente de bittorrent.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
@@ -56,17 +57,15 @@
## Acerca de
**Hydra** es un **Launcher de Juegos** con su propio **Cliente Bittorrent** y **autogestor de Repacks**.
**Hydra** es un **Launcher de Juegos** con su propio **Cliente Bittorrent**.
<br>
El launcher está escrito en TypeScript (Electron) y Python, el cuál se encarga del sistema de torrent usando libtorrent.
## Caracteristicas
- Buscador e instalador autogestionado de repacks a través de las páginas más confiables en él [Megahilo](https://www.reddit.com/r/Piracy/wiki/megathread/)
- Cliente propio de bittorrent integrado
- Integración de How Long To Beat (HLTB) en la página del juego
- Customización de rutas de descargas
- Notificaciones en actualizaciones a listas de repacks
- Soporte a Windows y Linux
- En constante actualización
- Y mucho más ...
@@ -140,9 +139,8 @@ pip install -r requirements.txt
## Variables del Entorno
Necesitas una llave API de SteamGridDB para así poder obtener los íconos de los juegos en la instalación.
Si quieres también tener los repacks de onlinefix, necesitarás añadir tus credenciales al .env
Una vez que los tengas, puedes copiar o renombrar el archivo `.env.example` cómo `.env` y colocarlo en `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Una vez que los tengas, puedes copiar o renombrar el archivo `.env.example` cómo `.env` y colocarlo en `STEAMGRIDDB_API_KEY`.
## Ejecucion

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra est un lanceur de jeux avec son propre client bittorrent intégré et un scraper de repack auto-géré.</strong>
<strong>Hydra est un lanceur de jeux avec son propre client bittorrent intégré.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Catalogue Hydra](./docs/screenshot.png)
@@ -56,17 +57,15 @@
## À propos
**Hydra** est un **lanceur de jeux** avec son propre **client BitTorrent** intégré et un **scraper de repack auto-géré**.
**Hydra** est un **lanceur de jeux** avec son propre **client BitTorrent** intégré.
<br>
Le lanceur est écrit en TypeScript (Electron) et Python, qui gère le système de torrent en utilisant libtorrent.
## Fonctionnalités
- Scraper de repack auto-géré parmi tous les sites les plus fiables sur le [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
- Client bittorrent intégré
- Intégration How Long To Beat (HLTB) sur la page du jeu
- Personnalisation des chemins de téléchargement
- Notifications de mise à jour de la liste de repack
- Support pour Windows et Linux
- Constamment mis à jour
- Et plus encore ...
@@ -140,9 +139,8 @@ pip install -r requirements.txt
## Variables d'environnement
Vous aurez besoin d'une clé API SteamGridDB pour récupérer les icônes de jeux lors de l'installation.
Si vous voulez avoir onlinefix comme repacker, vous devrez ajouter vos identifiants au fichier .env.
Une fois que vous l'avez, vous pouvez copier ou renommer le fichier `.env.example` en `.env` et y mettre `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Une fois que vous l'avez, vous pouvez copier ou renommer le fichier `.env.example` en `.env` et y mettre `STEAMGRIDDB_API_KEY`.
## Lancement

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra è un game launcher con il proprio client bittorrent e autogestore di repacks.</strong>
<strong>Hydra è un game launcher con il proprio client bittorrent.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
@@ -56,17 +57,15 @@
## A proposito
**Hydra** è un **Game Launcher** con il proprio **Client BitTorrent** e **autogestore di repack**.
**Hydra** è un **Game Launcher** con il proprio **Client BitTorrent**.
<br>
Il launcher è scritto in TypeScript (Electron) and Python, che gestisce il sistema di torrenting appoggiandosi a libtorrent.
## Caratteristiche
- Motore di ricerca automatizzato sulle fonti di repack dal [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
- Client Bittorrent integrato
- Integrazione How Long To Beat (HLTB) nella pagina del gioco
- Percorso del download Personalizzato
- Notifiche di aggiornamenti sulla list dei repacks
- Supporto Windows e Linux
- Costantemente Aggiornato
- E molto altro ...
@@ -140,9 +139,8 @@ pip install -r requirements.txt
## Variabili d'ambiente
Avrai bisogno di una chiave API SteamGridDB per poter caricare le icone di gioco.
Se intendi avere onlinefix come repacker dovrai aggiungere le tue credenziali al file .env
Una volta ottenuta, puoi copiare e rinominare il file `.env.example` a `.env` e metterlo in `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Una volta ottenuta, puoi copiare e rinominare il file `.env.example` a `.env` e metterlo in `STEAMGRIDDB_API_KEY`.
## Esecuzione

View File

@@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra - to program uruchamiający gry z własnym wbudowanym klientem bittorrent i samodzielnie zarządzanym repackagerem..</strong>
<strong>Hydra - to program uruchamiający gry z własnym wbudowanym klientem bittorrent.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
@@ -56,17 +57,15 @@
## O nas
**Hydra** - jest **programem uruchamiającym gry** z wbudowanym **klientem BitTorrent** i **samozarządzającym się repackagerem**.
**Hydra** - jest **programem uruchamiającym gry** z wbudowanym **klientem BitTorrent**.
<br>
Ten launcher jest napisany w TypeScript (Electron) i Pythonie, który współpracuje z systemem torrent przy użyciu libtorrent.
## Cechy
- Samodzielnie zarządzany repackager wśród wszystkich najbardziej zaufanych stron na [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/").
- Własny wbudowany klient bittorrent
- Integracja funkcji How Long To Beat (HLTB) na stronie gry
- Personalizacja folderu pobierania
- Powiadomienia o aktualizacjach listy repacków
- Wsparcie dla systemów Windows i Linux
- Stała aktualizacja
- I nie tylko ...
@@ -144,9 +143,8 @@ pip install -r requirements.txt
## Zmienne środowiskowe
Będziesz potrzebował klucza API SteamGridDB, aby uzyskać ikony gier podczas instalacji.
Jeśli chcesz użyć onlinefix jako repackagera, musisz dodać swoje dane uwierzytelniające do .env
Po jego uzyskaniu można skopiować plik lub zmienić jego nazwę `.env.example` na `.env` i umieść go na`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Po jego uzyskaniu można skopiować plik lub zmienić jego nazwę `.env.example` na `.env` i umieść go na`STEAMGRIDDB_API_KEY`.
## Run

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra é um Launcher de Jogos com seu próprio cliente de bittorrent integrado e um wrapper autogerenciado para busca de repacks.</strong>
<strong>Hydra é um Launcher de Jogos com seu próprio cliente de bittorrent integrado.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
@@ -56,17 +57,15 @@
## <a name="about"> Sobre
**Hydra** é um **Launcher de Jogos** com seu próprio **Cliente BitTorrent incorporado** e um **raspador de repack auto-gerenciado**.
**Hydra** é um **Launcher de Jogos** com seu próprio **Cliente BitTorrent incorporado**.
<br>
O launcher é escrito em TypeScript (Electron) e Python, que lida com o sistema de torrent usando libtorrent.
## <a name="features"> Recursos
- Wrapper de repacks auto-gerenciado entre todos os sites mais confiáveis no [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
- Cliente BitTorrent incorporado próprio
- Integração com [How Long To Beat (HLTB)](https://howlongtobeat.com/) na página do jogo
- Personalização do caminho de downloads
- Notificações de atualização da lista de repacks
- Suporte para Windows e Linux
- Constantemente atualizado
- E mais ...
@@ -137,14 +136,13 @@ Instale as dependências Python necessárias usando o pip:
pip install -r requirements.txt
```
## <a name="environment-variables"></a> Environment variables
## <a name="environment-variables"></a> Variáveis de ambiente
Você precisará de uma chave da API SteamGridDB para buscar os ícones do jogo durante a instalação.
Se você deseja ter o onlinefix como um repacker, precisará adicionar suas credenciais ao arquivo .env.
Depois de obtê-lo, você pode copiar ou renomear o arquivo `.env.example` para `.env` e inserir `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME` e `ONLINEFIX_PASSWORD`.
Depois de obtê-lo, você pode copiar ou renomear o arquivo `.env.example` para `.env` e inserir `STEAMGRIDDB_API_KEY`.
## <a name="running"></a> Running
## <a name="running"></a> Executando
Uma vez que você tenha configurado tudo, você pode executar o seguinte comando para iniciar tanto o processo Electron quanto o cliente BitTorrent:

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra - это игровой лаунчер с собственным встроенным клиентом BitTorrent и самостоятельным scraper`ом для репаков.</strong>
<strong>Hydra - это игровой лаунчер с собственным встроенным клиентом BitTorrent.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
@@ -140,9 +141,8 @@ pip install -r requirements.txt
## Переменные среды
Вам понадобится ключ API SteamGridDB, чтобы получить значки игр при установке.
Если вы хотите использовать onlinefix в качестве репака, вам нужно добавить ваши учетные данные в файл .env.
Как только у вас будет ключ, вы можете скопировать или переименовать файл `.env.example` в `.env` и поместить в него `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Как только у вас будет ключ, вы можете скопировать или переименовать файл `.env.example` в `.env` и поместить в него `STEAMGRIDDB_API_KEY`.
## Запуск

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra - це ігровий лаунчер з власним вбудованим bittorrent-клієнтом і самокерованим збирачем репаків.</strong>
<strong>Hydra - це ігровий лаунчер з власним вбудованим bittorrent-клієнтом.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
@@ -144,9 +145,8 @@ pip install -r requirements.txt
## Змінні середовища
Вам знадобиться ключ API SteamGridDB, щоб отримати іконки ігор під час встановлення.
Якщо ви хочете використовувати onlinefix як перепакувальник, вам потрібно додати свої облікові дані до .env
Отримавши його, ви можете скопіювати або перейменувати файл `.env.example` на `.env`і помістити його на`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Отримавши його, ви можете скопіювати або перейменувати файл `.env.example` на `.env`і помістити його на`STEAMGRIDDB_API_KEY`.
## Запустіть

View File

@@ -1,4 +1,4 @@
appId: site.hydralauncher.hydra
appId: gg.hydralauncher.hydra
productName: Hydra
directories:
buildResources: build

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "2.1.6",
"version": "2.1.7",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -51,6 +51,7 @@
"color.js": "^1.2.0",
"create-desktop-shortcuts": "^1.11.0",
"date-fns": "^3.6.0",
"dexie": "^4.0.8",
"electron-log": "^5.1.4",
"electron-updater": "^6.1.8",
"fetch-cookie": "^3.0.1",

File diff suppressed because one or more lines are too long

View File

@@ -311,6 +311,7 @@
"report_reason_violence": "Violence",
"report_reason_spam": "Spam",
"report_reason_other": "Other",
"profile_reported": "Profile reported"
"profile_reported": "Profile reported",
"your_friend_code": "Your friend code:"
}
}

View File

@@ -8,8 +8,8 @@
"trending": "Tendencias",
"surprise_me": "¡Sorpréndeme!",
"no_results": "No se encontraron resultados",
"hot": "Caliente ahora",
"weekly": "📅 Los mejores juegos de la semana",
"hot": "Popular Ahora",
"weekly": "📅 Destacados de esta semana",
"start_typing": "Empieza a escribir para buscar..."
},
"sidebar": {
@@ -123,11 +123,11 @@
"download": "Descargar",
"download_count": "Descargas",
"download_error": "Esta opción de descarga no está disponible.",
"executable_path_in_use": "Ejecutable ya en uso por \"{{game}}\"",
"nsfw_content_description": "{{title}} incluye contenido que puede no ser adecuado para todas las edades. \n¿Estás seguro de que quieres continuar?",
"executable_path_in_use": "El ejecutable se encuentra en uso por \"{{game}}\"",
"nsfw_content_description": "{{title}} puede ser no adecuado para todas las edades por su contenido. \n¿Deseas continuar de igual forma?",
"nsfw_content_title": "Este juego contiene contenido inapropiado.",
"player_count": "Jugadores activos",
"refuse_nsfw_content": "Volver",
"refuse_nsfw_content": "No, gracias",
"stats": "Estadísticas"
},
"activation": {
@@ -209,7 +209,7 @@
"download_options_one": "",
"download_options_other": "",
"download_options_zero": "",
"friends_only": "solo amigos",
"friends_only": "Solo amigos",
"must_be_valid_url": "La fuente debe ser una URL válida.",
"privacy": "Privacidad",
"private": "Privado",
@@ -296,21 +296,21 @@
"no_blocked_users": "No has bloqueado a ningún usuario",
"friend_code_copied": "Código de amigo copiado",
"undo_friendship_modal_text": "Esto deshará tu amistad con {{displayName}}",
"displayname_max_length": "El nombre para mostrar debe tener como máximo 50 caracteres",
"displayname_min_length": "El nombre para mostrar debe tener al menos 3 caracteres",
"displayname_max_length": "El nombre a mostrar debe tener como máximo 50 caracteres",
"displayname_min_length": "El nombre a mostrar debe tener al menos 3 caracteres",
"locked_profile": "Este perfil es privado.",
"privacy_hint": "Para ajustar quién puede ver esto, ve a <0>Configuración</0>.",
"profile_locked": "",
"profile_locked": "Este perfil es privado",
"profile_reported": "Perfil reportado",
"report": "Informe",
"report": "Reportar",
"report_description": "Información adicional",
"report_description_placeholder": "Información adicional",
"report_profile": "Reportar este perfil",
"report_reason": "¿Por qué estás denunciando este perfil?",
"report_reason_hate": "Discurso de odio",
"report_reason": "¿Cual es el motivo del reporte?",
"report_reason_hate": "Discursos de odio",
"report_reason_other": "Otro",
"report_reason_sexual_content": "Contenido sexual",
"report_reason_spam": "Correo basura",
"report_reason_spam": "Spam/Contenido no deseado",
"report_reason_violence": "Violencia",
"required_field": "Este campo es obligatorio",
"image_process_failure": "Error al procesar la imagen"

View File

@@ -315,6 +315,7 @@
"report_reason_violence": "Violência",
"report_reason_spam": "Spam",
"report_reason_other": "Outro",
"profile_reported": "Perfil reportado"
"profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:"
}
}

View File

@@ -275,6 +275,7 @@
"no_pending_invites": "Não tens convites de amizade pendentes",
"no_blocked_users": "Não tens nenhum utilizador bloqueado",
"friend_code_copied": "Código de amigo copiado",
"image_process_failure": "Falha ao processar a imagem"
"image_process_failure": "Falha ao processar a imagem",
"your_friend_code": "Seu código de amigo:"
}
}

View File

@@ -1,8 +1,8 @@
import type { GameShop } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi, RepacksManager } from "@main/services";
import { CatalogueCategory, formatName, steamUrlBuilder } from "@shared";
import { HydraApi } from "@main/services";
import { CatalogueCategory, steamUrlBuilder } from "@shared";
import { steamGamesWorker } from "@main/workers";
const getCatalogue = async (
@@ -26,14 +26,9 @@ const getCatalogue = async (
name: "getById",
});
const repacks = RepacksManager.search({
query: formatName(steamGame.name),
});
return {
title: steamGame.name,
shop: game.shop,
repacks,
cover: steamUrlBuilder.library(game.objectId),
objectID: game.objectId,
};

View File

@@ -45,15 +45,17 @@ const getGameShopDetails = async (
const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
(result) => {
gameShopCacheRepository.upsert(
{
objectID,
shop: "steam",
language,
serializedData: JSON.stringify(result),
},
["objectID"]
);
if (result) {
gameShopCacheRepository.upsert(
{
objectID,
shop: "steam",
language,
serializedData: JSON.stringify(result),
},
["objectID"]
);
}
return result;
}

View File

@@ -2,8 +2,7 @@ import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { RepacksManager } from "@main/services";
import { steamUrlBuilder } from "@shared";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
@@ -15,13 +14,14 @@ const getGames = async (
{ name: "list" }
);
const entries = RepacksManager.findRepacksForCatalogueEntries(
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
);
return {
results: entries,
cursor: cursor + entries.length,
results: steamGames.map((steamGame) => ({
title: steamGame.name,
shop: "steam",
cover: steamUrlBuilder.library(steamGame.id),
objectID: steamGame.id,
})),
cursor: cursor + steamGames.length,
};
};

View File

@@ -3,32 +3,15 @@ import { shuffle } from "lodash-es";
import { getSteam250List } from "@main/services";
import { registerEvent } from "../register-event";
import { getSteamGameById } from "../helpers/search-games";
import type { Steam250Game } from "@types";
const state = { games: Array<Steam250Game>(), index: 0 };
const filterGames = async (games: Steam250Game[]) => {
const results: Steam250Game[] = [];
for (const game of games) {
const steamGame = await getSteamGameById(game.objectID);
if (steamGame?.repacks.length) {
results.push(game);
}
}
return results;
};
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
if (state.games.length == 0) {
const steam250List = await getSteam250List();
const filteredSteam250List = await filterGames(steam250List);
state.games = shuffle(filteredSteam250List);
state.games = shuffle(steam250List);
}
if (state.games.length == 0) {

View File

@@ -1,9 +0,0 @@
import { RepacksManager } from "@main/services";
import { registerEvent } from "../register-event";
const searchGameRepacks = (
_event: Electron.IpcMainInvokeEvent,
query: string
) => RepacksManager.search({ query });
registerEvent("searchGameRepacks", searchGameRepacks);

View File

@@ -1,7 +1,7 @@
import { registerEvent } from "../register-event";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { CatalogueEntry } from "@types";
import { HydraApi, RepacksManager } from "@main/services";
import { HydraApi } from "@main/services";
const searchGamesEvent = async (
_event: Electron.IpcMainInvokeEvent,
@@ -11,15 +11,13 @@ const searchGamesEvent = async (
{ objectId: string; title: string; shop: string }[]
>("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false });
const steamGames = games.map((game) => {
return games.map((game) => {
return convertSteamGameToCatalogueEntry({
id: Number(game.objectId),
name: game.title,
clientIcon: null,
});
});
return RepacksManager.findRepacksForCatalogueEntries(steamGames);
};
registerEvent("searchGames", searchGamesEvent);

View File

@@ -1,42 +0,0 @@
import { registerEvent } from "../register-event";
import { dataSource } from "@main/data-source";
import { DownloadSource } from "@main/entity";
import axios from "axios";
import { downloadSourceSchema } from "../helpers/validators";
import { insertDownloadsFromSource } from "@main/helpers";
import { RepacksManager } from "@main/services";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const response = await axios.get(url);
const source = downloadSourceSchema.parse(response.data);
const downloadSource = await dataSource.transaction(
async (transactionalEntityManager) => {
const downloadSource = await transactionalEntityManager
.getRepository(DownloadSource)
.save({
url,
name: source.name,
downloadCount: source.downloads.length,
});
await insertDownloadsFromSource(
transactionalEntityManager,
downloadSource,
source.downloads
);
return downloadSource;
}
);
await RepacksManager.updateRepacks();
return downloadSource;
};
registerEvent("addDownloadSource", addDownloadSource);

View File

@@ -0,0 +1,9 @@
import { registerEvent } from "../register-event";
import { knexClient } from "@main/knex-client";
const deleteDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => knexClient("download_source").where({ id }).delete();
registerEvent("deleteDownloadSource", deleteDownloadSource);

View File

@@ -1,11 +1,7 @@
import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { knexClient } from "@main/knex-client";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
downloadSourceRepository.find({
order: {
createdAt: "DESC",
},
});
knexClient.select("*").from("download_source");
registerEvent("getDownloadSources", getDownloadSources);

View File

@@ -1,13 +0,0 @@
import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { RepacksManager } from "@main/services";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => {
await downloadSourceRepository.delete(id);
await RepacksManager.updateRepacks();
};
registerEvent("removeDownloadSource", removeDownloadSource);

View File

@@ -1,7 +0,0 @@
import { registerEvent } from "../register-event";
import { fetchDownloadSourcesAndUpdate } from "@main/helpers";
const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
fetchDownloadSourcesAndUpdate();
registerEvent("syncDownloadSources", syncDownloadSources);

View File

@@ -1,27 +0,0 @@
import { registerEvent } from "../register-event";
import { downloadSourceRepository } from "@main/repository";
import { RepacksManager } from "@main/services";
import { downloadSourceWorker } from "@main/workers";
const validateDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const existingSource = await downloadSourceRepository.findOne({
where: { url },
});
if (existingSource)
throw new Error("Source with the same url already exists");
const repacks = RepacksManager.repacks;
return downloadSourceWorker.run(
{ url, repacks },
{
name: "validateDownloadSource",
}
);
};
registerEvent("validateDownloadSource", validateDownloadSource);

View File

@@ -1,7 +1,6 @@
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
import { steamGamesWorker } from "@main/workers";
import { RepacksManager } from "@main/services";
import { steamUrlBuilder } from "@shared";
export interface SearchGamesArgs {
@@ -17,7 +16,6 @@ export const convertSteamGameToCatalogueEntry = (
title: game.name,
shop: "steam" as GameShop,
cover: steamUrlBuilder.library(String(game.id)),
repacks: [],
});
export const getSteamGameById = async (
@@ -29,9 +27,5 @@ export const getSteamGameById = async (
if (!steamGame) return null;
const catalogueEntry = convertSteamGameToCatalogueEntry(steamGame);
const result = RepacksManager.findRepacksForCatalogueEntry(catalogueEntry);
return result;
return convertSteamGameToCatalogueEntry(steamGame);
};

View File

@@ -1,13 +0,0 @@
import { z } from "zod";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});

View File

@@ -7,7 +7,6 @@ import "./catalogue/get-games";
import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game";
import "./catalogue/search-games";
import "./catalogue/search-game-repacks";
import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games";
import "./hardware/get-disk-free-space";
@@ -37,11 +36,8 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./download-sources/delete-download-source";
import "./download-sources/get-download-sources";
import "./download-sources/validate-download-source";
import "./download-sources/add-download-source";
import "./download-sources/remove-download-source";
import "./download-sources/sync-download-sources";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
@@ -60,6 +56,7 @@ import "./profile/update-profile";
import "./profile/process-profile-image";
import "./profile/send-friend-request";
import "./profile/sync-friend-requests";
import "./notifications/publish-new-repacks-notification";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong");

View File

@@ -3,7 +3,6 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64 } from "@main/helpers";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
@@ -36,20 +35,12 @@ const addGameToLibrary = async (
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
: null;
await gameRepository
.insert({
title,
iconUrl,
objectID,
shop,
})
.then(() => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
});
await gameRepository.insert({
title,
iconUrl,
objectID,
shop,
});
}
const game = await gameRepository.findOne({ where: { objectID } });

View File

@@ -0,0 +1,29 @@
import { Notification } from "electron";
import { registerEvent } from "../register-event";
import { userPreferencesRepository } from "@main/repository";
import { t } from "i18next";
const publishNewRepacksNotification = async (
_event: Electron.IpcMainInvokeEvent,
newRepacksCount: number
) => {
if (newRepacksCount < 1) return;
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.repackUpdatesNotificationsEnabled) {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
}),
body: t("repack_count", {
ns: "notifications",
count: newRepacksCount,
}),
}).show();
}
};
registerEvent("publishNewRepacksNotification", publishNewRepacksNotification);

View File

@@ -1,7 +1,6 @@
import { registerEvent } from "../register-event";
import type { StartGameDownloadPayload } from "@types";
import { getFileBase64 } from "@main/helpers";
import { DownloadManager, HydraApi, logger } from "@main/services";
import { Not } from "typeorm";
@@ -9,36 +8,25 @@ import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, Repack } from "@main/entity";
import { DownloadQueue, Game } from "@main/entity";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
payload: StartGameDownloadPayload
) => {
const { repackId, objectID, title, shop, downloadPath, downloader, uri } =
payload;
const { objectID, title, shop, downloadPath, downloader, uri } = payload;
return dataSource.transaction(async (transactionalEntityManager) => {
const gameRepository = transactionalEntityManager.getRepository(Game);
const repackRepository = transactionalEntityManager.getRepository(Repack);
const downloadQueueRepository =
transactionalEntityManager.getRepository(DownloadQueue);
const [game, repack] = await Promise.all([
gameRepository.findOne({
where: {
objectID,
shop,
},
}),
repackRepository.findOne({
where: {
id: repackId,
},
}),
]);
if (!repack) return;
const game = await gameRepository.findOne({
where: {
objectID,
shop,
},
});
await DownloadManager.pauseDownload();
@@ -71,26 +59,16 @@ const startGameDownload = async (
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
: null;
await gameRepository
.insert({
title,
iconUrl,
objectID,
downloader,
shop,
status: "active",
downloadPath,
uri,
})
.then((result) => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
return result;
});
await gameRepository.insert({
title,
iconUrl,
objectID,
downloader,
shop,
status: "active",
downloadPath,
uri,
});
}
const updatedGame = await gameRepository.findOne({

View File

@@ -73,7 +73,6 @@ const getUser = async (
recentGames,
};
} catch (err) {
console.log(err);
return null;
}
};

View File

@@ -1,76 +0,0 @@
import { dataSource } from "@main/data-source";
import { DownloadSource, Repack } from "@main/entity";
import { downloadSourceSchema } from "@main/events/helpers/validators";
import { downloadSourceRepository } from "@main/repository";
import { RepacksManager } from "@main/services";
import { downloadSourceWorker } from "@main/workers";
import { chunk } from "lodash-es";
import type { EntityManager } from "typeorm";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { z } from "zod";
export const insertDownloadsFromSource = async (
trx: EntityManager,
downloadSource: DownloadSource,
downloads: z.infer<typeof downloadSourceSchema>["downloads"]
) => {
const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map(
(download) => ({
title: download.title,
uris: JSON.stringify(download.uris),
magnet: download.uris[0]!,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSource: { id: downloadSource.id },
})
);
const downloadsChunks = chunk(repacks, 800);
for (const chunk of downloadsChunks) {
await trx
.getRepository(Repack)
.createQueryBuilder()
.insert()
.values(chunk)
.updateEntity(false)
.orIgnore()
.execute();
}
};
export const fetchDownloadSourcesAndUpdate = async () => {
const downloadSources = await downloadSourceRepository.find({
order: {
id: "desc",
},
});
const results = await downloadSourceWorker.run(downloadSources, {
name: "getUpdatedRepacks",
});
await dataSource.transaction(async (transactionalEntityManager) => {
for (const result of results) {
if (result.etag !== null) {
await transactionalEntityManager.getRepository(DownloadSource).update(
{ id: result.id },
{
etag: result.etag,
status: result.status,
downloadCount: result.downloads.length,
}
);
await insertDownloadsFromSource(
transactionalEntityManager,
result,
result.downloads
);
}
}
await RepacksManager.updateRepacks();
});
};

View File

@@ -7,16 +7,6 @@ export const getFileBuffer = async (url: string) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
);
export const getFileBase64 = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => {
const base64 = Buffer.from(buffer).toString("base64");
const contentType = response.headers.get("content-type");
return `data:${contentType};base64,${base64}`;
})
);
export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
@@ -36,6 +26,4 @@ export const requestWebPage = async (url: string) => {
};
export const isPortableVersion = () =>
process.env.PORTABLE_EXECUTABLE_FILE != null;
export * from "./download-source";
process.env.PORTABLE_EXECUTABLE_FILE !== null;

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, net, protocol } from "electron";
import { app, BrowserWindow, net, protocol, session } from "electron";
import { init } from "@sentry/electron/main";
import updater from "electron-updater";
import i18n from "i18next";
@@ -68,14 +68,13 @@ const runMigrations = async () => {
});
await knexClient.migrate.latest(migrationConfig);
await knexClient.destroy();
};
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
electronApp.setAppUserModelId("site.hydralauncher.hydra");
electronApp.setAppUserModelId("gg.hydralauncher.hydra");
protocol.handle("local", (request) => {
const filePath = request.url.slice("local:".length);
@@ -104,6 +103,46 @@ app.whenReady().then(async () => {
WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
callback({
requestHeaders: {
...details.requestHeaders,
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
},
});
});
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const headers = {
"access-control-allow-origin": ["*"],
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],
"access-control-expose-headers": ["ETag"],
"access-control-allow-headers": [
"Content-Type, Authorization, X-Requested-With, If-None-Match",
],
"access-control-allow-credentials": ["true"],
};
if (details.method === "OPTIONS") {
callback({
cancel: false,
responseHeaders: {
...details.responseHeaders,
...headers,
},
statusLine: "HTTP/1.1 200 OK",
});
} else {
callback({
responseHeaders: {
...details.responseHeaders,
...headers,
},
});
}
});
});
app.on("browser-window-created", (_, window) => {

View File

@@ -1,25 +1,14 @@
import {
DownloadManager,
RepacksManager,
PythonInstance,
startMainLoop,
} from "./services";
import { DownloadManager, PythonInstance, startMainLoop } from "./services";
import {
downloadQueueRepository,
repackRepository,
userPreferencesRepository,
} from "./repository";
import { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/real-debrid";
import { fetchDownloadSourcesAndUpdate } from "./helpers";
import { publishNewRepacksNotifications } from "./services/notifications";
import { MoreThan } from "typeorm";
import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
const loadState = async (userPreferences: UserPreferences | null) => {
RepacksManager.updateRepacks();
import("./events");
if (userPreferences?.realDebridApiToken) {
@@ -46,18 +35,6 @@ const loadState = async (userPreferences: UserPreferences | null) => {
}
startMainLoop();
const now = new Date();
fetchDownloadSourcesAndUpdate().then(async () => {
const newRepacksCount = await repackRepository.count({
where: {
createdAt: MoreThan(now),
},
});
if (newRepacksCount > 0) publishNewRepacksNotifications(newRepacksCount);
});
};
userPreferencesRepository

View File

@@ -7,5 +7,4 @@ export * from "./download";
export * from "./how-long-to-beat";
export * from "./process-watcher";
export * from "./main-loop";
export * from "./repacks-manager";
export * from "./hydra-api";

View File

@@ -49,24 +49,6 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
}
};
export const publishNewRepacksNotifications = async (count: number) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.repackUpdatesNotificationsEnabled) {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
}),
body: t("repack_count", {
ns: "notifications",
count: count,
}),
}).show();
}
};
export const publishNotificationUpdateReadyToInstall = async (
version: string
) => {

View File

@@ -1,63 +0,0 @@
import { repackRepository } from "@main/repository";
import { formatName } from "@shared";
import { CatalogueEntry, GameRepack } from "@types";
import flexSearch from "flexsearch";
export class RepacksManager {
public static repacks: GameRepack[] = [];
private static repacksIndex = new flexSearch.Index();
public static async updateRepacks() {
this.repacks = await repackRepository
.find({
order: {
createdAt: "DESC",
},
})
.then((repacks) =>
repacks.map((repack) => {
const uris: string[] = [];
const magnet = repack?.magnet;
if (magnet) uris.push(magnet);
return {
...repack,
uris: [...uris, ...JSON.parse(repack.uris)],
};
})
);
for (let i = 0; i < this.repacks.length; i++) {
this.repacksIndex.remove(i);
}
this.repacksIndex = new flexSearch.Index();
for (let i = 0; i < this.repacks.length; i++) {
const repack = this.repacks[i];
const formattedTitle = formatName(repack.title);
this.repacksIndex.add(i, formattedTitle);
}
}
public static search(options: flexSearch.SearchOptions) {
return this.repacksIndex
.search({ ...options, query: formatName(options.query ?? "") })
.map((index) => this.repacks[index]);
}
public static findRepacksForCatalogueEntry(entry: CatalogueEntry) {
const repacks = this.search({ query: formatName(entry.title) });
return { ...entry, repacks };
}
public static findRepacksForCatalogueEntries(entries: CatalogueEntry[]) {
return entries.map((entry) => {
const repacks = this.search({ query: formatName(entry.title) });
return { ...entry, repacks };
});
}
}

View File

@@ -106,7 +106,7 @@ export class WindowManager {
});
authWindow.loadURL(
`https://auth.hydralauncher.gg/?${searchParams.toString()}`
`${import.meta.env.MAIN_VITE_AUTH_URL}/?${searchParams.toString()}`
);
authWindow.once("ready-to-show", () => {

View File

@@ -3,6 +3,7 @@
interface ImportMetaEnv {
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
readonly MAIN_VITE_API_URL: string;
readonly MAIN_VITE_AUTH_URL: string;
readonly MAIN_VITE_SENTRY_DSN: string;
}

View File

@@ -1,71 +0,0 @@
import { downloadSourceSchema } from "@main/events/helpers/validators";
import { DownloadSourceStatus } from "@shared";
import type { DownloadSource, GameRepack } from "@types";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { z } from "zod";
export type DownloadSourceResponse = z.infer<typeof downloadSourceSchema> & {
etag: string | null;
status: DownloadSourceStatus;
};
export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
const results: DownloadSourceResponse[] = [];
for (const downloadSource of downloadSources) {
const headers = new AxiosHeaders();
if (downloadSource.etag) {
headers.set("If-None-Match", downloadSource.etag);
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
results.push({
...downloadSource,
downloads: source.downloads,
etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate,
});
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
results.push({
...downloadSource,
downloads: [],
etag: null,
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
return results;
};
export const validateDownloadSource = async ({
url,
repacks,
}: {
url: string;
repacks: GameRepack[];
}) => {
const response = await axios.get(url);
const source = downloadSourceSchema.parse(response.data);
const existingUris = source.downloads
.flatMap((download) => download.uris)
.filter((uri) => repacks.some((repack) => repack.magnet === uri));
return {
name: source.name,
downloadCount: source.downloads.length - existingUris.length,
};
};

View File

@@ -1,6 +1,5 @@
import path from "node:path";
import steamGamesWorkerPath from "./steam-games.worker?modulePath";
import downloadSourceWorkerPath from "./download-source.worker?modulePath";
import Piscina from "piscina";
@@ -13,7 +12,3 @@ export const steamGamesWorker = new Piscina({
},
maxThreads: 1,
});
export const downloadSourceWorker = new Piscina({
filename: downloadSourceWorkerPath,
});

View File

@@ -60,13 +60,8 @@ contextBridge.exposeInMainWorld("electron", {
/* Download sources */
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
validateDownloadSource: (url: string) =>
ipcRenderer.invoke("validateDownloadSource", url),
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
removeDownloadSource: (id: number) =>
ipcRenderer.invoke("removeDownloadSource", id),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
deleteDownloadSource: (id: number) =>
ipcRenderer.invoke("deleteDownloadSource", id),
/* Library */
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
@@ -182,4 +177,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("on-signout", listener);
return () => ipcRenderer.removeListener("on-signout", listener);
},
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
});

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useContext, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
@@ -26,6 +26,8 @@ import {
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { downloadSourcesWorker } from "./workers";
import { repacksContext } from "./context";
export interface AppProps {
children: React.ReactNode;
@@ -37,8 +39,12 @@ export function App() {
const { t } = useTranslation("app");
const downloadSourceMigrationLock = useRef(false);
const { clearDownload, setLastPacket } = useDownload();
const { indexRepacks } = useContext(repacksContext);
const {
isFriendsModalVisible,
friendRequetsModalTab,
@@ -197,7 +203,7 @@ export function App() {
useEffect(() => {
new MutationObserver(() => {
const modal = document.body.querySelector("[role=modal]");
const modal = document.body.querySelector("[role=dialog]");
dispatch(toggleDraggingDisabled(Boolean(modal)));
}).observe(document.body, {
@@ -206,6 +212,49 @@ export function App() {
});
}, [dispatch, draggingDisabled]);
useEffect(() => {
if (downloadSourceMigrationLock.current) return;
downloadSourceMigrationLock.current = true;
window.electron.getDownloadSources().then(async (downloadSources) => {
if (!downloadSources.length) {
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
channel.onmessage = (event: MessageEvent<number>) => {
const newRepacksCount = event.data;
window.electron.publishNewRepacksNotification(newRepacksCount);
};
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}
for (const downloadSource of downloadSources) {
const channel = new BroadcastChannel(
`download_sources:import:${downloadSource.url}`
);
await new Promise((resolve) => {
downloadSourcesWorker.postMessage([
"IMPORT_DOWNLOAD_SOURCE",
downloadSource.url,
]);
channel.onmessage = () => {
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
resolve(true);
});
indexRepacks();
channel.close();
};
}).catch(() => channel.close());
}
downloadSourceMigrationLock.current = false;
});
}, [indexRepacks]);
const handleToastClose = useCallback(() => {
dispatch(closeToast());
}, [dispatch]);

View File

@@ -1,13 +1,14 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import type { CatalogueEntry, GameStats } from "@types";
import type { CatalogueEntry, GameRepack, GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { useCallback, useState } from "react";
import { useCallback, useContext, useEffect, useState } from "react";
import { useFormat } from "@renderer/hooks";
import { repacksContext } from "@renderer/context";
export interface GameCardProps
extends React.DetailedHTMLProps<
@@ -25,9 +26,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { t } = useTranslation("game_card");
const [stats, setStats] = useState<GameStats | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
useEffect(() => {
if (!isIndexingRepacks) {
searchRepacks(game.title).then((repacks) => {
setRepacks(repacks);
});
}
}, [game, isIndexingRepacks, searchRepacks]);
const uniqueRepackers = Array.from(
new Set(game.repacks.map(({ repacker }) => repacker))
new Set(repacks.map(({ repacker }) => repacker))
);
const handleHover = useCallback(() => {

View File

@@ -5,6 +5,7 @@ import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
const LONG_POLLING_INTERVAL = 60_000;
@@ -68,6 +69,23 @@ export function SidebarProfile() {
);
}, [userDetails, t, friendRequestCount, showFriendsModal]);
const gameRunningDetails = () => {
if (!userDetails || !gameRunning) return null;
if (gameRunning.iconUrl) {
return (
<img
alt={gameRunning.title}
width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl}
/>
);
}
return <SteamLogo />;
};
return (
<div className={styles.profileContainer}>
<button
@@ -108,14 +126,7 @@ export function SidebarProfile() {
)}
</div>
{userDetails && gameRunning && (
<img
alt={gameRunning.title}
width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl!}
/>
)}
{gameRunningDetails()}
</div>
</button>

View File

@@ -1,4 +1,10 @@
import { createContext, useCallback, useEffect, useState } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
@@ -16,6 +22,7 @@ import type {
import { useTranslation } from "react-i18next";
import { GameDetailsContext } from "./game-details.context.types";
import { SteamContentDescriptor } from "@shared";
import { repacksContext } from "../repacks/repacks.context";
export const gameDetailsContext = createContext<GameDetailsContext>({
game: null,
@@ -52,7 +59,6 @@ export function GameDetailsContextProvider({
const { objectID, shop } = useParams();
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
@@ -64,10 +70,22 @@ export function GameDetailsContextProvider({
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [searchParams] = useSearchParams();
const gameTitle = searchParams.get("title")!;
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
useEffect(() => {
if (!isIndexingRepacks) {
searchRepacks(gameTitle).then((repacks) => {
setRepacks(repacks);
});
}
}, [game, gameTitle, isIndexingRepacks, searchRepacks]);
const { i18n } = useTranslation("game_details");
const dispatch = useAppDispatch();
@@ -91,37 +109,31 @@ export function GameDetailsContextProvider({
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
useEffect(() => {
Promise.allSettled([
window.electron.getGameShopDetails(
window.electron
.getGameShopDetails(
objectID!,
shop as GameShop,
getSteamLanguage(i18n.language)
),
window.electron.searchGameRepacks(gameTitle),
window.electron.getGameStats(objectID!, shop as GameShop),
])
.then(([appDetailsResult, repacksResult, statsResult]) => {
if (appDetailsResult.status === "fulfilled") {
setShopDetails(appDetailsResult.value);
)
.then((result) => {
setShopDetails(result);
if (
appDetailsResult.value?.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
)
) {
setHasNSFWContentBlocked(true);
}
if (
result?.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
)
) {
setHasNSFWContentBlocked(true);
}
if (repacksResult.status === "fulfilled")
setRepacks(repacksResult.value);
if (statsResult.status === "fulfilled") setStats(statsResult.value);
})
.finally(() => {
setIsLoading(false);
});
window.electron.getGameStats(objectID!, shop as GameShop).then((result) => {
setStats(result);
});
updateGame();
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);

View File

@@ -1,3 +1,4 @@
export * from "./game-details/game-details.context";
export * from "./settings/settings.context";
export * from "./user-profile/user-profile.context";
export * from "./repacks/repacks.context";

View File

@@ -0,0 +1,67 @@
import type { GameRepack } from "@types";
import { createContext, useCallback, useEffect, useState } from "react";
import { repacksWorker } from "@renderer/workers";
export interface RepacksContext {
searchRepacks: (query: string) => Promise<GameRepack[]>;
indexRepacks: () => void;
isIndexingRepacks: boolean;
}
export const repacksContext = createContext<RepacksContext>({
searchRepacks: async () => [] as GameRepack[],
indexRepacks: () => {},
isIndexingRepacks: false,
});
const { Provider } = repacksContext;
export const { Consumer: RepacksContextConsumer } = repacksContext;
export interface RepacksContextProps {
children: React.ReactNode;
}
export function RepacksContextProvider({ children }: RepacksContextProps) {
const [isIndexingRepacks, setIsIndexingRepacks] = useState(true);
const searchRepacks = useCallback(async (query: string) => {
return new Promise<GameRepack[]>((resolve) => {
const channelId = crypto.randomUUID();
repacksWorker.postMessage([channelId, query]);
const channel = new BroadcastChannel(`repacks:search:${channelId}`);
channel.onmessage = (event: MessageEvent<GameRepack[]>) => {
resolve(event.data);
channel.close();
};
return [];
});
}, []);
const indexRepacks = useCallback(() => {
setIsIndexingRepacks(true);
repacksWorker.postMessage("INDEX_REPACKS");
repacksWorker.onmessage = () => {
setIsIndexingRepacks(false);
};
}, []);
useEffect(() => {
indexRepacks();
}, [indexRepacks]);
return (
<Provider
value={{
searchRepacks,
indexRepacks,
isIndexingRepacks,
}}
>
{children}
</Provider>
);
}

View File

@@ -102,12 +102,7 @@ declare global {
/* Download sources */
getDownloadSources: () => Promise<DownloadSource[]>;
validateDownloadSource: (
url: string
) => Promise<{ name: string; downloadCount: number }>;
addDownloadSource: (url: string) => Promise<DownloadSource>;
removeDownloadSource: (id: number) => Promise<void>;
syncDownloadSources: () => Promise<void>;
deleteDownloadSource: (id: number) => Promise<void>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
@@ -171,6 +166,9 @@ declare global {
action: FriendRequestAction
) => Promise<void>;
sendFriendRequest: (userId: string) => Promise<void>;
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
}
interface Window {

13
src/renderer/src/dexie.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Dexie } from "dexie";
export const db = new Dexie("Hydra");
db.version(1).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
});
export const downloadSourcesTable = db.table("downloadSources");
export const repacksTable = db.table("repacks");
db.open();

View File

@@ -90,19 +90,9 @@ export function useUserDetails() {
username: userDetails?.username || "",
});
},
[updateUserDetails]
[updateUserDetails, userDetails?.username]
);
const fetchFriendRequests = useCallback(async () => {
return window.electron
.getFriendRequests()
.then((friendRequests) => {
syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch]);
const syncFriendRequests = useCallback(async () => {
return window.electron
.syncFriendRequests()
@@ -112,6 +102,16 @@ export function useUserDetails() {
.catch(() => {});
}, [dispatch]);
const fetchFriendRequests = useCallback(async () => {
return window.electron
.getFriendRequests()
.then((friendRequests) => {
syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch, syncFriendRequests]);
const showFriendsModal = useCallback(
(initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible({ initialTab, userId }));

View File

@@ -29,6 +29,9 @@ import { store } from "./store";
import resources from "@locales";
import "./workers";
import { RepacksContextProvider } from "./context";
Sentry.init({});
i18n
@@ -54,19 +57,21 @@ i18n
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<HashRouter>
<Routes>
<Route element={<App />}>
<Route path="/" Component={Home} />
<Route path="/catalogue" Component={Catalogue} />
<Route path="/downloads" Component={Downloads} />
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
<Route path="/profile/:userId" Component={Profile} />
</Route>
</Routes>
</HashRouter>
<RepacksContextProvider>
<HashRouter>
<Routes>
<Route element={<App />}>
<Route path="/" Component={Home} />
<Route path="/catalogue" Component={Catalogue} />
<Route path="/downloads" Component={Downloads} />
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
<Route path="/profile/:userId" Component={Profile} />
</Route>
</Routes>
</HashRouter>
</RepacksContextProvider>
</Provider>
</React.StrictMode>
);

View File

@@ -1,6 +1,5 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import parseTorrent from "parse-torrent";
import { Badge, Button, Modal, TextField } from "@renderer/components";
import type { GameRepack } from "@types";
@@ -33,8 +32,6 @@ export function RepacksModal({
const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const [infoHash, setInfoHash] = useState<string | null>(null);
const { repacks, game } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@@ -43,18 +40,9 @@ export function RepacksModal({
return orderBy(repacks, (repack) => repack.uploadDate, "desc");
}, [repacks]);
const getInfoHash = useCallback(async () => {
if (game?.uri?.startsWith("magnet:")) {
const torrent = await parseTorrent(game?.uri ?? "");
if (torrent.infoHash) setInfoHash(torrent.infoHash);
}
}, [game]);
useEffect(() => {
setFilteredRepacks(sortedRepacks);
if (game?.uri) getInfoHash();
}, [sortedRepacks, visible, game, getInfoHash]);
}, [sortedRepacks, visible, game]);
const handleRepackClick = (repack: GameRepack) => {
setRepack(repack);
@@ -77,9 +65,6 @@ export function RepacksModal({
};
const checkIfLastDownloadedOption = (repack: GameRepack) => {
if (infoHash) return repack.uris.some((uri) => uri.includes(infoHash));
if (!game?.uri) return false;
return repack.uris.some((uri) => uri.includes(game?.uri ?? ""));
};

View File

@@ -8,6 +8,9 @@ import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { downloadSourcesTable } from "@renderer/dexie";
import { DownloadSourceValidationResult } from "@types";
import { downloadSourcesWorker } from "@renderer/workers";
interface AddDownloadSourceModalProps {
visible: boolean;
@@ -39,41 +42,48 @@ export function AddDownloadSourceModal({
setValue,
setError,
clearErrors,
formState: { errors },
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: yupResolver(schema),
});
const [validationResult, setValidationResult] = useState<{
name: string;
downloadCount: number;
} | null>(null);
const [validationResult, setValidationResult] =
useState<DownloadSourceValidationResult | null>(null);
const { sourceUrl } = useContext(settingsContext);
const onSubmit = useCallback(
async (values: FormValues) => {
setIsLoading(true);
const existingDownloadSource = await downloadSourcesTable
.where({ url: values.url })
.first();
try {
const result = await window.electron.validateDownloadSource(values.url);
setValidationResult(result);
if (existingDownloadSource) {
setError("url", {
type: "server",
message: t("source_already_exists"),
});
setUrl(values.url);
} catch (error: unknown) {
if (error instanceof Error) {
if (
error.message.endsWith("Source with the same url already exists")
) {
setError("url", {
type: "server",
message: t("source_already_exists"),
});
}
}
} finally {
setIsLoading(false);
return;
}
downloadSourcesWorker.postMessage([
"VALIDATE_DOWNLOAD_SOURCE",
values.url,
]);
const channel = new BroadcastChannel(
`download_sources:validate:${values.url}`
);
channel.onmessage = (
event: MessageEvent<DownloadSourceValidationResult>
) => {
setValidationResult(event.data);
channel.close();
};
setUrl(values.url);
},
[setError, t]
);
@@ -91,9 +101,21 @@ export function AddDownloadSourceModal({
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const handleAddDownloadSource = async () => {
await window.electron.addDownloadSource(url);
onClose();
onAddDownloadSource();
setIsLoading(true);
if (validationResult) {
const channel = new BroadcastChannel(`download_sources:import:${url}`);
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
channel.onmessage = () => {
setIsLoading(false);
onClose();
onAddDownloadSource();
channel.close();
};
}
};
return (
@@ -122,7 +144,7 @@ export function AddDownloadSourceModal({
theme="outline"
style={{ alignSelf: "flex-end" }}
onClick={handleSubmit(onSubmit)}
disabled={isLoading}
disabled={isSubmitting || isLoading}
>
{t("validate_download_source")}
</Button>

View File

@@ -10,7 +10,9 @@ import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
import { repacksContext, settingsContext } from "@renderer/context";
import { downloadSourcesTable } from "@renderer/dexie";
import { downloadSourcesWorker } from "@renderer/workers";
export function SettingsDownloadSources() {
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
@@ -18,16 +20,23 @@ export function SettingsDownloadSources() {
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isSyncingDownloadSources, setIsSyncingDownloadSources] =
useState(false);
const [isRemovingDownloadSource, setIsRemovingDownloadSource] =
useState(false);
const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
const { t } = useTranslation("settings");
const { showSuccessToast } = useToast();
const { indexRepacks } = useContext(repacksContext);
const getDownloadSources = async () => {
return window.electron.getDownloadSources().then((sources) => {
setDownloadSources(sources);
});
await downloadSourcesTable
.toCollection()
.sortBy("createdAt")
.then((sources) => {
setDownloadSources(sources.reverse());
});
};
useEffect(() => {
@@ -38,14 +47,24 @@ export function SettingsDownloadSources() {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
const handleRemoveSource = async (id: number) => {
await window.electron.removeDownloadSource(id);
showSuccessToast(t("removed_download_source"));
const handleRemoveSource = (id: number) => {
setIsRemovingDownloadSource(true);
const channel = new BroadcastChannel(`download_sources:delete:${id}`);
getDownloadSources();
downloadSourcesWorker.postMessage(["DELETE_DOWNLOAD_SOURCE", id]);
channel.onmessage = () => {
showSuccessToast(t("removed_download_source"));
getDownloadSources();
indexRepacks();
setIsRemovingDownloadSource(false);
channel.close();
};
};
const handleAddDownloadSource = async () => {
indexRepacks();
await getDownloadSources();
showSuccessToast(t("added_download_source"));
};
@@ -53,15 +72,17 @@ export function SettingsDownloadSources() {
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
window.electron
.syncDownloadSources()
.then(() => {
showSuccessToast(t("download_sources_synced"));
getDownloadSources();
})
.finally(() => {
setIsSyncingDownloadSources(false);
});
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
channel.onmessage = () => {
showSuccessToast(t("download_sources_synced"));
getDownloadSources();
setIsSyncingDownloadSources(false);
channel.close();
};
};
const statusTitle = {
@@ -88,7 +109,11 @@ export function SettingsDownloadSources() {
<Button
type="button"
theme="outline"
disabled={!downloadSources.length || isSyncingDownloadSources}
disabled={
!downloadSources.length ||
isSyncingDownloadSources ||
isRemovingDownloadSource
}
onClick={syncDownloadSources}
>
<SyncIcon />
@@ -99,6 +124,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => setShowAddDownloadSourceModal(true)}
disabled={isSyncingDownloadSources}
>
<PlusCircleIcon />
{t("add_download_source")}
@@ -148,6 +174,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => handleRemoveSource(downloadSource.id)}
disabled={isRemovingDownloadSource}
>
<NoEntryIcon />
{t("remove_download_source")}

View File

@@ -81,7 +81,7 @@ export const UserFriendModal = ({
alignItems: "center",
}}
>
<p>Seu código de amigo: </p>
<p>{t("your_friend_code")}</p>
<button
className={styles.friendCodeButton}
onClick={copyToClipboard}

View File

@@ -0,0 +1,165 @@
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
import { z } from "zod";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { DownloadSourceStatus } from "@shared";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
type Payload =
| ["IMPORT_DOWNLOAD_SOURCE", string]
| ["DELETE_DOWNLOAD_SOURCE", number]
| ["VALIDATE_DOWNLOAD_SOURCE", string]
| ["SYNC_DOWNLOAD_SOURCES", string];
self.onmessage = async (event: MessageEvent<Payload>) => {
const [type, data] = event.data;
if (type === "VALIDATE_DOWNLOAD_SOURCE") {
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
const { name } = downloadSourceSchema.parse(response.data);
const channel = new BroadcastChannel(`download_sources:validate:${data}`);
channel.postMessage({
name,
etag: response.headers["etag"],
downloadCount: response.data.downloads.length,
});
}
if (type === "DELETE_DOWNLOAD_SOURCE") {
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
await repacksTable.where({ downloadSourceId: data }).delete();
await downloadSourcesTable.where({ id: data }).delete();
});
const channel = new BroadcastChannel(`download_sources:delete:${data}`);
channel.postMessage(true);
}
if (type === "IMPORT_DOWNLOAD_SOURCE") {
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
const now = new Date();
const id = await downloadSourcesTable.add({
url: data,
name: response.data.name,
etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate,
downloadCount: response.data.downloads.length,
createdAt: now,
updatedAt: now,
});
const downloadSource = await downloadSourcesTable.get(id);
const repacks = response.data.downloads.map((download) => ({
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: response.data.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource!.id,
createdAt: now,
updatedAt: now,
}));
await repacksTable.bulkAdd(repacks);
});
const channel = new BroadcastChannel(`download_sources:import:${data}`);
channel.postMessage(true);
}
if (type === "SYNC_DOWNLOAD_SOURCES") {
const channel = new BroadcastChannel(`download_sources:sync:${data}`);
let newRepacksCount = 0;
try {
const downloadSources = await downloadSourcesTable.toArray();
const existingRepacks = await repacksTable.toArray();
for (const downloadSource of downloadSources) {
const headers = new AxiosHeaders();
if (downloadSource.etag) {
headers.set("If-None-Match", downloadSource.etag);
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
await db.transaction(
"rw",
repacksTable,
downloadSourcesTable,
async () => {
await downloadSourcesTable.update(downloadSource.id, {
etag: response.headers["etag"],
downloadCount: source.downloads.length,
status: DownloadSourceStatus.UpToDate,
});
const now = new Date();
const repacks = source.downloads
.filter(
(download) =>
!existingRepacks.some(
(repack) => repack.title === download.title
)
)
.map((download) => ({
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: source.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
}));
newRepacksCount += repacks.length;
await repacksTable.bulkAdd(repacks);
}
);
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
await downloadSourcesTable.update(downloadSource.id, {
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
channel.postMessage(newRepacksCount);
} catch (err) {
channel.postMessage(-1);
}
}
};

View File

@@ -0,0 +1,5 @@
import RepacksWorker from "./repacks.worker?worker";
import DownloadSourcesWorker from "./download-sources.worker?worker";
export const repacksWorker = new RepacksWorker();
export const downloadSourcesWorker = new DownloadSourcesWorker();

View File

@@ -0,0 +1,50 @@
import { repacksTable } from "@renderer/dexie";
import { formatName } from "@shared";
import { GameRepack } from "@types";
import flexSearch from "flexsearch";
const index = new flexSearch.Index();
const state = {
repacks: [] as any[],
};
interface SerializedGameRepack extends Omit<GameRepack, "uris"> {
uris: string;
}
self.onmessage = async (
event: MessageEvent<[string, string] | "INDEX_REPACKS">
) => {
if (event.data === "INDEX_REPACKS") {
repacksTable
.toCollection()
.sortBy("uploadDate")
.then((results) => {
state.repacks = results.reverse();
for (let i = 0; i < state.repacks.length; i++) {
const repack = state.repacks[i];
const formattedTitle = formatName(repack.title);
index.add(i, formattedTitle);
}
self.postMessage("INDEXING_COMPLETE");
});
} else {
const [requestId, query] = event.data;
const results = index.search(formatName(query)).map((index) => {
const repack = state.repacks.at(index as number) as SerializedGameRepack;
return {
...repack,
uris: [...repack.uris, repack.magnet].filter(Boolean),
};
});
const channel = new BroadcastChannel(`repacks:search:${requestId}`);
channel.postMessage(results);
}
};

View File

@@ -44,7 +44,6 @@ export interface CatalogueEntry {
title: string;
/* Epic Games covers cannot be guessed with objectID */
cover: string;
repacks: GameRepack[];
}
export interface UserGame {
@@ -71,7 +70,6 @@ export interface Game {
status: GameStatus | null;
folderName: string;
downloadPath: string | null;
repacks: GameRepack[];
progress: number;
bytesDownloaded: number;
playTimeInMilliseconds: number;
@@ -226,6 +224,19 @@ export interface UpdateProfileRequest {
bio?: string;
}
export interface DownloadSourceDownload {
title: string;
uris: string[];
uploadDate: string;
fileSize: string;
}
export interface DownloadSourceValidationResult {
name: string;
etag: string;
downloadCount: number;
}
export interface DownloadSource {
id: number;
name: string;

View File

@@ -13,10 +13,13 @@ start_download_payload = sys.argv[4]
torrent_downloader = None
if start_download_payload:
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
torrent_downloader = TorrentDownloader(torrent_port)
torrent_downloader.start_download(initial_download['game_id'], initial_download['magnet'], initial_download['save_path'])
try:
if start_download_payload:
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
torrent_downloader = TorrentDownloader(torrent_port)
torrent_downloader.start_download(initial_download['game_id'], initial_download['magnet'], initial_download['save_path'])
except (json.JSONDecodeError, KeyError, ValueError) as e:
sys.stderr.write(f"Failed to start torrent download: {e}\n")
class Handler(BaseHTTPRequestHandler):
rpc_password_header = 'x-hydra-rpc-password'
@@ -30,95 +33,131 @@ class Handler(BaseHTTPRequestHandler):
sys.stderr.write("%s - - [%s] %s\n" %
(self.address_string(),
self.log_date_time_string(),
format%args))
format % args))
def log_message(self, format, *args):
for route in self.skip_log_routes:
if route in args[0]: return
if route in args[0]:
return
super().log_message(format, *args)
def do_GET(self):
if self.path == "/status":
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
self.end_headers()
return
try:
if self.path == "/status":
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
status = torrent_downloader.get_download_status()
self.wfile.write(json.dumps(status).encode('utf-8'))
elif self.path == "/healthcheck":
self.send_response(200)
self.end_headers()
elif self.path == "/process-list":
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
self.end_headers()
return
process_list = [proc.info for proc in psutil.process_iter(['exe', 'pid', 'username'])]
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(process_list).encode('utf-8'))
def do_POST(self):
global torrent_downloader
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
self.end_headers()
return
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
if self.path == "/profile-image":
parsed_image_path = data['image_path']
try:
parsed_image_path, mime_type = ProfileImageProcessor.process_image(parsed_image_path)
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({'imagePath': parsed_image_path, 'mimeType': mime_type}).encode('utf-8'))
except:
self.send_response(400)
status = torrent_downloader.get_download_status()
self.wfile.write(json.dumps(status).encode('utf-8'))
elif self.path == "/healthcheck":
self.send_response(200)
self.end_headers()
elif self.path == "/action":
if torrent_downloader is None:
torrent_downloader = TorrentDownloader(torrent_port)
elif self.path == "/process-list":
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
self.end_headers()
return
if data['action'] == 'start':
torrent_downloader.start_download(data['game_id'], data['magnet'], data['save_path'])
elif data['action'] == 'pause':
torrent_downloader.pause_download(data['game_id'])
elif data['action'] == 'cancel':
torrent_downloader.cancel_download(data['game_id'])
elif data['action'] == 'kill-torrent':
torrent_downloader.abort_session()
torrent_downloader = None
process_list = [proc.info for proc in psutil.process_iter(['exe', 'pid', 'username'])]
self.send_response(200)
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(process_list).encode('utf-8'))
except Exception as e:
sys.stderr.write(f"Error in GET request: {e}\n")
self.send_response(500)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
global torrent_downloader
try:
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
self.end_headers()
return
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
if self.path == "/profile-image":
parsed_image_path = data['image_path']
try:
parsed_image_path, mime_type = ProfileImageProcessor.process_image(parsed_image_path)
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({'imagePath': parsed_image_path, 'mimeType': mime_type}).encode('utf-8'))
except Exception as e:
sys.stderr.write(f"Error processing profile image: {e}\n")
self.send_response(400)
self.end_headers()
elif self.path == "/action":
if torrent_downloader is None:
torrent_downloader = TorrentDownloader(torrent_port)
if data['action'] == 'start':
try:
torrent_downloader.start_download(data['game_id'], data['magnet'], data['save_path'])
except Exception as e:
sys.stderr.write(f"Error starting torrent: {e}\n")
self.send_response(500)
self.end_headers()
return
elif data['action'] == 'pause':
try:
torrent_downloader.pause_download(data['game_id'])
except Exception as e:
sys.stderr.write(f"Error pausing torrent: {e}\n")
self.send_response(500)
self.end_headers()
return
elif data['action'] == 'cancel':
try:
torrent_downloader.cancel_download(data['game_id'])
except Exception as e:
sys.stderr.write(f"Error canceling torrent: {e}\n")
self.send_response(500)
self.end_headers()
return
elif data['action'] == 'kill-torrent':
try:
torrent_downloader.abort_session()
torrent_downloader = None
except Exception as e:
sys.stderr.write(f"Error killing torrent: {e}\n")
self.send_response(500)
self.end_headers()
return
self.send_response(200)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
except Exception as e:
sys.stderr.write(f"Error in POST request: {e}\n")
self.send_response(500)
self.end_headers()
if __name__ == "__main__":
httpd = HTTPServer(("", int(http_port)), Handler)
httpd.serve_forever()
try:
httpd = HTTPServer(("", int(http_port)), Handler)
sys.stderr.write(f"Server running on port {http_port}\n")
httpd.serve_forever()
except Exception as e:
sys.stderr.write(f"Failed to start HTTP server: {e}\n")

View File

@@ -4,7 +4,11 @@ class TorrentDownloader:
def __init__(self, port: str):
self.torrent_handles = {}
self.downloading_game_id = -1
self.session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=port)})
try:
self.session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=port)})
except Exception as e:
raise RuntimeError(f"Failed to initialize torrent session: {e}")
self.trackers = [
"udp://tracker.opentrackr.org:1337/announce",
"http://tracker.opentrackr.org:1337/announce",
@@ -103,56 +107,81 @@ class TorrentDownloader:
]
def start_download(self, game_id: int, magnet: str, save_path: str):
params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers}
torrent_handle = self.session.add_torrent(params)
self.torrent_handles[game_id] = torrent_handle
torrent_handle.set_flags(lt.torrent_flags.auto_managed)
torrent_handle.resume()
try:
params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers}
torrent_handle = self.session.add_torrent(params)
self.torrent_handles[game_id] = torrent_handle
torrent_handle.set_flags(lt.torrent_flags.auto_managed)
torrent_handle.resume()
self.downloading_game_id = game_id
except Exception as e:
raise RuntimeError(f"Failed to start download for game {game_id}: {e}")
self.downloading_game_id = game_id
def pause_download(self, game_id: int):
torrent_handle = self.torrent_handles.get(game_id)
if torrent_handle:
torrent_handle.pause()
torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
self.downloading_game_id = -1
try:
torrent_handle = self.torrent_handles.get(game_id)
if torrent_handle:
torrent_handle.pause()
torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
self.downloading_game_id = -1
else:
raise KeyError(f"Torrent handle not found for game {game_id}")
except Exception as e:
raise RuntimeError(f"Failed to pause download for game {game_id}: {e}")
def cancel_download(self, game_id: int):
torrent_handle = self.torrent_handles.get(game_id)
if torrent_handle:
torrent_handle.pause()
self.session.remove_torrent(torrent_handle)
self.torrent_handles[game_id] = None
self.downloading_game_id = -1
try:
torrent_handle = self.torrent_handles.get(game_id)
if torrent_handle:
torrent_handle.pause()
self.session.remove_torrent(torrent_handle)
self.torrent_handles[game_id] = None
self.downloading_game_id = -1
else:
raise KeyError(f"Torrent handle not found for game {game_id}")
except Exception as e:
raise RuntimeError(f"Failed to cancel download for game {game_id}: {e}")
def abort_session(self):
for game_id in self.torrent_handles:
torrent_handle = self.torrent_handles[game_id]
torrent_handle.pause()
self.session.remove_torrent(torrent_handle)
self.session.abort()
self.torrent_handles = {}
self.downloading_game_id = -1
try:
for game_id in self.torrent_handles:
torrent_handle = self.torrent_handles[game_id]
torrent_handle.pause()
self.session.remove_torrent(torrent_handle)
self.session.abort()
self.torrent_handles = {}
self.downloading_game_id = -1
except Exception as e:
raise RuntimeError(f"Failed to abort torrent session: {e}")
def get_download_status(self):
if self.downloading_game_id == -1:
return None
torrent_handle = self.torrent_handles.get(self.downloading_game_id)
try:
torrent_handle = self.torrent_handles.get(self.downloading_game_id)
if not torrent_handle:
raise KeyError(f"Torrent handle not found for game {self.downloading_game_id}")
status = torrent_handle.status()
info = torrent_handle.get_torrent_info()
status = torrent_handle.status()
info = torrent_handle.get_torrent_info()
return {
'folderName': info.name() if info else "",
'fileSize': info.total_size() if info else 0,
'gameId': self.downloading_game_id,
'progress': status.progress,
'downloadSpeed': status.download_rate,
'numPeers': status.num_peers,
'numSeeds': status.num_seeds,
'status': status.state,
'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download,
}
return {
'folderName': info.name() if info else "",
'fileSize': info.total_size() if info else 0,
'gameId': self.downloading_game_id,
'progress': status.progress,
'downloadSpeed': status.download_rate,
'numPeers': status.num_peers,
'numSeeds': status.num_seeds,
'status': status.state,
'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download,
}
except Exception as e:
raise RuntimeError(f"Failed to get download status for game {self.downloading_game_id}: {e}")

View File

@@ -3638,6 +3638,11 @@ detect-node@^2.0.4:
resolved "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz"
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
dexie@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/dexie/-/dexie-4.0.8.tgz#21fca70686bdaa1d86fad45b6b19316f6a084a1d"
integrity sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"