Compare commits

..

148 Commits

Author SHA1 Message Date
Chubby Granny Chaser
6fce60f9f7 ci: increasing version 2024-07-05 17:10:04 +01:00
Chubby Granny Chaser
c8aa9fd681 Merge branch 'main' of github.com:hydralauncher/hydra 2024-07-05 17:09:43 +01:00
Chubby Granny Chaser
0f12dfae88 ci: increasing version 2024-07-05 17:08:27 +01:00
Zamitto
be48306ca2 Merge pull request #768 from hydralauncher/hyd-229-improve-visibility-of-update-message
feat: add color to update icon and notify when update is ready to install
2024-07-05 12:45:53 -03:00
Zamitto
ab81e21341 feat: update font size 2024-07-05 12:38:53 -03:00
Zamitto
b7f94102da feat: add version string to notification 2024-07-05 12:30:34 -03:00
Zamitto
9e7b27afe6 feat: undo change 2024-07-05 12:22:13 -03:00
Zamitto
c24523e8e6 feat: update i18n 2024-07-05 12:18:37 -03:00
Zamitto
b58330ed35 feat: undo change 2024-07-05 12:13:47 -03:00
Zamitto
dde40f39e9 Merge branch 'main' into hyd-229-improve-visibility-of-update-message 2024-07-05 12:10:30 -03:00
Zamitto
d2b3017de9 feat: show notification only when update is ready to install 2024-07-05 12:10:19 -03:00
Chubby Granny Chaser
64f4dad7cc Merge pull request #783 from hydralauncher/fix/replacing-underscore-with-whitespace
feat: replacing underscore with whitespace
2024-07-05 16:04:49 +01:00
Chubby Granny Chaser
154d211b21 Merge branch 'main' into fix/replacing-underscore-with-whitespace 2024-07-05 15:54:42 +01:00
Chubby Granny Chaser
7905ef6c10 feat: replacing underscore with whitespace 2024-07-05 15:53:32 +01:00
Zamitto
b09f2c055f feat: creating notification for update available 2024-07-04 20:00:20 -03:00
Chubby Granny Chaser
2c5b3b4ffa Merge pull request #778 from hydralauncher/feature/adding-directors-cut-filter
Feature/adding directors cut filter
2024-07-04 23:41:07 +01:00
Chubby Granny Chaser
fdefc0c165 feat: adding directors cut filter 2024-07-04 23:14:09 +01:00
Chubby Granny Chaser
47ca2535e3 feat: adding directors cut filter 2024-07-04 23:12:20 +01:00
Chubby Granny Chaser
f706836a43 feat: adding directors cut filter 2024-07-04 23:11:21 +01:00
Chubby Granny Chaser
d8158bb80e Merge branch 'main' of github.com:hydralauncher/hydra into fix/adding-sorting-to-repacks-modal 2024-07-04 18:36:15 +01:00
Chubby Granny Chaser
4e422bdf91 feat: migrating download source validation to worker thread 2024-07-04 18:35:47 +01:00
Zamitto
4be3db8007 feat: add error logs 2024-07-03 18:03:11 -03:00
Zamitto
29b64237ed feat: remove old vbs file 2024-07-03 17:57:03 -03:00
Zamitto
d481164bf3 feat: add color to update icon and notify 2024-07-03 17:44:04 -03:00
Zamitto
138f33e0c3 Merge pull request #752 from hydralauncher/hyd-228-investigate-why-users-are-being-logged-out-when-updating
feat: prevent api calls when user is not logged in or auth is not loaded yet
2024-07-03 17:05:23 -03:00
Chubby Granny Chaser
be3c78f584 Merge branch 'main' into hyd-228-investigate-why-users-are-being-logged-out-when-updating 2024-07-03 20:52:44 +01:00
Chubby Granny Chaser
be1d9825d3 Merge pull request #755 from hydralauncher/feature/aria2-for-http-downloads
Feature/aria2 for http downloads
2024-07-03 20:52:34 +01:00
Zamitto
981116f221 Merge branch 'main' into hyd-228-investigate-why-users-are-being-logged-out-when-updating
# Conflicts:
#	src/main/events/user-preferences/auto-launch.ts
2024-07-03 16:32:25 -03:00
Chubby Granny Chaser
26aad178ee Merge branch 'main' into feature/aria2-for-http-downloads 2024-07-03 20:30:52 +01:00
Zamitto
56c8349899 Merge pull request #767 from hydralauncher/hyd-226-investigate-if-its-possible-to-use-psutil-to-list-processes
remove UAC; replace ps-list with psutil
2024-07-03 16:30:01 -03:00
Chubby Granny Chaser
0b2c407770 Merge branch 'main' into feature/aria2-for-http-downloads 2024-07-03 20:26:28 +01:00
Zamitto
d2e3d48ef8 Merge branch 'main' into hyd-226-investigate-if-its-possible-to-use-psutil-to-list-processes 2024-07-03 16:20:55 -03:00
Zamitto
153291f89f feat: apply suggestions 2024-07-03 16:10:23 -03:00
Zamitto
ae3daa4c79 fix: remove symbols from name before creating game shortcut 2024-07-03 15:52:25 -03:00
Zamitto
1397e3932d feat: remove pslist and use sudo-prompt to close game if needed 2024-07-03 15:31:56 -03:00
Zamitto
0f5db4f34e feat: crete kill-torrent 2024-07-03 12:06:26 -03:00
Zamitto
75c8f69e81 feat: get process list from rpc 2024-07-03 11:25:32 -03:00
Zamitto
aa253466a3 feat: refactor 2024-07-02 23:31:07 -03:00
Zamitto
b8bd786c45 feat: refactor 2024-07-02 15:42:23 -03:00
Zamitto
c9c585f820 Merge branch 'main' into hyd-228-investigate-why-users-are-being-logged-out-when-updating 2024-07-02 14:56:21 -03:00
Zamitto
9e11d6c098 feat: refactor hydra api 2024-07-02 14:56:01 -03:00
Zamitto
2f83c2c9da Merge pull request #739 from hydralauncher/fix/not-updating-i18n-in-main-process
fix: update i18n in updateUserPreferences and in hydra startup
2024-07-02 14:55:46 -03:00
Chubby Granny Chaser
dc94a886e6 fix: sorting repacks modal 2024-07-02 17:34:46 +01:00
Chubby Granny Chaser
7deabc4889 Merge branch 'main' into feature/aria2-for-http-downloads 2024-07-02 17:25:38 +01:00
Zamitto
e57200d024 Merge branch 'main' into fix/not-updating-i18n-in-main-process 2024-07-02 13:18:00 -03:00
Chubby Granny Chaser
7a13739d49 Merge pull request #747 from Carvalho286/main
Update translation.json
2024-07-02 17:16:51 +01:00
Chubby Granny Chaser
f8cbbc64f0 Merge branch 'feature/aria2-for-http-downloads' of github.com:hydralauncher/hydra into feature/aria2-for-http-downloads 2024-07-02 17:10:31 +01:00
Chubby Granny Chaser
9096eb5e0e fix: removing aria2 source folder 2024-07-02 17:10:04 +01:00
Chubby Granny Chaser
eebb5fec61 fix: removing aria2 source folder 2024-07-02 17:07:47 +01:00
Chubby Granny Chaser
88cfd0d095 Merge branch 'main' into feature/aria2-for-http-downloads 2024-07-02 17:06:58 +01:00
Chubby Granny Chaser
a43768ce67 feat: supporting queue using aria2 2024-07-02 17:06:30 +01:00
Chubby Granny Chaser
16a8c28935 feat: disabling bittorrent on aria2 2024-07-02 15:50:17 +01:00
Chubby Granny Chaser
1cc5a5b209 fix: adding real debrid real time tracking 2024-07-02 15:38:36 +01:00
Chubby Granny Chaser
a39082d326 feat: using aria2 for http downloads 2024-07-02 15:33:26 +01:00
Zamitto
0c1a75eedd poc: psutil for process watcher 2024-07-01 20:23:39 -03:00
Zamitto
dd23358a95 feat: prevent api calls when user is not logged in 2024-07-01 15:48:52 -03:00
Zamitto
8f00254dc2 Merge branch 'main' into fix/not-updating-i18n-in-main-process 2024-07-01 11:25:51 -03:00
Miguel Carvalho
449b34d3dd Update translation.json 2024-06-30 21:26:31 +01:00
Zamitto
9870213fff Merge pull request #732 from Lianela/main
Spanish translation little updates
2024-06-29 15:37:12 -03:00
Zamitto
de237b7c39 Merge branch 'main' into main 2024-06-29 14:47:18 -03:00
Zamitto
8a5d4e38b6 fix: update i18n in updateUserPreferences and in hydra startup 2024-06-29 14:34:30 -03:00
Zamitto
77152a32ab Merge pull request #736 from zxcsixx/patch-1
Update RU translation.json
2024-06-29 13:24:31 -03:00
Roman
c57c8dc477 Update RU translation.json
Fixed some mistakes
2024-06-29 17:39:05 +03:00
Lianela
455d80da3e Changed some strings
To make more friendly or better to understand some things, I change a few words
2024-06-29 01:10:56 -06:00
Lianela
d61c535c6f Fixed little typo
Nothing interesting, just a fix...
2024-06-29 01:03:00 -06:00
Lianela
23308a7780 added translation for file verification message 2024-06-29 01:01:29 -06:00
Chubby Granny Chaser
05ec01178b Merge pull request #722 from hydralauncher/rc/2.0.2
Rc/2.0.2
2024-06-28 22:15:40 +01:00
Chubby Granny Chaser
84e279cc14 Merge branch 'main' into rc/2.0.2 2024-06-28 22:08:25 +01:00
Chubby Granny Chaser
8eca067aed ci: fix sentry variable 2024-06-28 22:01:42 +01:00
Chubby Granny Chaser
05e4934f9f ci: fix sentry variable 2024-06-28 21:47:27 +01:00
Chubby Granny Chaser
ec0439e41b ci: fix sentry variable 2024-06-28 21:25:03 +01:00
Chubby Granny Chaser
b61fd1e61a Merge pull request #720 from hydralauncher/ci/sentry
ci: adding sentry
2024-06-28 20:55:42 +01:00
Zamitto
6d4f47df38 Merge pull request #566 from Panetina/romanian
Translated to romanian
2024-06-28 16:52:15 -03:00
Zamitto
0eaf629d37 Merge pull request #671 from CMAULTOP/main
Fix ru language
2024-06-28 16:50:45 -03:00
Zamitto
c12f16f59e Merge pull request #718 from hydralauncher/feat/better-api-logs-and-handle-401
feat: better api logs and handle 401
2024-06-28 16:42:42 -03:00
Chubby Granny Chaser
ac27438a35 ci: adding sentry 2024-06-28 20:27:22 +01:00
Zamitto
d3787b4525 feat: remove unused strings 2024-06-28 15:46:22 -03:00
Zamitto
ec8a0f75ac Merge branch 'main' into romanian 2024-06-28 15:42:50 -03:00
Zamitto
7e85ac5b43 feat: rename vbs file 2024-06-28 15:35:12 -03:00
Zamitto
a4644e7501 Update translation.json 2024-06-28 15:20:38 -03:00
Zamitto
ed978af3ae feat: disable old windows auto launch 2024-06-28 13:16:33 -03:00
Zamitto
4bd2174bf3 feat: handling 401 status code 2024-06-28 12:24:12 -03:00
Zamitto
c27182c618 feat: navigate back if request fails for get user 2024-06-28 12:23:46 -03:00
Zamitto
1ceabb00be feat: better logs on api error 2024-06-28 11:29:23 -03:00
Chubby Granny Chaser
2a44313d84 Merge pull request #706 from hydralauncher/feature/libtorrent-reloaded-remake-remaster
Feature/libtorrent reloaded remake remaster
2024-06-28 15:21:45 +01:00
Zamitto
e0dca85825 Merge pull request #709 from hydralauncher/hyd-192-select-lnk-as-parse-target-executable
feat: make it possible to select shortcuts (.lnk) on game executable
2024-06-28 11:16:47 -03:00
Zamitto
ec8ccf7728 Merge pull request #710 from hydralauncher/fix/window-auto-launch-on-startup
fix: windows auto launch on startup
2024-06-28 11:16:32 -03:00
Zamitto
e88088cca4 feat: add new line and rename script file to hydralauncher 2024-06-28 09:55:17 -03:00
Chubby Granny Chaser
75b69f38fc chore: removing extra line on main.py 2024-06-28 13:43:57 +01:00
Chubby Granny Chaser
50a1ba1dea feat: adding file verification message 2024-06-28 13:40:59 +01:00
Chubby Granny Chaser
2229151795 feat: splitting downloader.py 2024-06-28 12:20:09 +01:00
Chubby Granny Chaser
041fce027e feat: splitting downloader.py 2024-06-28 12:08:33 +01:00
Chubby Granny Chaser
1d5004ecb4 Merge branch 'main' into main 2024-06-28 12:04:39 +01:00
Chubby Granny Chaser
363bcf16a4 feat: adding authorization to rpc 2024-06-28 12:03:01 +01:00
Zamitto
b1532a52c8 feat: add script to resources 2024-06-27 19:40:53 -03:00
Zamitto
a3f7d3c59e fix: send signout event when auth token is empty 2024-06-27 19:05:08 -03:00
Zamitto
f1fecb684b feat: dont show auto launch on portable version 2024-06-27 19:00:12 -03:00
Zamitto
9c99e56b70 fix: add script to auto launch hydra on startup 2024-06-27 18:50:18 -03:00
Zamitto
7be626b3dd feat: make it possible to select shortcuts (.lnk) 2024-06-27 18:10:02 -03:00
Chubby Granny Chaser
96e96cd8aa feat: adding real debrid downloads 2024-06-27 22:05:50 +01:00
Chubby Granny Chaser
13644c60e8 feat: adding real debrid downloads 2024-06-27 21:52:04 +01:00
Chubby Granny Chaser
a1e41ea464 feat: adding file verification message 2024-06-27 19:59:33 +01:00
Chubby Granny Chaser
41dc504660 feat: adding initial torrent as arg command 2024-06-27 19:26:04 +01:00
Chubby Granny Chaser
a0cc15b5d8 feat: increasing healthcheck duration 2024-06-27 18:52:53 +01:00
Chubby Granny Chaser
7cd121cb80 feat: adding healthcheck 2024-06-27 18:46:59 +01:00
Chubby Granny Chaser
ccaea88a88 Merge branch 'feature/libtorrent-reloaded-remake-remaster' of github.com:hydralauncher/hydra into feature/libtorrent-reloaded-remake-remaster 2024-06-27 18:12:10 +01:00
Chubby Granny Chaser
d90888c7ba Merge branch 'main' into feature/libtorrent-reloaded-remake-remaster 2024-06-27 18:11:31 +01:00
Chubby Granny Chaser
9f9ea6ee88 fix: removing python tick 2024-06-27 18:10:30 +01:00
Chubby Granny Chaser
c26315219e fix: keeping last status available on rpc 2024-06-27 17:38:20 +01:00
Chubby Granny Chaser
c1c06c2d20 Merge branch 'feature/libtorrent-reloaded-remake-remaster' of github.com:hydralauncher/hydra into feature/libtorrent-reloaded-remake-remaster 2024-06-27 17:19:54 +01:00
Chubby Granny Chaser
328b7cb137 feat: using rpc to communicate 2024-06-27 17:18:48 +01:00
Zamitto
82f72071f9 Merge pull request #707 from hydralauncher/i18n/kazach-translation
feat: add Kazakh translation
2024-06-27 12:25:32 -03:00
Zamitto
d9ed2403ed feat: add kazach translation 2024-06-27 11:30:23 -03:00
Chubby Granny Chaser
d447942f84 Merge branch 'main' into feature/libtorrent-reloaded-remake-remaster 2024-06-27 15:23:48 +01:00
Chubby Granny Chaser
05cfdefc84 fix: fixing postinstall script 2024-06-27 15:21:16 +01:00
Zamitto
e4020d5b6a Merge pull request #605 from Ecron/lang-ca
Added Catalan translation.
2024-06-27 11:13:03 -03:00
Zamitto
1a047547fc feat: remove outdated strings 2024-06-27 11:02:29 -03:00
Chubby Granny Chaser
47ab35421c feat: adding libtorrent again 2024-06-27 14:57:25 +01:00
Chubby Granny Chaser
e08aa9c299 feat: adding libtorrent again 2024-06-27 14:56:57 +01:00
Chubby Granny Chaser
e44049ff63 feat: adding libtorrent again 2024-06-27 14:55:50 +01:00
Zamitto
7aa02f9d64 Merge branch 'main' into lang-ca 2024-06-27 10:55:02 -03:00
Zamitto
3fe6ab469b Fix some comma problems 2024-06-27 10:54:44 -03:00
Chubby Granny Chaser
ccd1d18981 feat: adding libtorrent again 2024-06-27 14:54:02 +01:00
Chubby Granny Chaser
906e801036 feat: adding libtorrent again 2024-06-27 14:52:53 +01:00
Chubby Granny Chaser
63c13e17cb feat: adding libtorrent again 2024-06-27 14:51:13 +01:00
Ecron
c1297530f6 Update translation.json
Added 2 new strings under header section.
2024-06-27 15:42:22 +02:00
Ecron
ac10e755b8 Update src/locales/ca/translation.json
Removed the splash section.
2024-06-27 15:41:10 +02:00
Ecron
1f17dda2f8 Update src/locales/ca/translation.json
Removed social networks.
2024-06-27 15:38:33 +02:00
Zamitto
94284a427f Merge pull request #677 from hydralauncher/fix/captcha-not-showing-on-linux
fix: set nodeIntegrationInSubFrames true on auth window
2024-06-25 10:04:10 -03:00
Павел
7fe8a6425b Update translation.json 2024-06-25 09:28:52 +03:00
Zamitto
2e1eb9e9b7 fix: set nodeIntegrationInSubFrames true on auth window 2024-06-24 23:36:55 -03:00
Zamitto
fe33045b9e Merge pull request #676 from hydralauncher/zamitto/hyd-22-define-the-cloudfront-url-on-the-csp
feat: add cloud front url to CSP
2024-06-24 21:16:57 -03:00
Zamitto
2020663ee5 feat: use wildcard on cloudfront url 2024-06-24 21:09:03 -03:00
Zamitto
2b51b82d03 feat: add cloud front to CSP 2024-06-24 21:02:13 -03:00
Павел
13b691aaad Update translation.json 2024-06-24 20:29:19 +03:00
Павел
e10f9f829c Update translation.json 2024-06-24 20:24:00 +03:00
Павел
936881e570 Update translation.json 2024-06-24 20:18:18 +03:00
Павел
0c826cb6f7 Update translation.json 2024-06-24 20:16:32 +03:00
Zamitto
2a27c37a25 Merge pull request #649 from 01M/patch-1
Update translation.json
2024-06-23 21:40:31 -03:00
Zamitto
3fd9776987 Merge branch 'main' into patch-1 2024-06-23 12:58:54 -03:00
01M
7a6d8ece63 Merge branch 'main' into patch-1 2024-06-23 17:55:20 +03:00
01M
2a3fda90b3 Update translation.json
Fixed translation
2024-06-23 17:25:54 +03:00
Ecron
4744d1ed52 Update index.ts expoting Catalan language file 2024-06-20 17:09:36 +02:00
Ecron
3b40413257 Added Catalan translation.
Added Catalan translation.
2024-06-18 16:46:13 +02:00
Zamitto
42eff5e906 Merge branch 'main' into romanian 2024-06-12 13:35:41 -03:00
Zamitto
d29f266ca1 Merge branch 'main' into romanian 2024-06-10 13:04:01 -03:00
Panetina
42864a4bea Merge branch 'main' into romanian 2024-06-05 12:03:48 +03:00
Panetina
d70b46d475 Translated to romanian
Translated everything to romanian.
Discord: panyel if there are any issues
2024-06-04 15:20:52 +03:00
90 changed files with 3928 additions and 1589 deletions

View File

@@ -1,3 +1,3 @@
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
MAIN_VITE_API_URL=API_URL
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN

View File

@@ -22,6 +22,17 @@ jobs:
- name: Install dependencies
run: yarn
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: pip install -r requirements.txt
- name: Build with cx_Freeze
run: python torrent-client/setup.py build
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: yarn build:linux
@@ -29,6 +40,8 @@ jobs:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Windows
@@ -38,6 +51,8 @@ jobs:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create artifact

View File

@@ -1,6 +1,6 @@
name: Lint
on: [pull_request, push]
on: pull_request
jobs:
lint:

View File

@@ -24,6 +24,17 @@ jobs:
- name: Install dependencies
run: yarn
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: pip install -r requirements.txt
- name: Build with cx_Freeze
run: python torrent-client/setup.py build
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: yarn build:linux
@@ -31,6 +42,8 @@ jobs:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Windows
@@ -40,6 +53,8 @@ jobs:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Release

2
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.vscode
node_modules
hydra-download-manager/
aria2/
fastlist.exe
__pycache__
@@ -9,3 +10,4 @@ out
*.log*
.env
.vite
sentry.properties

View File

@@ -83,7 +83,7 @@ Puedes unirte a nuestra conversación y discusiones en nuestro canal de [Telegra
### Haz un fork y clona tu repositorio
1. Rea;iza un fork del repositorio [(Haz click acá para hacer un fork ahora)](https://github.com/hydralauncher/hydra/fork)
1. Realiza un fork del repositorio [(Haz click acá para hacer un fork ahora)](https://github.com/hydralauncher/hydra/fork)
2. Clona el código forkeado `git clone https://github.com/tu_nombredeusuario/hydra`
3. Crea una nueva rama
4. Sube tus commits

View File

@@ -4,9 +4,8 @@ directories:
buildResources: build
extraResources:
- aria2
- hydra-download-manager
- seeds
- from: node_modules/ps-list/vendor/fastlist-0.3.0-x64.exe
to: fastlist.exe
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
files:
- "!**/.vscode/*"
@@ -19,7 +18,6 @@ asarUnpack:
- resources/**
win:
executableName: Hydra
requestedExecutionLevel: requireAdministrator
target:
- nsis
- portable
@@ -32,7 +30,6 @@ nsis:
allowToChangeInstallationDirectory: true
portable:
artifactName: ${name}-${version}-portable.${ext}
requestExecutionLevel: admin
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:

View File

@@ -6,9 +6,16 @@ import {
externalizeDepsPlugin,
} from "electron-vite";
import react from "@vitejs/plugin-react";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import svgr from "vite-plugin-svgr";
const sentryPlugin = sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: "hydra-launcher",
project: "hydra-launcher",
});
export default defineConfig(({ mode }) => {
loadEnv(mode);
@@ -28,7 +35,7 @@ export default defineConfig(({ mode }) => {
"@shared": resolve("src/shared"),
},
},
plugins: [externalizeDepsPlugin(), swcPlugin()],
plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin],
},
preload: {
plugins: [externalizeDepsPlugin()],
@@ -44,7 +51,7 @@ export default defineConfig(({ mode }) => {
"@shared": resolve("src/shared"),
},
},
plugins: [svgr(), react(), vanillaExtractPlugin()],
plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin],
},
};
});

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "2.0.1",
"version": "2.0.3",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -38,6 +38,7 @@
"@fontsource/fira-sans": "^5.0.20",
"@primer/octicons-react": "^19.9.0",
"@reduxjs/toolkit": "^2.2.3",
"@sentry/electron": "^5.1.0",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/recipes": "^0.5.2",
"aria2": "^4.1.2",
@@ -65,11 +66,11 @@
"lottie-react": "^2.4.0",
"parse-torrent": "^11.0.16",
"piscina": "^4.5.1",
"ps-list": "^8.1.1",
"react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1",
"react-router-dom": "^6.22.3",
"sudo-prompt": "^9.2.1",
"typeorm": "^0.3.20",
"user-agents": "^1.1.193",
"yaml": "^2.4.1",
@@ -81,6 +82,7 @@
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^1.0.1",
"@electron-toolkit/tsconfig": "^1.0.1",
"@sentry/vite-plugin": "^2.20.1",
"@swc/core": "^1.4.16",
"@types/auto-launch": "^5.0.5",
"@types/color": "^3.0.6",

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
libtorrent
cx_Freeze
cx_Logging; sys_platform == 'win32'
lief; sys_platform == 'win32'
pywin32; sys_platform == 'win32'
psutil

View File

@@ -0,0 +1,148 @@
{
"home": {
"featured": "Destacats",
"trending": "Populars",
"surprise_me": "Sorprèn-me",
"no_results": "No s'ha trobat res"
},
"sidebar": {
"catalogue": "Catàleg",
"downloads": "Baixades",
"settings": "Configuració",
"my_library": "Biblioteca",
"downloading_metadata": "{{title}} (S'estan baixant les metadades…)",
"paused": "{{title}} (Pausat)",
"downloading": "{{title}} ({{percentage}} - S'està baixant…)",
"filter": "Filtra la biblioteca",
"home": "Inici"
},
"header": {
"search": "Cerca jocs",
"home": "Inici",
"catalogue": "Catàleg",
"downloads": "Baixades",
"search_results": "Resultats de la cerca",
"settings": "Configuració",
"version_available_install": "Hi ha disponible la versió {{version}}. Feu clic aquí per a reiniciar i instal·lar-la.",
"version_available_download": "Hi ha disponible la versió {{version}}. Feu clic aquí per a baixar-la."
},
"bottom_panel": {
"no_downloads_in_progress": "Cap baixada en curs",
"downloading_metadata": "S'estan baixant les metadades de: {{title}}…",
"downloading": "S'està baixant: {{title}}… ({{percentage}} complet) - Finalització: {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Pàgina següent",
"previous_page": "Pàgina anterior"
},
"game_details": {
"open_download_options": "Obre les opcions de baixada",
"download_options_zero": "No hi ha opcions de baixada",
"download_options_one": "{{count}} opció de baixada",
"download_options_other": "{{count}} opcions de baixada",
"updated_at": "Actualitzat: {{updated_at}}",
"install": "Instal·la",
"resume": "Reprèn",
"pause": "Pausa",
"cancel": "Cancel·la",
"remove": "Elimina",
"space_left_on_disk": "{{space}} lliures al disc",
"eta": "Finalització: {{eta}}",
"downloading_metadata": "S'estan baixant les metadades…",
"filter": "Filtra els reempaquetats",
"requirements": "Requisits del sistema",
"minimum": "Mínims",
"recommended": "Recomanats",
"release_date": "Publicat el {{date}}",
"publisher": "Publicat per {{publisher}}",
"hours": "hores",
"minutes": "minuts",
"amount_hours": "{{amount}} hores",
"amount_minutes": "{{amount}} minuts",
"accuracy": "{{accuracy}}% de precisió",
"add_to_library": "Afegeix a la biblioteca",
"remove_from_library": "Elimina de la biblioteca",
"no_downloads": "No hi ha baixades disponibles",
"play_time": "Jugat durant {{amount}}",
"last_time_played": "Última partida: {{period}}",
"not_played_yet": "Encara no has jugat al {{title}}",
"next_suggestion": "Suggeriment següent",
"play": "Inicia",
"deleting": "S'està eliminant l'instal·lador…",
"close": "Tanca",
"playing_now": "S'està jugant",
"change": "Canvia",
"repacks_modal_description": "Tria quin reempaquetat vols baixar",
"select_folder_hint": "Per a canviar la carpeta predefinida, vés a la <0>Configuració</0>",
"download_now": "Baixa ara",
"no_shop_details": "No s'han pogut recuperar els detalls de la tenda.",
"download_options": "Opcions de baixada",
"download_path": "Ruta de baixada",
"previous_screenshot": "Captura anterior",
"next_screenshot": "Captura següent",
"screenshot": "Captura {{number}}",
"open_screenshot": "Obre la captura {{number}}"
},
"activation": {
"title": "Activa l'Hydra",
"installation_id": "ID d'instal·lació:",
"enter_activation_code": "Introdueix el codi d'activació",
"message": "Si no saps on demanar-ho, no ho hauries de tenir.",
"activate": "Activa",
"loading": "S'està carregant…"
},
"downloads": {
"resume": "Reprèn",
"pause": "Pausa",
"eta": "Finalització {{eta}}",
"paused": "Pausada",
"verifying": "S'està verificant…",
"completed": "Completada",
"cancel": "Cancel·la",
"filter": "Filtra els jocs baixats",
"remove": "Elimina",
"downloading_metadata": "S'estan baixant les metadades…",
"deleting": "S'està eliminant l'instal·lador…",
"delete": "Elimina l'instal·lador",
"delete_modal_title": "N'estàs segur?",
"delete_modal_description": "S'eliminaran de l'ordinador tots els fitxers d'instal·lació",
"install": "Instal·la"
},
"settings": {
"downloads_path": "Ruta de baixades",
"change": "Actualitza",
"notifications": "Notificacions",
"enable_download_notifications": "Quan finalitzi una baixada",
"enable_repack_list_notifications": "Quan s'afegeixi un nou reempaquetat",
"real_debrid_api_token_label": "Testimoni de l'API de Real Debrid",
"quit_app_instead_hiding": "Tanca l'Hydra en compte de minimitzar-la a la safata",
"launch_with_system": "Inicia l'Hydra quan s'iniciï el sistema",
"general": "General",
"behavior": "Comportament",
"enable_real_debrid": "Activa el Real Debrid",
"real_debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí</0>.",
"save_changes": "Desa els canvis"
},
"notifications": {
"download_complete": "La baixada ha finalitzat",
"game_ready_to_install": "{{title}} ja es pot instal·lar",
"repack_list_updated": "S'ha actualitzat la llista de reempaquetats",
"repack_count_one": "S'ha afegit {{count}} reempaquetat",
"repack_count_other": "S'han afegit {{count}} reempaquetats"
},
"system_tray": {
"open": "Obre l'Hydra",
"quit": "Tanca"
},
"game_card": {
"no_downloads": "No hi ha baixades disponibles"
},
"binary_not_found_modal": {
"title": "Programes no instal·lats",
"description": "No s'ha trobat els executables del Wine o el Lutris al sistema.",
"instructions": "Comprova quina és la manera correcta d'instal·lar qualsevol d'ells en la teva distribució de Linux perquè el joc pugui executar-se amb normalitat."
},
"modal": {
"close": "Botó de tancar"
}
}

View File

@@ -36,7 +36,8 @@
"no_downloads_in_progress": "No downloads in progress",
"downloading_metadata": "Downloading {{title}} metadata…",
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Conclusion {{eta}} - {{speed}}",
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…"
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
"checking_files": "Checking {{title}} files… ({{percentage}} complete)"
},
"catalogue": {
"next_page": "Next page",
@@ -144,7 +145,8 @@
"downloads_completed": "Completed",
"queued": "Queued",
"no_downloads_title": "Such empty",
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start."
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start.",
"checking_files": "Checking files…"
},
"settings": {
"downloads_path": "Downloads path",
@@ -197,7 +199,9 @@
"game_ready_to_install": "{{title}} is ready to install",
"repack_list_updated": "Repack list updated",
"repack_count_one": "{{count}} repack added",
"repack_count_other": "{{count}} repacks added"
"repack_count_other": "{{count}} repacks added",
"new_update_available": "Version {{version}} available",
"restart_to_install_update": "Restart Hydra to install the update"
},
"system_tray": {
"open": "Open Hydra",

View File

@@ -36,7 +36,8 @@
"no_downloads_in_progress": "Sin descargas en progreso",
"downloading_metadata": "Descargando metadatos de {{title}}…",
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}",
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…"
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…",
"checking_files": "Verificando archivos de {{title}}… ({{percentage}} completado)"
},
"catalogue": {
"next_page": "Siguiente página",
@@ -92,7 +93,7 @@
"screenshot": "Captura {{number}}",
"open_screenshot": "Abrir captura {{number}}",
"download_settings": "Ajustes de descarga",
"downloader": "Descargador",
"downloader": "Método de descarga",
"select_executable": "Seleccionar",
"no_executable_selected": "No se seleccionó un ejecutable",
"open_folder": "Abrir carpeta",
@@ -144,7 +145,8 @@
"downloads_completed": "Completado",
"queued": "En cola",
"no_downloads_title": "Esto está tan... vacío",
"no_downloads_description": "No has descargado nada con Hydra... aún, ¡pero nunca es tarde para comenzar!."
"no_downloads_description": "No has descargado nada con Hydra... aún, ¡pero nunca es tarde para comenzar!.",
"checking_files": "Verificando archivos…"
},
"settings": {
"downloads_path": "Ruta de descarga",
@@ -161,7 +163,7 @@
"language": "Idioma",
"real_debrid_api_token": "Token API",
"enable_real_debrid": "Activar Real-Debrid",
"real_debrid_description": "Real-Debrid es un descargador sin restricciones que te permite descargar archivos instantáneamente con la máxima velocidad de tu internet.",
"real_debrid_description": "Real-Debrid es una forma de descargar sin restricciones archivos instantáneamente con la máxima velocidad de tu internet.",
"real_debrid_invalid_token": "Token de API inválido",
"real_debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>",
"real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratuita. Por favor, suscríbete a Real-Debrid",
@@ -197,7 +199,8 @@
"game_ready_to_install": "{{title}} está listo para instalarse",
"repack_list_updated": "Lista de repacks actualizadas",
"repack_count_one": "{{count}} repack ha sido añadido",
"repack_count_other": "{{count}} repacks añadidos"
"repack_count_other": "{{count}} repacks añadidos",
"new_update_available": "Version {{version}} disponible"
},
"system_tray": {
"open": "Abrir Hydra",

View File

@@ -16,3 +16,6 @@ export { default as ko } from "./ko/translation.json";
export { default as da } from "./da/translation.json";
export { default as ar } from "./ar/translation.json";
export { default as fa } from "./fa/translation.json";
export { default as ro } from "./ro/translation.json";
export { default as ca } from "./ca/translation.json";
export { default as kk } from "./kk/translation.json";

View File

@@ -0,0 +1,242 @@
{
"app": {
"successfully_signed_in": "Сәтті кіру"
},
"home": {
"featured": "Ұсынылған",
"trending": "Трендте",
"surprise_me": "Таңқалдыр",
"no_results": "Ештеңе табылмады"
},
"sidebar": {
"catalogue": "Каталог",
"downloads": "Жүктеулер",
"settings": "Параметрлер",
"my_library": "Кітапхана",
"downloading_metadata": "{{title}} (Метадеректерді жүктеу…)",
"paused": "{{title}} (Тоқтатылды)",
"downloading": "{{title}} ({{percentage}} - Жүктеу…)",
"filter": "Кітапхана фильтрі",
"home": "Басты бет",
"queued": "{{title}} (Кезекте)",
"game_has_no_executable": "Ойынды іске қосу файлы таңдалмаған",
"sign_in": "Кіру"
},
"header": {
"search": "Іздеу",
"home": "Басты бет",
"catalogue": "Каталог",
"downloads": "Жүктеулер",
"search_results": "Іздеу нәтижелері",
"settings": "Параметрлер",
"version_available_install": "Қол жетімді нұсқа {{version}}. Қайта іске қосу және орнату үшін мұнда басыңыз.",
"version_available_download": "Қол жетімді нұсқа {{version}}. Жүктеу үшін мұнда басыңыз."
},
"bottom_panel": {
"no_downloads_in_progress": "Белсенді жүктеулер жоқ",
"downloading_metadata": "Метадеректерді жүктеу {{title}}…",
"downloading": "Жүктеу {{title}}… ({{percentage}} аяқталды) - Аяқтау {{eta}} - {{speed}}",
"calculating_eta": "Жүктеу {{title}}… ({{percentage}} аяқталды) - Қалған уақытты есептеу…"
},
"catalogue": {
"next_page": "Келесі бет",
"previous_page": "Алдыңғы бет"
},
"game_details": {
"open_download_options": "Жүктеу нұсқаларын ашу",
"download_options_zero": "Жүктеу нұсқалары жоқ",
"download_options_one": "{{count}} жүктеу нұсқасы",
"download_options_other": "{{count}} жүктеу нұсқалары",
"updated_at": "Жаңартылды {{updated_at}}",
"install": "Орнату",
"resume": "Жандандыру",
"pause": "Тоқтату",
"cancel": "Болдырмау",
"remove": "Жою",
"space_left_on_disk": "{{space}} бос орын",
"eta": "Аяқтау {{eta}}",
"calculating_eta": "Қалған уақытты есептеу…",
"downloading_metadata": "Метадеректерді жүктеу…",
"filter": "Репактар фильтрі",
"requirements": "Жүйелік талаптар",
"minimum": "Минималды",
"recommended": "Ұсынылған",
"paused": "Тоқтатылды",
"release_date": "Шыққан күні {{date}}",
"publisher": "Баспагер {{publisher}}",
"hours": "сағат",
"minutes": "минут",
"amount_hours": "{{amount}} сағат",
"amount_minutes": "{{amount}} минут",
"accuracy": "дәлдік {{accuracy}}%",
"add_to_library": "Кітапханаға қосу",
"remove_from_library": "Кітапханадан жою",
"no_downloads": "Жүктеулер жоқ",
"play_time": "Ойнау уақыты {{amount}}",
"last_time_played": "Соңғы ойнаған уақыт {{period}}",
"not_played_yet": "Сіз {{title}} ойнамағансыз",
"next_suggestion": "Келесі ұсыныс",
"play": "Ойнау",
"deleting": "Орнатушыны жою…",
"close": "Жабу",
"playing_now": "Қазір ойнап жатыр",
"change": "Өзгерту",
"repacks_modal_description": "Жүктеу үшін репакты таңдаңыз",
"select_folder_hint": "Әдепкі жүктеу қалтасын өзгерту үшін <0>Параметрлер</0> ашыңыз",
"download_now": "Қазір жүктеу",
"no_shop_details": "Сипаттаманы алу мүмкін болмады",
"download_options": "Жүктеу нұсқалары",
"download_path": "Жүктеу жолы",
"previous_screenshot": "Алдыңғы скриншот",
"next_screenshot": "Келесі скриншот",
"screenshot": "Скриншот {{number}}",
"open_screenshot": "Скриншотты ашу {{number}}",
"download_settings": "Жүктеу параметрлері",
"downloader": "Жүктегіш",
"select_executable": "Таңдау",
"no_executable_selected": "Файл таңдалмаған",
"open_folder": "Қалтаны ашу",
"open_download_location": "Жүктеу қалтасын қарау",
"create_shortcut": "Жұмыс үстелінде жарлық жасау",
"remove_files": "Файлдарды жою",
"remove_from_library_title": "Сіз сенімдісіз бе?",
"remove_from_library_description": "{{game}} сіздің кітапханаңыздан жойылады.",
"options": "Параметрлер",
"executable_section_title": "Файл",
"executable_section_description": "\"Ойнау\" батырмасын басқанда іске қосылатын файл жолы",
"downloads_secion_title": "Жүктеулер",
"downloads_section_description": "Ойынның жаңартулары немесе басқа нұсқалары бар-жоғын тексеру",
"danger_zone_section_title": "Қауіпті аймақ",
"danger_zone_section_description": "Осы ойынды кітапханаңыздан жою немесе Hydra жүктеген файлдарды жою",
"download_in_progress": "Жүктеу жүріп жатыр",
"download_paused": "Жүктеу тоқтатылды",
"last_downloaded_option": "Соңғы жүктеу нұсқасы",
"create_shortcut_success": "Жарлық жасалды",
"create_shortcut_error": "Жарлық жасау мүмкін болмады"
},
"activation": {
"title": "Hydra-ны белсендіру",
"installation_id": "Орнату ID:",
"enter_activation_code": "Активтендіру кодын енгізіңіз",
"message": "Егер оның қайдан алуға болатынын білмесеңіз, сізде оның болмауы керек.",
"activate": "Белсендіру",
"loading": "Жүктеу…"
},
"downloads": {
"resume": "Жандандыру",
"pause": "Тоқтату",
"eta": "Аяқтау {{eta}}",
"paused": "Тоқтатылды",
"verifying": "Тексеру…",
"completed": "Аяқталды",
"removed": "Жүктелмеген",
"cancel": "Болдырмау",
"filter": "Жүктелген ойындар фильтрі",
"remove": "Жою",
"downloading_metadata": "Метадеректерді жүктеу…",
"deleting": "Орнатушыны жою…",
"delete": "Орнатушыны жою",
"delete_modal_title": "Сіз сенімдісіз бе?",
"delete_modal_description": "Бұл барлық орнатушыларды компьютеріңізден жояды",
"install": "Орнату",
"download_in_progress": "Жүктеу жүріп жатыр",
"queued_downloads": "Кезектегі жүктеулер",
"downloads_completed": "Аяқталды",
"queued": "Кезекте",
"no_downloads_title": "Мұнда бос...",
"no_downloads_description": "Сіз Hydra арқылы әлі ештеңе жүктемегенсіз, бірақ бастау ешқашан кеш емес."
},
"settings": {
"downloads_path": "Жүктеу жолы",
"change": "Өзгерту",
"notifications": "Хабарламалар",
"enable_download_notifications": "Жүктеу аяқталғанда",
"enable_repack_list_notifications": "Жаңа репак қосылғанда",
"real_debrid_api_token_label": "Real-Debrid API-токен",
"quit_app_instead_hiding": "Hydra-ны трейге жасырудың орнына жабу",
"launch_with_system": "Жүйемен бірге Hydra-ны іске қосу",
"general": "Жалпы",
"behavior": "Мінез-құлық",
"download_sources": "Жүктеу көздері",
"language": "Тіл",
"real_debrid_api_token": "API Кілті",
"enable_real_debrid": "Real-Debrid-ті қосу",
"real_debrid_description": "Real-Debrid - бұл шектеусіз жүктеуші, ол интернетте орналастырылған файлдарды тез жүктеуге немесе жеке желі арқылы кез келген блоктарды айналып өтіп, оларды бірден плеерге беруге мүмкіндік береді.",
"real_debrid_invalid_token": "Қате API кілті",
"real_debrid_api_token_hint": "API кілтін <0>осы жерден</0> алуға болады",
"real_debrid_free_account_error": "\"{{username}}\" аккаунты жазылымға ие емес. Real-Debrid жазылымын алыңыз",
"real_debrid_linked_message": "\"{{username}}\" аккаунты байланған",
"save_changes": "Өзгерістерді сақтау",
"changes_saved": "Өзгерістер сәтті сақталды",
"download_sources_description": "Hydra осы көздерден жүктеу сілтемелерін алады. URL-да жүктеу сілтемелері бар .json файлына тікелей сілтеме болуы керек.",
"validate_download_source": "Тексеру",
"remove_download_source": "Жою",
"add_download_source": "Жүктеу көзін қосу",
"download_count_zero": "Жүктеулер тізімінде жоқ",
"download_count_one": "{{countFormatted}} жүктеу тізімде",
"download_count_other": "{{countFormatted}} жүктеу тізімде",
"download_options_zero": "Қолжетімді жүктеулер жоқ",
"download_options_one": "{{countFormatted}} жүктеу нұсқасы қол жетімді",
"download_options_other": "{{countFormatted}} жүктеу нұсқалары қол жетімді",
"download_source_url": "Көздің сілтемесі",
"add_download_source_description": ".json файлға сілтемені қойыңыз",
"download_source_up_to_date": "Жаңартылған",
"download_source_errored": "Қате",
"sync_download_sources": "Көздерді синхрондау",
"removed_download_source": "Жүктеу көзі жойылды",
"added_download_source": "Жүктеу көзі қосылды",
"download_sources_synced": "Барлық жүктеу көздері синхрондалды",
"insert_valid_json_url": "Жарамды JSON URL енгізіңіз",
"found_download_option_zero": "Жүктеу нұсқалары табылмады",
"found_download_option_one": "{{countFormatted}} жүктеу нұсқасы табылды",
"found_download_option_other": "{{countFormatted}} жүктеу нұсқалары табылды",
"import": "Импорттау"
},
"notifications": {
"download_complete": "Жүктеу аяқталды",
"game_ready_to_install": "{{title}} орнатуға дайын",
"repack_list_updated": "Репактар тізімі жаңартылды",
"repack_count_one": "{{count}} репак қосылды",
"repack_count_other": "{{count}} репактар қосылды"
},
"system_tray": {
"open": "Hydra-ны ашу",
"quit": "Шығу"
},
"game_card": {
"no_downloads": "Жүктеулер жоқ"
},
"binary_not_found_modal": {
"title": "Бағдарламалар орнатылмаған",
"description": "Wine немесе Lutris табылмады",
"instructions": "Linux дистрибутивіңізге олардың кез келгенін дұрыс орнатудың жолын біліңіз осылайша ойын дұрыс жұмыс істей алады"
},
"modal": {
"close": "Жабу"
},
"forms": {
"toggle_password_visibility": "Құпиясөзді көрсету"
},
"user_profile": {
"amount_hours": "{{amount}} сағат",
"amount_minutes": "{{amount}} минут",
"last_time_played": "Соңғы ойын {{period}}",
"activity": "Соңғы әрекет",
"library": "Кітапхана",
"total_play_time": "Барлығы ойнаған: {{amount}}",
"no_recent_activity_title": "Хммм... Мұнда ештеңе жоқ",
"no_recent_activity_description": "Сіз ұзақ уақыт бойы ештеңе ойнаған жоқсыз. Мұны өзгерту керек!",
"display_name": "Көрсету аты",
"saving": "Сақтау",
"save": "Сақталды",
"edit_profile": "Профильді өзгерту",
"saved_successfully": "Сәтті сақталды",
"try_again": "Қайта көріңіз",
"sign_out_modal_title": "Сіз сенімдісіз бе?",
"cancel": "Болдырмау",
"successfully_signed_out": "Аккаунттан сәтті шығу",
"sign_out": "Шығу",
"playing_for": "Ойнаған {{amount}}",
"sign_out_modal_text": "Сіздің кітапханаңыз ағымдағы аккаунтпен байланысты. Жүйеден шыққанда сіздің кітапханаңыз қол жетімсіз болады және прогресс сақталмайды. Шығу?"
}
}

View File

@@ -1,9 +1,9 @@
{
"app": {
"successfully_signed_in": "Logado com sucesso"
"successfully_signed_in": "Autenticado com sucesso"
},
"home": {
"featured": "Destaque",
"featured": "Destaques",
"trending": "Populares",
"surprise_me": "Surpreenda-me",
"no_results": "Nenhum resultado encontrado"
@@ -36,7 +36,8 @@
"no_downloads_in_progress": "Sem downloads em andamento",
"downloading_metadata": "Baixando metadados de {{title}}…",
"downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}",
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…"
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…",
"checking_files": "Verificando arquivos de {{title}}…"
},
"game_details": {
"open_download_options": "Ver opções de download",
@@ -140,7 +141,8 @@
"downloads_completed": "Completo",
"queued": "Na fila",
"no_downloads_title": "Nada por aqui…",
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar."
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar.",
"checking_files": "Verificando arquivos…"
},
"settings": {
"downloads_path": "Diretório dos downloads",
@@ -193,7 +195,9 @@
"game_ready_to_install": "{{title}} está pronto para ser instalado",
"repack_list_updated": "Lista de repacks atualizada",
"repack_count_one": "{{count}} novo repack",
"repack_count_other": "{{count}} novos repacks"
"repack_count_other": "{{count}} novos repacks",
"new_update_available": "Versão {{version}} disponível",
"restart_to_install_update": "Reinicie o Hydra para instalar a nova versão"
},
"system_tray": {
"open": "Abrir Hydra",

View File

@@ -0,0 +1,159 @@
{
"home": {
"featured": "Recomandate",
"trending": "Populare",
"surprise_me": "Surprinde-mă",
"no_results": "Niciun rezultat găsit"
},
"sidebar": {
"catalogue": "Catalog",
"downloads": "Descărcări",
"settings": "Setări",
"my_library": "Biblioteca mea",
"downloading_metadata": "{{title}} (Se descarcă metadata...)",
"paused": "{{title}} (Pauzat)",
"downloading": "{{title}} ({{percentage}} - Se descarcă...)",
"filter": "Filtrează biblioteca",
"home": "Acasă"
},
"header": {
"search": "Caută jocuri",
"home": "Acasă",
"catalogue": "Catalog",
"downloads": "Descărcări",
"search_results": "Rezultatele căutării",
"settings": "Setări"
},
"bottom_panel": {
"no_downloads_in_progress": "Nicio descărcare în curs",
"downloading_metadata": "Se descarcă metadata pentru {{title}}...",
"downloading": "Se descarcă {{title}}... ({{percentage}} complet) - Concluzie {{eta}} - {{speed}}",
"calculating_eta": "Se descarcă {{title}}... ({{percentage}} complet) - Calculare timp rămas..."
},
"catalogue": {
"next_page": "Pagina următoare",
"previous_page": "Pagina anterioară"
},
"game_details": {
"open_download_options": "Deschide opțiunile de descărcare",
"download_options_zero": "Nicio opțiune de descărcare",
"download_options_one": "{{count}} opțiune de descărcare",
"download_options_other": "{{count}} opțiuni de descărcare",
"updated_at": "Actualizat la {{updated_at}}",
"install": "Instalează",
"resume": "Reia",
"pause": "Pauză",
"cancel": "Anulează",
"remove": "Elimină",
"space_left_on_disk": "{{space}} liber pe disc",
"eta": "Concluzie {{eta}}",
"calculating_eta": "Calculare timp rămas...",
"downloading_metadata": "Se descarcă metadata...",
"filter": "Filtrează repack-urile",
"requirements": "Cerințe de sistem",
"minimum": "Minim",
"recommended": "Recomandat",
"paused": "Pauzat",
"release_date": "Lansat pe {{date}}",
"publisher": "Publicat de {{publisher}}",
"hours": "ore",
"minutes": "minute",
"amount_hours": "{{amount}} ore",
"amount_minutes": "{{amount}} minute",
"accuracy": "{{accuracy}}% acuratețe",
"add_to_library": "Adaugă în bibliotecă",
"remove_from_library": "Elimină din bibliotecă",
"no_downloads": "Nicio descărcare disponibilă",
"play_time": "Jucat timp de {{amount}}",
"last_time_played": "Ultima dată jucat {{period}}",
"not_played_yet": "Nu ai jucat încă {{title}}",
"next_suggestion": "Sugestia următoare",
"play": "Joacă",
"deleting": "Se șterge programul de instalare...",
"close": "Închide",
"playing_now": "Se joacă acum",
"change": "Schimbă",
"repacks_modal_description": "Alege repack-ul pe care vrei să-l descarci",
"select_folder_hint": "Pentru a schimba folderul predefinit, mergi la <0>Setări</0>",
"download_now": "Descarcă acum",
"no_shop_details": "Nu s-au putut obține detalii din magazin.",
"download_options": "Opțiuni de descărcare",
"download_path": "Locația de descărcare",
"previous_screenshot": "Captura de ecran anterioară",
"next_screenshot": "Captura de ecran următoare",
"screenshot": "Captură de ecran {{number}}",
"open_screenshot": "Deschide captura de ecran {{number}}",
"download_settings": "Setări de descărcare",
"downloader": "Program de descărcare"
},
"activation": {
"title": "Activează Hydra",
"installation_id": "ID-ul de instalare:",
"enter_activation_code": "Introdu codul de activare",
"message": "Dacă nu știi de unde să ceri acest lucru, atunci nu ar trebui să-l ai.",
"activate": "Activează",
"loading": "Se încarcă..."
},
"downloads": {
"resume": "Reia",
"pause": "Pauză",
"eta": "Concluzie {{eta}}",
"paused": "Pauzat",
"verifying": "Se verifică...",
"completed": "Completat",
"removed": "Nu este descărcat",
"cancel": "Anulează",
"filter": "Filtrează jocurile descărcate",
"remove": "Elimină",
"downloading_metadata": "Se descarcă metadata...",
"deleting": "Se șterge programul de instalare...",
"delete": "Elimină programul de instalare",
"delete_modal_title": "Ești sigur?",
"delete_modal_description": "Aceasta va elimina toate fișierele de instalare de pe computer",
"install": "Instalează"
},
"settings": {
"downloads_path": "Locația de descărcare",
"change": "Actualizează",
"notifications": "Notificări",
"enable_download_notifications": "Când o descărcare este completă",
"enable_repack_list_notifications": "Când un nou repack este adăugat",
"real_debrid_api_token_label": "Token API Real-Debrid",
"quit_app_instead_hiding": "Nu ascunde Hydra la închidere",
"launch_with_system": "Lansează Hydra la pornirea sistemului",
"general": "General",
"behavior": "Comportament",
"language": "Limbă",
"real_debrid_api_token": "Token API",
"enable_real_debrid": "Activează Real-Debrid",
"real_debrid_description": "Real-Debrid este un descărcător fără restricții care îți permite să descarci fișiere instantaneu și la cea mai bună viteză a internetului tău.",
"real_debrid_invalid_token": "Token API invalid",
"real_debrid_api_token_hint": "Poți obține token-ul tău API <0>aici</0>",
"real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid",
"real_debrid_linked_message": "Contul \"{{username}}\" a fost legat",
"save_changes": "Salvează modificările",
"changes_saved": "Modificările au fost salvate cu succes"
},
"notifications": {
"download_complete": "Descărcare completă",
"game_ready_to_install": "{{title}} este gata de instalare",
"repack_list_updated": "Lista de repack-uri a fost actualizată",
"repack_count_one": "{{count}} repack adăugat",
"repack_count_other": "{{count}} repack-uri adăugate"
},
"system_tray": {
"open": "Deschide Hydra",
"quit": "Ieși"
},
"game_card": {
"no_downloads": "Nicio descărcare disponibilă"
},
"binary_not_found_modal": {
"title": "Programele nu sunt instalate",
"description": "Fișierele executabile Wine sau Lutris nu au fost găsite pe sistemul tău",
"instructions": "Verifică modul corect de instalare a oricăruia dintre acestea pe distribuția ta Linux pentru ca jocul să ruleze normal"
},
"modal": {
"close": "Buton de închidere"
}
}

View File

@@ -153,11 +153,11 @@
"enable_download_notifications": "По завершении загрузки",
"enable_repack_list_notifications": "При добавлении нового репака",
"real_debrid_api_token_label": "Real-Debrid API-токен",
"quit_app_instead_hiding": "Закрывать Hydra вместо того, чтобы сворачивать его в трей",
"launch_with_system": "Запуск Hydra вместе с системой",
"quit_app_instead_hiding": "Закрывать приложение вместо сворачивания в трей",
"launch_with_system": "Запускать Hydra вместе с системой",
"general": "Основные",
"behavior": "Поведение",
"download_sources": "Скачать исходный код",
"download_sources": "Источники загрузки",
"language": "Язык",
"real_debrid_api_token": "API Ключ",
"enable_real_debrid": "Включить Real-Debrid",
@@ -197,7 +197,8 @@
"game_ready_to_install": "{{title}} готова к установке",
"repack_list_updated": "Список репаков обновлен",
"repack_count_one": "{{count}} репак добавлен",
"repack_count_other": "{{count}} репаков добавлено"
"repack_count_other": "{{count}} репаков добавлено",
"new_update_available": "Доступна версия {{version}}"
},
"system_tray": {
"open": "Открыть Hydra",
@@ -228,7 +229,7 @@
"no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!",
"display_name": "Отображаемое имя",
"saving": "Сохранение",
"save": "Сохранено",
"save": "Сохранить",
"edit_profile": "Редактировать Профиль",
"saved_successfully": "Успешно сохранено",
"try_again": "Пожалуйста, попробуйте ещё раз",

View File

@@ -9,9 +9,8 @@ import {
} from "typeorm";
import { Repack } from "./repack.entity";
import type { GameShop } from "@types";
import type { GameShop, GameStatus } from "@types";
import { Downloader } from "@shared";
import type { Aria2Status } from "aria2";
import type { DownloadQueue } from "./download-queue.entity";
@Entity("game")
@@ -47,7 +46,7 @@ export class Game {
shop: GameShop;
@Column("text", { nullable: true })
status: Aria2Status | null;
status: GameStatus | null;
@Column("int", { default: Downloader.Torrent })
downloader: Downloader;

View File

@@ -1,4 +1,5 @@
import jwt from "jsonwebtoken";
import * as Sentry from "@sentry/electron/main";
import { userAuthRepository } from "@main/repository";
import { registerEvent } from "../register-event";
@@ -8,6 +9,9 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
if (!auth) return null;
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
Sentry.setContext("sessionId", payload.sessionId);
return payload.sessionId;
};

View File

@@ -1,5 +1,6 @@
import { registerEvent } from "../register-event";
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
import * as Sentry from "@sentry/electron/main";
import { HydraApi, PythonInstance, gamesPlaytime } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, UserAuth } from "@main/entity";
@@ -19,12 +20,15 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
gamesPlaytime.clear();
});
/* Disconnects aria2 */
DownloadManager.disconnect();
/* Removes user from Sentry */
Sentry.setUser(null);
/* Disconnects libtorrent */
PythonInstance.killTorrent();
await Promise.all([
databaseOperations,
HydraApi.post("/auth/logout").catch(),
HydraApi.post("/auth/logout").catch(() => {}),
]);
};

View File

@@ -3,6 +3,7 @@ import { registerEvent } from "../register-event";
import updater, { UpdateInfo } from "electron-updater";
import { WindowManager } from "@main/services";
import { app } from "electron";
import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications";
const { autoUpdater } = updater;
@@ -20,13 +21,17 @@ const mockValuesForDebug = () => {
sendEvent({ type: "update-downloaded" });
};
const newVersionInfo = { version: "" };
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
autoUpdater
.once("update-available", (info: UpdateInfo) => {
sendEvent({ type: "update-available", info });
newVersionInfo.version = info.version;
})
.once("update-downloaded", () => {
sendEvent({ type: "update-downloaded" });
publishNotificationUpdateReadyToInstall(newVersionInfo.version);
});
if (app.isPackaged) {

View File

@@ -1,17 +1,12 @@
import { registerEvent } from "../register-event";
import axios from "axios";
import { downloadSourceRepository } from "@main/repository";
import { downloadSourceSchema } from "../helpers/validators";
import { RepacksManager } from "@main/services";
import { downloadSourceWorker } from "@main/workers";
const validateDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const response = await axios.get(url);
const source = downloadSourceSchema.parse(response.data);
const existingSource = await downloadSourceRepository.findOne({
where: { url },
});
@@ -21,14 +16,12 @@ const validateDownloadSource = async (
const repacks = RepacksManager.repacks;
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,
};
return downloadSourceWorker.run(
{ url, repacks },
{
name: "validateDownloadSource",
}
);
};
registerEvent("validateDownloadSource", validateDownloadSource);

View File

@@ -0,0 +1,10 @@
import { shell } from "electron";
export const parseExecutablePath = (path: string) => {
if (process.platform === "win32" && path.endsWith(".lnk")) {
const { target } = shell.readShortcutLink(path);
return target;
}
return path;
};

View File

@@ -22,7 +22,6 @@ import "./library/open-game-installer-path";
import "./library/update-executable-path";
import "./library/remove-game";
import "./library/remove-game-from-library";
import "./misc/is-user-logged-in";
import "./misc/open-external";
import "./misc/show-open-dialog";
import "./torrenting/cancel-game-download";
@@ -49,4 +48,8 @@ import "./profile/update-profile";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());
ipcMain.handle(
"isPortableVersion",
() => process.env.PORTABLE_EXECUTABLE_FILE != null
);
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);

View File

@@ -53,18 +53,7 @@ const addGameToLibrary = async (
const game = await gameRepository.findOne({ where: { objectID } });
createGame(game!).then((response) => {
const {
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
gameRepository.update(
{ objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
});
createGame(game!);
});
};

View File

@@ -1,39 +1,45 @@
import path from "node:path";
import { gameRepository } from "@main/repository";
import { getProcesses } from "@main/helpers";
import { registerEvent } from "../register-event";
import { PythonInstance, logger } from "@main/services";
import sudo from "sudo-prompt";
import { app } from "electron";
const getKillCommand = (pid: number) => {
if (process.platform == "win32") {
return `taskkill /PID ${pid}`;
}
return `kill -9 ${pid}`;
};
const closeGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const processes = await getProcesses();
const processes = await PythonInstance.getProcessList();
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game) return false;
const executablePath = game.executablePath!;
const basename = path.win32.basename(executablePath);
const basenameWithoutExtension = path.win32.basename(
executablePath,
path.extname(executablePath)
);
if (!game) return;
const gameProcess = processes.find((runningProcess) => {
if (process.platform === "win32") {
return runningProcess.name === basename;
}
return [basename, basenameWithoutExtension].includes(runningProcess.name);
return runningProcess.exe === game.executablePath;
});
if (gameProcess) return process.kill(gameProcess.pid);
return false;
if (gameProcess) {
try {
process.kill(gameProcess.pid);
} catch (err) {
sudo.exec(
getKillCommand(gameProcess.pid),
{ name: app.getName() },
(error, _stdout, _stderr) => {
logger.error(error);
}
);
}
}
};
registerEvent("closeGame", closeGame);

View File

@@ -4,6 +4,7 @@ import { IsNull, Not } from "typeorm";
import createDesktopShortcut from "create-desktop-shortcuts";
import path from "node:path";
import { app } from "electron";
import { removeSymbolsFromName } from "@shared";
const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent,
@@ -22,7 +23,7 @@ const createGameShortcut = async (
const options = {
filePath,
name: game.title,
name: removeSymbolsFromName(game.title),
};
return createDesktopShortcut({

View File

@@ -45,10 +45,6 @@ const deleteGameFolder = async (
reject();
}
const aria2ControlFilePath = `${folderPath}.aria2`;
if (fs.existsSync(aria2ControlFilePath))
fs.rmSync(aria2ControlFilePath);
resolve();
}
);

View File

@@ -2,15 +2,18 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { shell } from "electron";
import { parseExecutablePath } from "../helpers/parse-executable-path";
const openGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number,
executablePath: string
) => {
await gameRepository.update({ id: gameId }, { executablePath });
const parsedPath = parseExecutablePath(executablePath);
shell.openPath(executablePath);
await gameRepository.update({ id: gameId }, { executablePath: parsedPath });
shell.openPath(parsedPath);
};
registerEvent("openGame", openGame);

View File

@@ -20,7 +20,7 @@ const removeRemoveGameFromLibrary = async (gameId: number) => {
const game = await gameRepository.findOne({ where: { id: gameId } });
if (game?.remoteId) {
HydraApi.delete(`/games/${game.remoteId}`);
HydraApi.delete(`/games/${game.remoteId}`).catch(() => {});
}
};

View File

@@ -1,6 +1,7 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { parseExecutablePath } from "../helpers/parse-executable-path";
const updateExecutablePath = async (
_event: Electron.IpcMainInvokeEvent,
@@ -12,7 +13,7 @@ const updateExecutablePath = async (
id,
},
{
executablePath,
executablePath: parseExecutablePath(executablePath),
}
);
};

View File

@@ -1,8 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const isUserLoggedIn = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.isLoggedIn();
};
registerEvent("isUserLoggedIn", isUserLoggedIn);

View File

@@ -1,8 +1,9 @@
import { registerEvent } from "../register-event";
import * as Sentry from "@sentry/electron/main";
import { HydraApi } from "@main/services";
import { UserProfile } from "@types";
import { userAuthRepository } from "@main/repository";
import { logger } from "@main/services";
import { UserNotLoggedInError } from "@shared";
const getMe = async (
_event: Electron.IpcMainInvokeEvent
@@ -21,10 +22,15 @@ const getMe = async (
["id"]
);
Sentry.setUser({ id: me.id, username: me.username });
return me;
})
.catch((err) => {
logger.error("getMe", err);
if (err instanceof UserNotLoggedInError) {
return null;
}
return userAuthRepository.findOne({ where: { id: 1 } });
});
};

View File

@@ -26,9 +26,11 @@ const updateProfile = async (
_event: Electron.IpcMainInvokeEvent,
displayName: string,
newProfileImagePath: string | null
): Promise<UserProfile> => {
) => {
if (!newProfileImagePath) {
return (await patchUserProfile(displayName)).data;
return patchUserProfile(displayName).then(
(response) => response.data as UserProfile
);
}
const stats = fs.statSync(newProfileImagePath);
@@ -51,11 +53,11 @@ const updateProfile = async (
});
return profileImageUrl;
})
.catch(() => {
return undefined;
});
.catch(() => undefined);
return (await patchUserProfile(displayName, profileImageUrl)).data;
return patchUserProfile(displayName, profileImageUrl).then(
(response) => response.data as UserProfile
);
};
registerEvent("updateProfile", updateProfile);

View File

@@ -95,18 +95,7 @@ const startGameDownload = async (
},
});
createGame(updatedGame!).then((response) => {
const {
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
gameRepository.update(
{ objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
});
createGame(updatedGame!);
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });

View File

@@ -1,18 +1,41 @@
import { registerEvent } from "../register-event";
import AutoLaunch from "auto-launch";
import { app } from "electron";
import path from "path";
import fs from "node:fs";
import { logger } from "@main/services";
const windowsStartupPath = path.join(
app.getPath("appData"),
"Microsoft",
"Windows",
"Start Menu",
"Programs",
"Startup"
);
const autoLaunch = async (
_event: Electron.IpcMainInvokeEvent,
enabled: boolean
) => {
if (!app.isPackaged) return;
const appLauncher = new AutoLaunch({
name: app.getName(),
});
if (enabled) {
appLauncher.enable().catch();
appLauncher.enable().catch((err) => {
logger.error(err);
});
} else {
appLauncher.disable().catch();
if (process.platform == "win32") {
fs.rm(path.join(windowsStartupPath, "Hydra.vbs"), () => {});
}
appLauncher.disable().catch((err) => {
logger.error(err);
});
}
};

View File

@@ -2,17 +2,23 @@ import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types";
import i18next from "i18next";
const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent,
preferences: Partial<UserPreferences>
) =>
userPreferencesRepository.upsert(
) => {
if (preferences.language) {
i18next.changeLanguage(preferences.language);
}
return userPreferencesRepository.upsert(
{
id: 1,
...preferences,
},
["id"]
);
};
registerEvent("updateUserPreferences", updateUserPreferences);

View File

@@ -57,5 +57,4 @@ export const requestWebPage = async (url: string) => {
.then((response) => response.data);
};
export * from "./ps";
export * from "./download-source";

View File

@@ -1,33 +0,0 @@
import psList from "ps-list";
import path from "node:path";
import childProcess from "node:child_process";
import { promisify } from "node:util";
import { app } from "electron";
const TEN_MEGABYTES = 1000 * 1000 * 10;
const execFile = promisify(childProcess.execFile);
export const getProcesses = async () => {
if (process.platform == "win32") {
const binaryPath = app.isPackaged
? path.join(process.resourcesPath, "fastlist.exe")
: path.join(__dirname, "..", "..", "fastlist.exe");
const { stdout } = await execFile(binaryPath, {
maxBuffer: TEN_MEGABYTES,
windowsHide: true,
});
return stdout
.trim()
.split("\r\n")
.map((line) => line.split("\t"))
.map(([pid, ppid, name]) => ({
pid: Number.parseInt(pid, 10),
ppid: Number.parseInt(ppid, 10),
name,
}));
} else {
return psList();
}
};

View File

@@ -1,10 +1,11 @@
import { app, BrowserWindow, net, protocol } from "electron";
import { init } from "@sentry/electron/main";
import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import url from "node:url";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { DownloadManager, logger, WindowManager } from "@main/services";
import { logger, PythonInstance, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
import * as resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
@@ -22,6 +23,12 @@ autoUpdater.logger = logger;
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
if (import.meta.env.MAIN_VITE_SENTRY_DSN) {
init({
dsn: import.meta.env.MAIN_VITE_SENTRY_DSN,
});
}
app.commandLine.appendSwitch("--no-sandbox");
i18n.init({
@@ -65,6 +72,10 @@ app.whenReady().then(async () => {
where: { id: 1 },
});
if (userPreferences?.language) {
i18n.changeLanguage(userPreferences.language);
}
WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
});
@@ -108,7 +119,8 @@ app.on("window-all-closed", () => {
});
app.on("before-quit", () => {
DownloadManager.disconnect();
/* Disconnects libtorrent */
PythonInstance.kill();
});
app.on("activate", () => {

View File

@@ -1,4 +1,9 @@
import { DownloadManager, RepacksManager, startMainLoop } from "./services";
import {
DownloadManager,
RepacksManager,
PythonInstance,
startMainLoop,
} from "./services";
import {
downloadQueueRepository,
repackRepository,
@@ -12,18 +17,16 @@ import { MoreThan } from "typeorm";
import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
startMainLoop();
const loadState = async (userPreferences: UserPreferences | null) => {
await RepacksManager.updateRepacks();
RepacksManager.updateRepacks();
import("./events");
if (userPreferences?.realDebridApiToken)
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
HydraApi.setupApi().then(async () => {
if (HydraApi.isLoggedIn()) uploadGamesBatch();
HydraApi.setupApi().then(() => {
uploadGamesBatch();
});
const [nextQueueItem] = await downloadQueueRepository.find({
@@ -35,8 +38,13 @@ const loadState = async (userPreferences: UserPreferences | null) => {
},
});
if (nextQueueItem?.game.status === "active")
if (nextQueueItem?.game.status === "active") {
DownloadManager.startDownload(nextQueueItem.game);
} else {
PythonInstance.spawn();
}
startMainLoop();
const now = new Date();

View File

@@ -1,304 +0,0 @@
import Aria2, { StatusResponse } from "aria2";
import path from "node:path";
import { downloadQueueRepository, gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager";
import { RealDebridClient } from "./real-debrid";
import { Downloader } from "@shared";
import { DownloadProgress } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { Game } from "@main/entity";
import { startAria2 } from "./aria2c";
import { sleep } from "@main/helpers";
import { logger } from "./logger";
import type { ChildProcess } from "node:child_process";
import { publishDownloadCompleteNotification } from "./notifications";
export class DownloadManager {
private static downloads = new Map<number, string>();
private static connected = false;
private static gid: string | null = null;
private static game: Game | null = null;
private static realDebridTorrentId: string | null = null;
private static aria2c: ChildProcess | null = null;
private static aria2 = new Aria2({});
private static async connect() {
this.aria2c = startAria2();
let retries = 0;
while (retries < 4 && !this.connected) {
try {
await this.aria2.open();
logger.log("Connected to aria2");
this.connected = true;
} catch (err) {
await sleep(100);
logger.log("Failed to connect to aria2, retrying...");
retries++;
}
}
}
public static disconnect() {
if (this.aria2c) {
this.aria2c.kill();
this.connected = false;
}
}
private static getETA(
totalLength: number,
completedLength: number,
speed: number
) {
const remainingBytes = totalLength - completedLength;
if (remainingBytes >= 0 && speed > 0) {
return (remainingBytes / speed) * 1000;
}
return -1;
}
private static getFolderName(status: StatusResponse) {
if (status.bittorrent?.info) return status.bittorrent.info.name;
const [file] = status.files;
if (file) return path.win32.basename(file.path);
return null;
}
private static async getRealDebridDownloadUrl() {
if (this.realDebridTorrentId) {
const torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
const { status, links } = torrentInfo;
if (status === "waiting_files_selection") {
await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
return null;
}
if (status === "downloaded") {
const [link] = links;
const { download } = await RealDebridClient.unrestrictLink(link);
return decodeURIComponent(download);
}
if (WindowManager.mainWindow) {
const progress = torrentInfo.progress / 100;
const totalDownloaded = progress * torrentInfo.bytes;
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
const payload = {
numPeers: 0,
numSeeds: torrentInfo.seeders,
downloadSpeed: torrentInfo.speed,
timeRemaining: this.getETA(
torrentInfo.bytes,
totalDownloaded,
torrentInfo.speed
),
isDownloadingMetadata: status === "magnet_conversion",
game: {
...this.game,
bytesDownloaded: progress * torrentInfo.bytes,
progress,
},
} as DownloadProgress;
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify(payload))
);
}
}
return null;
}
public static async watchDownloads() {
if (!this.game) return;
if (!this.gid && this.realDebridTorrentId) {
const options = { dir: this.game.downloadPath! };
const downloadUrl = await this.getRealDebridDownloadUrl();
if (downloadUrl) {
this.gid = await this.aria2.call("addUri", [downloadUrl], options);
this.downloads.set(this.game.id, this.gid);
this.realDebridTorrentId = null;
}
}
if (!this.gid) return;
const status = await this.aria2.call("tellStatus", this.gid);
const isDownloadingMetadata = status.bittorrent && !status.bittorrent?.info;
if (status.followedBy?.length) {
this.gid = status.followedBy[0];
this.downloads.set(this.game.id, this.gid);
return;
}
const progress =
Number(status.completedLength) / Number(status.totalLength);
if (!isDownloadingMetadata) {
const update: QueryDeepPartialEntity<Game> = {
bytesDownloaded: Number(status.completedLength),
fileSize: Number(status.totalLength),
status: status.status,
};
if (!isNaN(progress)) update.progress = progress;
await gameRepository.update(
{ id: this.game.id },
{
...update,
status: status.status,
folderName: this.getFolderName(status),
}
);
}
const game = await gameRepository.findOne({
where: { id: this.game.id, isDeleted: false },
});
if (WindowManager.mainWindow && game) {
if (!isNaN(progress))
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
const payload = {
numPeers: Number(status.connections),
numSeeds: Number(status.numSeeders ?? 0),
downloadSpeed: Number(status.downloadSpeed),
timeRemaining: this.getETA(
Number(status.totalLength),
Number(status.completedLength),
Number(status.downloadSpeed)
),
isDownloadingMetadata: !!isDownloadingMetadata,
game,
} as DownloadProgress;
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify(payload))
);
}
if (progress === 1 && this.game && !isDownloadingMetadata) {
publishDownloadCompleteNotification(this.game);
await downloadQueueRepository.delete({ game: this.game });
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (nextQueueItem) {
this.resumeDownload(nextQueueItem.game);
}
}
}
private static clearCurrentDownload() {
if (this.game) {
this.downloads.delete(this.game.id);
this.gid = null;
this.game = null;
this.realDebridTorrentId = null;
}
}
static async cancelDownload(gameId: number) {
const gid = this.downloads.get(gameId);
if (gid) {
await this.aria2.call("forceRemove", gid);
if (this.gid === gid) {
this.clearCurrentDownload();
WindowManager.mainWindow?.setProgressBar(-1);
} else {
this.downloads.delete(gameId);
}
}
}
static async pauseDownload() {
if (this.gid) {
await this.aria2.call("forcePause", this.gid);
this.gid = null;
}
this.game = null;
this.realDebridTorrentId = null;
WindowManager.mainWindow?.setProgressBar(-1);
}
static async resumeDownload(game: Game) {
if (this.downloads.has(game.id)) {
const gid = this.downloads.get(game.id)!;
await this.aria2.call("unpause", gid);
this.gid = gid;
this.game = game;
this.realDebridTorrentId = null;
} else {
return this.startDownload(game);
}
}
static async startDownload(game: Game) {
if (!this.connected) await this.connect();
const options = {
dir: game.downloadPath!,
};
if (game.downloader === Downloader.RealDebrid) {
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
game!.uri!
);
} else {
this.gid = await this.aria2.call("addUri", [game.uri!], options);
this.downloads.set(game.id, this.gid);
}
this.game = game;
}
}

View File

@@ -0,0 +1,105 @@
import { Game } from "@main/entity";
import { Downloader } from "@shared";
import { PythonInstance } from "./python-instance";
import { WindowManager } from "../window-manager";
import { downloadQueueRepository, gameRepository } from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications";
import { RealDebridDownloader } from "./real-debrid-downloader";
import type { DownloadProgress } from "@types";
export class DownloadManager {
private static currentDownloader: Downloader | null = null;
public static async watchDownloads() {
let status: DownloadProgress | null = null;
if (this.currentDownloader === Downloader.RealDebrid) {
status = await RealDebridDownloader.getStatus();
} else {
status = await PythonInstance.getStatus();
}
if (status) {
const { gameId, progress } = status;
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (WindowManager.mainWindow && game) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(
JSON.stringify({
...status,
game,
})
)
);
}
if (progress === 1 && game) {
publishDownloadCompleteNotification(game);
await downloadQueueRepository.delete({ game });
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (nextQueueItem) {
this.resumeDownload(nextQueueItem.game);
}
}
}
}
static async pauseDownload() {
if (this.currentDownloader === Downloader.RealDebrid) {
await RealDebridDownloader.pauseDownload();
} else {
await PythonInstance.pauseDownload();
}
WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null;
}
static async resumeDownload(game: Game) {
if (game.downloader === Downloader.RealDebrid) {
RealDebridDownloader.startDownload(game);
this.currentDownloader = Downloader.RealDebrid;
} else {
PythonInstance.startDownload(game);
this.currentDownloader = Downloader.Torrent;
}
}
static async cancelDownload(gameId: number) {
if (this.currentDownloader === Downloader.RealDebrid) {
RealDebridDownloader.cancelDownload(gameId);
} else {
PythonInstance.cancelDownload(gameId);
}
WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null;
}
static async startDownload(game: Game) {
if (game.downloader === Downloader.RealDebrid) {
RealDebridDownloader.startDownload(game);
this.currentDownloader = Downloader.RealDebrid;
} else {
PythonInstance.startDownload(game);
this.currentDownloader = Downloader.Torrent;
}
}
}

View File

@@ -0,0 +1,13 @@
export const calculateETA = (
totalLength: number,
completedLength: number,
speed: number
) => {
const remainingBytes = totalLength - completedLength;
if (remainingBytes >= 0 && speed > 0) {
return (remainingBytes / speed) * 1000;
}
return -1;
};

View File

@@ -0,0 +1,68 @@
import type { ChildProcess } from "node:child_process";
import { logger } from "../logger";
import { sleep } from "@main/helpers";
import { startAria2 } from "../aria2c";
import Aria2 from "aria2";
export class HttpDownload {
private static connected = false;
private static aria2c: ChildProcess | null = null;
private static aria2 = new Aria2({});
private static async connect() {
this.aria2c = startAria2();
let retries = 0;
while (retries < 4 && !this.connected) {
try {
await this.aria2.open();
logger.log("Connected to aria2");
this.connected = true;
} catch (err) {
await sleep(100);
logger.log("Failed to connect to aria2, retrying...");
retries++;
}
}
}
public static getStatus(gid: string) {
if (this.connected) {
return this.aria2.call("tellStatus", gid);
}
return null;
}
public static disconnect() {
if (this.aria2c) {
this.aria2c.kill();
this.connected = false;
}
}
static async cancelDownload(gid: string) {
await this.aria2.call("forceRemove", gid);
}
static async pauseDownload(gid: string) {
await this.aria2.call("forcePause", gid);
}
static async resumeDownload(gid: string) {
await this.aria2.call("unpause", gid);
}
static async startDownload(downloadPath: string, downloadUrl: string) {
if (!this.connected) await this.connect();
const options = {
dir: downloadPath,
};
return this.aria2.call("addUri", [downloadUrl], options);
}
}

View File

@@ -0,0 +1,2 @@
export * from "./download-manager";
export * from "./python-instance";

View File

@@ -0,0 +1,162 @@
import cp from "node:child_process";
import { Game } from "@main/entity";
import {
RPC_PASSWORD,
RPC_PORT,
startTorrentClient as startRPCClient,
} from "./torrent-client";
import { gameRepository } from "@main/repository";
import { DownloadProgress } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { calculateETA } from "./helpers";
import axios from "axios";
import {
CancelDownloadPayload,
StartDownloadPayload,
PauseDownloadPayload,
LibtorrentStatus,
LibtorrentPayload,
ProcessPayload,
} from "./types";
export class PythonInstance {
private static pythonProcess: cp.ChildProcess | null = null;
private static downloadingGameId = -1;
private static rpc = axios.create({
baseURL: `http://localhost:${RPC_PORT}`,
headers: {
"x-hydra-rpc-password": RPC_PASSWORD,
},
});
public static spawn(args?: StartDownloadPayload) {
this.pythonProcess = startRPCClient(args);
}
public static kill() {
if (this.pythonProcess) {
this.pythonProcess.kill();
this.pythonProcess = null;
this.downloadingGameId = -1;
}
}
public static killTorrent() {
if (this.pythonProcess) {
this.rpc.post("/action", { action: "kill-torrent" });
this.downloadingGameId = -1;
}
}
public static async getProcessList() {
return (
(await this.rpc.get<ProcessPayload[] | null>("/process-list")).data || []
);
}
public static async getStatus() {
if (this.downloadingGameId === -1) return null;
const response = await this.rpc.get<LibtorrentPayload | null>("/status");
if (response.data === null) return null;
try {
const {
progress,
numPeers,
numSeeds,
downloadSpeed,
bytesDownloaded,
fileSize,
folderName,
status,
gameId,
} = response.data;
this.downloadingGameId = gameId;
const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata;
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
if (!isDownloadingMetadata && !isCheckingFiles) {
const update: QueryDeepPartialEntity<Game> = {
bytesDownloaded,
fileSize,
progress,
status: "active",
};
await gameRepository.update(
{ id: gameId },
{
...update,
folderName,
}
);
}
if (progress === 1 && !isCheckingFiles) {
this.downloadingGameId = -1;
}
return {
numPeers,
numSeeds,
downloadSpeed,
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
isDownloadingMetadata,
isCheckingFiles,
progress,
gameId,
} as DownloadProgress;
} catch (err) {
return null;
}
}
static async pauseDownload() {
await this.rpc
.post("/action", {
action: "pause",
game_id: this.downloadingGameId,
} as PauseDownloadPayload)
.catch(() => {});
this.downloadingGameId = -1;
}
static async startDownload(game: Game) {
if (!this.pythonProcess) {
this.spawn({
game_id: game.id,
magnet: game.uri!,
save_path: game.downloadPath!,
});
} else {
await this.rpc.post("/action", {
action: "start",
game_id: game.id,
magnet: game.uri,
save_path: game.downloadPath,
} as StartDownloadPayload);
}
this.downloadingGameId = game.id;
}
static async cancelDownload(gameId: number) {
await this.rpc
.post("/action", {
action: "cancel",
game_id: gameId,
} as CancelDownloadPayload)
.catch(() => {});
this.downloadingGameId = -1;
}
}

View File

@@ -0,0 +1,162 @@
import { Game } from "@main/entity";
import { RealDebridClient } from "../real-debrid";
import { gameRepository } from "@main/repository";
import { calculateETA } from "./helpers";
import { DownloadProgress } from "@types";
import { HttpDownload } from "./http-download";
export class RealDebridDownloader {
private static downloads = new Map<number, string>();
private static downloadingGame: Game | null = null;
private static realDebridTorrentId: string | null = null;
private static async getRealDebridDownloadUrl() {
if (this.realDebridTorrentId) {
const torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
const { status, links } = torrentInfo;
if (status === "waiting_files_selection") {
await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
return null;
}
if (status === "downloaded") {
const [link] = links;
const { download } = await RealDebridClient.unrestrictLink(link);
return decodeURIComponent(download);
}
}
return null;
}
public static async getStatus() {
if (this.downloadingGame) {
const gid = this.downloads.get(this.downloadingGame.id)!;
const status = await HttpDownload.getStatus(gid);
if (status) {
const progress =
Number(status.completedLength) / Number(status.totalLength);
await gameRepository.update(
{ id: this.downloadingGame!.id },
{
bytesDownloaded: Number(status.completedLength),
fileSize: Number(status.totalLength),
progress,
status: "active",
}
);
const result = {
numPeers: 0,
numSeeds: 0,
downloadSpeed: Number(status.downloadSpeed),
timeRemaining: calculateETA(
Number(status.totalLength),
Number(status.completedLength),
Number(status.downloadSpeed)
),
isDownloadingMetadata: false,
isCheckingFiles: false,
progress,
gameId: this.downloadingGame!.id,
} as DownloadProgress;
if (progress === 1) {
this.downloads.delete(this.downloadingGame.id);
this.realDebridTorrentId = null;
this.downloadingGame = null;
}
return result;
}
}
if (this.realDebridTorrentId && this.downloadingGame) {
const torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
const { status } = torrentInfo;
if (status === "downloaded") {
this.startDownload(this.downloadingGame);
}
const progress = torrentInfo.progress / 100;
const totalDownloaded = progress * torrentInfo.bytes;
return {
numPeers: 0,
numSeeds: torrentInfo.seeders,
downloadSpeed: torrentInfo.speed,
timeRemaining: calculateETA(
torrentInfo.bytes,
totalDownloaded,
torrentInfo.speed
),
isDownloadingMetadata: status === "magnet_conversion",
} as DownloadProgress;
}
return null;
}
static async pauseDownload() {
const gid = this.downloads.get(this.downloadingGame!.id!);
if (gid) {
await HttpDownload.pauseDownload(gid);
}
this.realDebridTorrentId = null;
this.downloadingGame = null;
}
static async startDownload(game: Game) {
this.downloadingGame = game;
if (this.downloads.has(game.id)) {
await this.resumeDownload(game.id!);
return;
}
this.realDebridTorrentId = await RealDebridClient.getTorrentId(game!.uri!);
const downloadUrl = await this.getRealDebridDownloadUrl();
if (downloadUrl) {
this.realDebridTorrentId = null;
const gid = await HttpDownload.startDownload(
game.downloadPath!,
downloadUrl
);
this.downloads.set(game.id!, gid);
}
}
static async cancelDownload(gameId: number) {
const gid = this.downloads.get(gameId);
if (gid) {
await HttpDownload.cancelDownload(gid);
this.downloads.delete(gameId);
}
}
static async resumeDownload(gameId: number) {
const gid = this.downloads.get(gameId);
if (gid) {
await HttpDownload.resumeDownload(gid);
}
}
}

View File

@@ -0,0 +1,60 @@
import path from "node:path";
import cp from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import { app, dialog } from "electron";
import type { StartDownloadPayload } from "./types";
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: "hydra-download-manager",
linux: "hydra-download-manager",
win32: "hydra-download-manager.exe",
};
export const BITTORRENT_PORT = "5881";
export const RPC_PORT = "8084";
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
export const startTorrentClient = (args?: StartDownloadPayload) => {
const commonArgs = [
BITTORRENT_PORT,
RPC_PORT,
RPC_PASSWORD,
args ? encodeURIComponent(JSON.stringify(args)) : "",
];
if (app.isPackaged) {
const binaryName = binaryNameByPlatform[process.platform]!;
const binaryPath = path.join(
process.resourcesPath,
"hydra-download-manager",
binaryName
);
if (!fs.existsSync(binaryPath)) {
dialog.showErrorBox(
"Fatal",
"Hydra Download Manager binary not found. Please check if it has been removed by Windows Defender."
);
app.quit();
}
return cp.spawn(binaryPath, commonArgs, {
stdio: "inherit",
windowsHide: true,
});
} else {
const scriptPath = path.join(
__dirname,
"..",
"..",
"torrent-client",
"main.py"
);
return cp.spawn("python3", [scriptPath, ...commonArgs], {
stdio: "inherit",
});
}
};

View File

@@ -0,0 +1,38 @@
export interface StartDownloadPayload {
game_id: number;
magnet: string;
save_path: string;
}
export interface PauseDownloadPayload {
game_id: number;
}
export interface CancelDownloadPayload {
game_id: number;
}
export enum LibtorrentStatus {
CheckingFiles = 1,
DownloadingMetadata = 2,
Downloading = 3,
Finished = 4,
Seeding = 5,
}
export interface LibtorrentPayload {
progress: number;
numPeers: number;
numSeeds: number;
downloadSpeed: number;
bytesDownloaded: number;
fileSize: number;
folderName: string;
status: LibtorrentStatus;
gameId: number;
}
export interface ProcessPayload {
exe: string;
pid: number;
}

View File

@@ -5,6 +5,7 @@ import url from "url";
import { uploadGamesBatch } from "./library-sync";
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id";
import { logger } from "./logger";
import { UserNotLoggedInError } from "@shared";
export class HydraApi {
private static instance: AxiosInstance;
@@ -19,7 +20,7 @@ export class HydraApi {
expirationTimestamp: 0,
};
static isLoggedIn() {
private static isLoggedIn() {
return this.userAuth.authToken !== "";
}
@@ -90,7 +91,21 @@ export class HydraApi {
return response;
},
(error) => {
logger.error("response error", error);
logger.error(" ---- RESPONSE ERROR -----");
const { config } = error;
logger.error(config.method, config.baseURL, config.url, config.headers);
if (error.response) {
logger.error(error.response.status, error.response.data);
} else if (error.request) {
logger.error(error.request);
} else {
logger.error("Error", error.message);
}
logger.error(" ----- END RESPONSE ERROR -------");
return Promise.reject(error);
}
);
@@ -106,14 +121,15 @@ export class HydraApi {
};
}
private static async revalidateAccessTokenIfExpired() {
if (!this.userAuth.authToken) {
userAuthRepository.delete({ id: 1 });
logger.error("user is not logged in");
throw new Error("user is not logged in");
private static sendSignOutEvent() {
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-signout");
}
}
private static async revalidateAccessTokenIfExpired() {
const now = new Date();
if (this.userAuth.expirationTimestamp < now.getTime()) {
try {
const response = await this.instance.post(`/auth/refresh`, {
@@ -139,26 +155,7 @@ export class HydraApi {
["id"]
);
} catch (err) {
if (
err instanceof AxiosError &&
(err?.response?.status === 401 || err?.response?.status === 403)
) {
this.userAuth = {
authToken: "",
expirationTimestamp: 0,
refreshToken: "",
};
userAuthRepository.delete({ id: 1 });
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-signout");
}
logger.log("user refresh token expired");
}
throw err;
this.handleUnauthorizedError(err);
}
}
}
@@ -171,28 +168,64 @@ export class HydraApi {
};
}
private static handleUnauthorizedError = (err) => {
if (err instanceof AxiosError && err.response?.status === 401) {
this.userAuth = {
authToken: "",
expirationTimestamp: 0,
refreshToken: "",
};
userAuthRepository.delete({ id: 1 });
this.sendSignOutEvent();
}
throw err;
};
static async get(url: string) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
return this.instance.get(url, this.getAxiosConfig());
return this.instance
.get(url, this.getAxiosConfig())
.catch(this.handleUnauthorizedError);
}
static async post(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
return this.instance.post(url, data, this.getAxiosConfig());
return this.instance
.post(url, data, this.getAxiosConfig())
.catch(this.handleUnauthorizedError);
}
static async put(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
return this.instance.put(url, data, this.getAxiosConfig());
return this.instance
.put(url, data, this.getAxiosConfig())
.catch(this.handleUnauthorizedError);
}
static async patch(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
return this.instance.patch(url, data, this.getAxiosConfig());
return this.instance
.patch(url, data, this.getAxiosConfig())
.catch(this.handleUnauthorizedError);
}
static async delete(url: string) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
return this.instance.delete(url, this.getAxiosConfig());
return this.instance
.delete(url, this.getAxiosConfig())
.catch(this.handleUnauthorizedError);
}
}

View File

@@ -3,7 +3,7 @@ export * from "./steam";
export * from "./steam-250";
export * from "./steam-grid";
export * from "./window-manager";
export * from "./download-manager";
export * from "./download";
export * from "./how-long-to-beat";
export * from "./process-watcher";
export * from "./main-loop";

View File

@@ -1,11 +1,25 @@
import { Game } from "@main/entity";
import { HydraApi } from "../hydra-api";
import { gameRepository } from "@main/repository";
export const createGame = async (game: Game) => {
return HydraApi.post(`/games`, {
HydraApi.post(`/games`, {
objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,
});
})
.then((response) => {
const {
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
gameRepository.update(
{ objectID: game.objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
})
.catch(() => {});
};

View File

@@ -2,71 +2,63 @@ import { gameRepository } from "@main/repository";
import { HydraApi } from "../hydra-api";
import { steamGamesWorker } from "@main/workers";
import { getSteamAppAsset } from "@main/helpers";
import { logger } from "../logger";
import { AxiosError } from "axios";
export const mergeWithRemoteGames = async () => {
try {
const games = await HydraApi.get("/games");
for (const game of games.data) {
const localGame = await gameRepository.findOne({
where: {
objectID: game.objectId,
},
});
if (localGame) {
const updatedLastTimePlayed =
localGame.lastTimePlayed == null ||
(game.lastTimePlayed &&
new Date(game.lastTimePlayed) > localGame.lastTimePlayed)
? game.lastTimePlayed
: localGame.lastTimePlayed;
const updatedPlayTime =
localGame.playTimeInMilliseconds < game.playTimeInMilliseconds
? game.playTimeInMilliseconds
: localGame.playTimeInMilliseconds;
gameRepository.update(
{
return HydraApi.get("/games")
.then(async (response) => {
for (const game of response.data) {
const localGame = await gameRepository.findOne({
where: {
objectID: game.objectId,
shop: "steam",
},
{
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
playTimeInMilliseconds: updatedPlayTime,
}
);
} else {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
if (steamGame) {
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
if (localGame) {
const updatedLastTimePlayed =
localGame.lastTimePlayed == null ||
(game.lastTimePlayed &&
new Date(game.lastTimePlayed) > localGame.lastTimePlayed)
? game.lastTimePlayed
: localGame.lastTimePlayed;
gameRepository.insert({
objectID: game.objectId,
title: steamGame?.name,
remoteId: game.id,
shop: game.shop,
iconUrl,
lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds,
const updatedPlayTime =
localGame.playTimeInMilliseconds < game.playTimeInMilliseconds
? game.playTimeInMilliseconds
: localGame.playTimeInMilliseconds;
gameRepository.update(
{
objectID: game.objectId,
shop: "steam",
},
{
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
playTimeInMilliseconds: updatedPlayTime,
}
);
} else {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
if (steamGame) {
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
gameRepository.insert({
objectID: game.objectId,
title: steamGame?.name,
remoteId: game.id,
shop: game.shop,
iconUrl,
lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds,
});
}
}
}
}
} catch (err) {
if (err instanceof AxiosError) {
logger.error("getRemoteGames", err.response, err.message);
} else {
logger.error("getRemoteGames", err);
}
}
})
.catch(() => {});
};

View File

@@ -6,8 +6,8 @@ export const updateGamePlaytime = async (
deltaInMillis: number,
lastTimePlayed: Date
) => {
return HydraApi.put(`/games/${game.remoteId}`, {
HydraApi.put(`/games/${game.remoteId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed,
});
}).catch(() => {});
};

View File

@@ -2,43 +2,32 @@ import { gameRepository } from "@main/repository";
import { chunk } from "lodash-es";
import { IsNull } from "typeorm";
import { HydraApi } from "../hydra-api";
import { logger } from "../logger";
import { AxiosError } from "axios";
import { mergeWithRemoteGames } from "./merge-with-remote-games";
import { WindowManager } from "../window-manager";
export const uploadGamesBatch = async () => {
try {
const games = await gameRepository.find({
where: { remoteId: IsNull(), isDeleted: false },
});
const games = await gameRepository.find({
where: { remoteId: IsNull(), isDeleted: false },
});
const gamesChunks = chunk(games, 200);
const gamesChunks = chunk(games, 200);
for (const chunk of gamesChunks) {
await HydraApi.post(
"/games/batch",
chunk.map((game) => {
return {
objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,
};
})
);
}
await mergeWithRemoteGames();
if (WindowManager.mainWindow)
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
} catch (err) {
if (err instanceof AxiosError) {
logger.error("uploadGamesBatch", err.response, err.message);
} else {
logger.error("uploadGamesBatch", err);
}
for (const chunk of gamesChunks) {
await HydraApi.post(
"/games/batch",
chunk.map((game) => {
return {
objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,
};
})
).catch(() => {});
}
await mergeWithRemoteGames();
if (WindowManager.mainWindow)
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
};

View File

@@ -1,5 +1,5 @@
import { sleep } from "@main/helpers";
import { DownloadManager } from "./download-manager";
import { DownloadManager } from "./download";
import { watchProcesses } from "./process-watcher";
export const startMainLoop = async () => {

View File

@@ -1,7 +1,7 @@
import { Notification, nativeImage } from "electron";
import { t } from "i18next";
import { parseICO } from "icojs";
import trayIcon from "@resources/tray-icon.png?asset";
import { Game } from "@main/entity";
import { gameRepository, userPreferencesRepository } from "@main/repository";
@@ -39,11 +39,9 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
new Notification({
title: t("download_complete", {
ns: "notifications",
lng: userPreferences.language,
}),
body: t("game_ready_to_install", {
ns: "notifications",
lng: userPreferences.language,
title: game.title,
}),
icon,
@@ -60,13 +58,26 @@ export const publishNewRepacksNotifications = async (count: number) => {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
lng: userPreferences?.language || "en",
}),
body: t("repack_count", {
ns: "notifications",
lng: userPreferences?.language || "en",
count: count,
}),
}).show();
}
};
export const publishNotificationUpdateReadyToInstall = async (
version: string
) => {
new Notification({
title: t("new_update_available", {
ns: "notifications",
version,
}),
body: t("restart_to_install_update", {
ns: "notifications",
}),
icon: trayIcon,
}).show();
};

View File

@@ -1,11 +1,9 @@
import path from "node:path";
import { IsNull, Not } from "typeorm";
import { gameRepository } from "@main/repository";
import { getProcesses } from "@main/helpers";
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
import { GameRunning } from "@types";
import { PythonInstance } from "./download";
export const gamesPlaytime = new Map<
number,
@@ -21,23 +19,13 @@ export const watchProcesses = async () => {
});
if (games.length === 0) return;
const processes = await getProcesses();
const processes = await PythonInstance.getProcessList();
for (const game of games) {
const executablePath = game.executablePath!;
const basename = path.win32.basename(executablePath);
const basenameWithoutExtension = path.win32.basename(
executablePath,
path.extname(executablePath)
);
const gameProcess = processes.find((runningProcess) => {
if (process.platform === "win32") {
return runningProcess.name === basename;
}
return [basename, basenameWithoutExtension].includes(runningProcess.name);
return executablePath == runningProcess.exe;
});
if (gameProcess) {
@@ -60,12 +48,7 @@ export const watchProcesses = async () => {
if (game.remoteId) {
updateGamePlaytime(game, 0, new Date());
} else {
createGame({ ...game, lastTimePlayed: new Date() }).then(
(response) => {
const { id: remoteId } = response.data;
gameRepository.update({ objectID: game.objectID }, { remoteId });
}
);
createGame({ ...game, lastTimePlayed: new Date() });
}
gamesPlaytime.set(game.id, {
@@ -84,10 +67,7 @@ export const watchProcesses = async () => {
game.lastTimePlayed!
);
} else {
createGame(game).then((response) => {
const { id: remoteId } = response.data;
gameRepository.update({ objectID: game.objectID }, { remoteId });
});
createGame(game);
}
}
}

View File

@@ -95,6 +95,7 @@ export class WindowManager {
minimizable: false,
webPreferences: {
sandbox: false,
nodeIntegrationInSubFrames: true,
},
});

View File

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

View File

@@ -1,6 +1,6 @@
import { downloadSourceSchema } from "@main/events/helpers/validators";
import { DownloadSourceStatus } from "@shared";
import type { DownloadSource } from "@types";
import type { DownloadSource, GameRepack } from "@types";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { z } from "zod";
@@ -48,3 +48,24 @@ export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
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

@@ -110,8 +110,8 @@ contextBridge.exposeInMainWorld("electron", {
ping: () => ipcRenderer.invoke("ping"),
getVersion: () => ipcRenderer.invoke("getVersion"),
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
isUserLoggedIn: () => ipcRenderer.invoke("isUserLoggedIn"),
showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("showOpenDialog", options),
platform: process.platform,

View File

@@ -6,7 +6,7 @@
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
/>
</head>
<body style="background-color: #1c1c1c">

View File

@@ -93,12 +93,8 @@ export function App() {
dispatch(setProfileBackground(profileBackground));
}
window.electron.isUserLoggedIn().then((isLoggedIn) => {
if (isLoggedIn) {
fetchUserDetails().then((response) => {
if (response) updateUserDetails(response);
});
}
fetchUserDetails().then((response) => {
if (response) updateUserDetails(response);
});
}, [fetchUserDetails, updateUserDetails, dispatch]);

View File

@@ -32,8 +32,17 @@ export function BottomPanel() {
const status = useMemo(() => {
if (isGameDownloading) {
if (lastPacket?.isCheckingFiles)
return t("checking_files", {
title: lastPacket?.game.title,
percentage: progress,
});
if (lastPacket?.isDownloadingMetadata)
return t("downloading_metadata", { title: lastPacket?.game.title });
return t("downloading_metadata", {
title: lastPacket?.game.title,
percentage: progress,
});
if (!eta) {
return t("calculating_eta", {
@@ -56,6 +65,7 @@ export function BottomPanel() {
isGameDownloading,
lastPacket?.game,
lastPacket?.isDownloadingMetadata,
lastPacket?.isCheckingFiles,
progress,
eta,
downloadSpeed,

View File

@@ -47,10 +47,8 @@ export function AutoUpdateSubHeader() {
return (
<header className={styles.subheader}>
<Link to={releasesPageUrl} className={styles.newVersionLink}>
<SyncIcon size={12} />
<small>
{t("version_available_download", { version: newVersion })}
</small>
<SyncIcon className={styles.newVersionIcon} size={12} />
{t("version_available_download", { version: newVersion })}
</Link>
</header>
);
@@ -64,10 +62,8 @@ export function AutoUpdateSubHeader() {
className={styles.newVersionButton}
onClick={handleClickInstallUpdate}
>
<SyncIcon size={12} />
<small>
{t("version_available_install", { version: newVersion })}
</small>
<SyncIcon className={styles.newVersionIcon} size={12} />
{t("version_available_install", { version: newVersion })}
</button>
</header>
);

View File

@@ -157,7 +157,7 @@ export const newVersionButton = style({
justifyContent: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.body,
fontSize: "13px",
fontSize: "12px",
":hover": {
textDecoration: "underline",
cursor: "pointer",
@@ -169,5 +169,9 @@ export const newVersionLink = style({
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: "#8e919b",
fontSize: "13px",
fontSize: "12px",
});
export const newVersionIcon = style({
color: vars.color.success,
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
@@ -14,6 +14,7 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@@ -35,6 +36,10 @@ export function Sidebar() {
const location = useLocation();
const sortedLibrary = useMemo(() => {
return sortBy(library, (game) => game.title);
}, [library]);
const { lastPacket, progress } = useDownload();
const { showWarningToast } = useToast();
@@ -43,7 +48,7 @@ export function Sidebar() {
updateLibrary();
}, [lastPacket?.game.id, updateLibrary]);
const isDownloading = library.some(
const isDownloading = sortedLibrary.some(
(game) => game.status === "active" && game.progress !== 1
);
@@ -63,7 +68,7 @@ export function Sidebar() {
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setFilteredLibrary(
library.filter((game) =>
sortedLibrary.filter((game) =>
game.title
.toLowerCase()
.includes(event.target.value.toLocaleLowerCase())
@@ -72,8 +77,8 @@ export function Sidebar() {
};
useEffect(() => {
setFilteredLibrary(library);
}, [library]);
setFilteredLibrary(sortedLibrary);
}, [sortedLibrary]);
useEffect(() => {
window.onmousemove = (event: MouseEvent) => {

View File

@@ -140,7 +140,7 @@ export function GameDetailsContextProvider({
filters: [
{
name: "Game executable",
extensions: ["exe"],
extensions: ["exe", "lnk"],
},
],
})

View File

@@ -100,10 +100,10 @@ declare global {
/* Misc */
openExternal: (src: string) => Promise<void>;
isUserLoggedIn: () => Promise<boolean>;
getVersion: () => Promise<string>;
ping: () => string;
getDefaultDownloadsPath: () => Promise<string>;
isPortableVersion: () => Promise<boolean>;
showOpenDialog: (
options: Electron.OpenDialogOptions
) => Promise<Electron.OpenDialogReturnValue>;

View File

@@ -22,13 +22,14 @@ export function useDownload() {
);
const dispatch = useAppDispatch();
const startDownload = (payload: StartGameDownloadPayload) =>
const startDownload = (payload: StartGameDownloadPayload) => {
dispatch(clearDownload());
window.electron.startGameDownload(payload).then((game) => {
dispatch(clearDownload());
updateLibrary();
return game;
});
};
const pauseDownload = async (gameId: number) => {
await window.electron.pauseGameDownload(gameId);
@@ -65,7 +66,7 @@ export function useDownload() {
updateLibrary();
});
const getETA = () => {
const calculateETA = () => {
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
try {
@@ -85,9 +86,9 @@ export function useDownload() {
return {
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
progress: formatDownloadProgress(lastPacket?.game.progress),
progress: formatDownloadProgress(lastPacket?.progress ?? 0),
lastPacket,
eta: getETA(),
eta: calculateETA(),
startDownload,
pauseDownload,
resumeDownload,

View File

@@ -57,8 +57,14 @@ export function useUserDetails() {
);
const fetchUserDetails = useCallback(async () => {
return window.electron.getMe();
}, []);
return window.electron.getMe().then((userDetails) => {
if (userDetails == null) {
clearUserDetails();
}
return userDetails;
});
}, [clearUserDetails]);
const patchUser = useCallback(
async (displayName: string, imageProfileUrl: string | null) => {

View File

@@ -6,6 +6,8 @@ import { Provider } from "react-redux";
import LanguageDetector from "i18next-browser-languagedetector";
import { HashRouter, Route, Routes } from "react-router-dom";
import * as Sentry from "@sentry/electron/renderer";
import "@fontsource/fira-mono/400.css";
import "@fontsource/fira-mono/500.css";
import "@fontsource/fira-mono/700.css";
@@ -29,6 +31,8 @@ import { store } from "./store";
import * as resources from "@locales";
import { User } from "./pages/user/user";
Sentry.init({});
i18n
.use(LanguageDetector)
.use(initReactI18next)

View File

@@ -21,13 +21,10 @@ export function Catalogue() {
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingInfScroll, setIsLoadingInfScroll] = useState(false);
const [resultsExhausted, setResultsExhausted] = useState(false);
const contentRef = useRef<HTMLElement>(null);
const cursorRef = useRef<number>(0);
const cursorInfScrollRef = useRef<number>(cursorRef.current + 24);
const navigate = useNavigate();
@@ -65,44 +62,9 @@ export function Catalogue() {
cursor: cursorRef.current.toString(),
});
resetInfiniteScroll();
navigate(`/catalogue?${params.toString()}`);
};
const resetInfiniteScroll = () => {
cursorInfScrollRef.current = cursorRef.current + 24;
setResultsExhausted(false);
};
const infiniteLoading = () => {
if (resultsExhausted || !contentRef.current) return;
const isAtBottom =
contentRef.current.offsetHeight + contentRef.current.scrollTop ==
contentRef.current.scrollHeight;
if (isAtBottom) {
setIsLoadingInfScroll(true);
window.electron
.getGames(24, cursorInfScrollRef.current)
.then(({ results, cursor }) => {
return new Promise((resolve) => {
setTimeout(() => {
if (results.length == 0) {
setResultsExhausted(true);
}
cursorInfScrollRef.current += cursor;
setSearchResults([...searchResults, ...results]);
resolve(null);
}, 500);
});
})
.finally(() => {
setIsLoadingInfScroll(false);
});
}
};
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section
@@ -116,10 +78,7 @@ export function Catalogue() {
}}
>
<Button
onClick={() => {
resetInfiniteScroll();
navigate(-1);
}}
onClick={() => navigate(-1)}
theme="outline"
disabled={cursor === 0 || isLoading}
>
@@ -133,11 +92,7 @@ export function Catalogue() {
</Button>
</section>
<section
ref={contentRef}
className={styles.content}
onScroll={infiniteLoading}
>
<section ref={contentRef} className={styles.content}>
<section className={styles.cards}>
{isLoading &&
Array.from({ length: 12 }).map((_, index) => (
@@ -155,11 +110,6 @@ export function Catalogue() {
))}
</>
)}
{isLoadingInfScroll &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))}
</section>
</section>
</SkeletonTheme>

View File

@@ -67,6 +67,19 @@ export function DownloadGroup({
}
if (isGameDownloading) {
if (lastPacket?.isDownloadingMetadata) {
return <p>{t("downloading_metadata")}</p>;
}
if (lastPacket?.isCheckingFiles) {
return (
<>
<p>{progress}</p>
<p>{t("checking_files")}</p>
</>
);
}
return (
<>
<p>{progress}</p>
@@ -110,7 +123,7 @@ export function DownloadGroup({
);
}
return <p>{t(game.status)}</p>;
return <p>{t(game.status as string)}</p>;
};
const getGameActions = (game: LibraryGame) => {

View File

@@ -122,7 +122,7 @@ export function HeroPanelActions() {
<Button
onClick={() => setShowGameOptionsModal(true)}
theme="outline"
disabled={deleting || isGameRunning}
disabled={deleting}
className={styles.heroPanelAction}
>
<GearIcon />

View File

@@ -54,7 +54,7 @@ export function HeroPanelPlaytime() {
if (!game) return null;
const hasDownload =
["active", "paused"].includes(game.status) && game.progress !== 1;
["active", "paused"].includes(game.status as string) && game.progress !== 1;
const isGameDownloading =
game.status === "active" && lastPacket?.game.id === game.id;

View File

@@ -23,7 +23,7 @@ export function GameOptionsModal({
const { showSuccessToast, showErrorToast } = useToast();
const { updateGame, setShowRepacksModal, selectGameExecutable } =
const { updateGame, setShowRepacksModal, repacks, selectGameExecutable } =
useContext(gameDetailsContext);
const [showDeleteModal, setShowDeleteModal] = useState(false);
@@ -156,7 +156,7 @@ export function GameOptionsModal({
<Button
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={deleting || isGameDownloading}
disabled={deleting || isGameDownloading || !repacks.length}
>
{t("open_download_options")}
</Button>

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext, useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import parseTorrent from "parse-torrent";
@@ -12,6 +12,7 @@ import { format } from "date-fns";
import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared";
import { orderBy } from "lodash-es";
export interface RepacksModalProps {
visible: boolean;
@@ -38,16 +39,20 @@ export function RepacksModal({
const { t } = useTranslation("game_details");
const sortedRepacks = useMemo(() => {
return orderBy(repacks, (repack) => repack.uploadDate, "desc");
}, [repacks]);
const getInfoHash = useCallback(async () => {
const torrent = await parseTorrent(game?.uri ?? "");
if (torrent.infoHash) setInfoHash(torrent.infoHash);
}, [game]);
useEffect(() => {
setFilteredRepacks(repacks);
setFilteredRepacks(sortedRepacks);
if (game?.uri) getInfoHash();
}, [repacks, visible, game, getInfoHash]);
}, [sortedRepacks, visible, game, getInfoHash]);
const handleRepackClick = (repack: GameRepack) => {
setRepack(repack);
@@ -58,7 +63,7 @@ export function RepacksModal({
const term = event.target.value.toLocaleLowerCase();
setFilteredRepacks(
repacks.filter((repack) => {
sortedRepacks.filter((repack) => {
const lowerCaseTitle = repack.title.toLowerCase();
const lowerCaseRepacker = repack.repacker.toLowerCase();

View File

@@ -10,6 +10,8 @@ export function SettingsBehavior() {
(state) => state.userPreferences.value
);
const [showRunAtStartup, setShowRunAtStartup] = useState(false);
const { updateUserPreferences } = useContext(settingsContext);
const [form, setForm] = useState({
@@ -28,6 +30,12 @@ export function SettingsBehavior() {
}
}, [userPreferences]);
useEffect(() => {
window.electron.isPortableVersion().then((isPortableVersion) => {
setShowRunAtStartup(!isPortableVersion);
});
}, []);
const handleChange = (values: Partial<typeof form>) => {
setForm((prev) => ({ ...prev, ...values }));
updateUserPreferences(values);
@@ -45,14 +53,16 @@ export function SettingsBehavior() {
}
/>
<CheckboxField
label={t("launch_with_system")}
onChange={() => {
handleChange({ runAtStartup: !form.runAtStartup });
window.electron.autoLaunch(!form.runAtStartup);
}}
checked={form.runAtStartup}
/>
{showRunAtStartup && (
<CheckboxField
label={t("launch_with_system")}
onChange={() => {
handleChange({ runAtStartup: !form.runAtStartup });
window.electron.autoLaunch(!form.runAtStartup);
}}
checked={form.runAtStartup}
/>
)}
</>
);
}

View File

@@ -1,6 +1,6 @@
import { UserProfile } from "@types";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { UserSkeleton } from "./user-skeleton";
@@ -12,6 +12,7 @@ import * as styles from "./user.css";
export const User = () => {
const { userId } = useParams();
const [userProfile, setUserProfile] = useState<UserProfile>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
@@ -20,6 +21,8 @@ export const User = () => {
if (userProfile) {
dispatch(setHeaderTitle(userProfile.displayName));
setUserProfile(userProfile);
} else {
navigate(-1);
}
});
}, [dispatch, userId]);

View File

@@ -1,6 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -8,6 +8,13 @@ export enum DownloadSourceStatus {
Errored,
}
export class UserNotLoggedInError extends Error {
constructor() {
super("user not logged in");
this.name = "UserNotLoggedInError";
}
}
const FORMAT = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
export const formatBytes = (bytes: number): string => {
@@ -44,10 +51,15 @@ export const removeSpecialEditionFromName = (name: string) =>
export const removeDuplicateSpaces = (name: string) =>
name.replace(/\s{2,}/g, " ");
export const replaceUnderscoreWithSpace = (name: string) =>
name.replace(/_/g, " ");
export const formatName = pipe<string>(
removeReleaseYearFromName,
removeSymbolsFromName,
removeSpecialEditionFromName,
replaceUnderscoreWithSpace,
(str) => str.replace(/DIRECTOR'S CUT/g, ""),
removeSymbolsFromName,
removeDuplicateSpaces,
(str) => str.trim()
);

View File

@@ -1,6 +1,13 @@
import type { Aria2Status } from "aria2";
import type { DownloadSourceStatus, Downloader } from "@shared";
export type GameStatus =
| "active"
| "waiting"
| "paused"
| "error"
| "complete"
| "removed";
export type GameShop = "steam" | "epic";
export interface SteamGenre {
@@ -106,7 +113,7 @@ export interface Game {
id: number;
title: string;
iconUrl: string;
status: Aria2Status | null;
status: GameStatus | null;
folderName: string;
downloadPath: string | null;
repacks: GameRepack[];
@@ -142,6 +149,9 @@ export interface DownloadProgress {
numPeers: number;
numSeeds: number;
isDownloadingMetadata: boolean;
isCheckingFiles: boolean;
progress: number;
gameId: number;
game: LibraryGame;
}
@@ -262,6 +272,7 @@ export interface UserDetails {
export interface UserProfile {
id: string;
displayName: string;
username: string;
profileImageUrl: string | null;
totalPlayTimeInSeconds: number;
libraryGames: UserGame[];

View File

@@ -0,0 +1,62 @@
import libtorrent as lt
class Downloader:
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)})
def start_download(self, game_id: int, magnet: str, save_path: str):
params = {'url': magnet, 'save_path': save_path}
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
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
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
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
def get_download_status(self):
if self.downloading_game_id == -1:
return None
torrent_handle = self.torrent_handles.get(self.downloading_game_id)
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,
}

88
torrent-client/main.py Normal file
View File

@@ -0,0 +1,88 @@
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import urllib.parse
import psutil
from downloader import Downloader
torrent_port = sys.argv[1]
http_port = sys.argv[2]
rpc_password = sys.argv[3]
start_download_payload = sys.argv[4]
downloader = None
if start_download_payload:
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
downloader = Downloader(torrent_port)
downloader.start_download(initial_download['game_id'], initial_download['magnet'], initial_download['save_path'])
class Handler(BaseHTTPRequestHandler):
rpc_password_header = 'x-hydra-rpc-password'
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
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
status = 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 downloader
if self.path == "/action":
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 downloader is None:
downloader = Downloader(torrent_port)
if data['action'] == 'start':
downloader.start_download(data['game_id'], data['magnet'], data['save_path'])
elif data['action'] == 'pause':
downloader.pause_download(data['game_id'])
elif data['action'] == 'cancel':
downloader.cancel_download(data['game_id'])
elif data['action'] == 'kill-torrent':
downloader.abort_session()
downloader = None
self.send_response(200)
self.end_headers()
if __name__ == "__main__":
httpd = HTTPServer(("", int(http_port)), Handler)
httpd.serve_forever()

20
torrent-client/setup.py Normal file
View File

@@ -0,0 +1,20 @@
from cx_Freeze import setup, Executable
# Dependencies are automatically detected, but it might need fine tuning.
build_exe_options = {
"packages": ["libtorrent"],
"build_exe": "hydra-download-manager",
"include_msvcr": True
}
setup(
name="hydra-download-manager",
version="0.1",
description="Hydra",
options={"build_exe": build_exe_options},
executables=[Executable(
"torrent-client/main.py",
target_name="hydra-download-manager",
icon="build/icon.ico"
)]
)

2871
yarn.lock

File diff suppressed because it is too large Load Diff