Compare commits

...

173 Commits

Author SHA1 Message Date
Chubby Granny Chaser
32b9f88702 feat: adding ota updates 2025-10-13 23:56:16 +01:00
Chubby Granny Chaser
c9fc4dfc02 feat: adding ota updates 2025-10-13 23:55:33 +01:00
Chubby Granny Chaser
0ae3e35cb4 feat: adding ota updates 2025-10-13 23:53:44 +01:00
Chubby Granny Chaser
cd136c07a6 ci: prod deployment wrangler 2025-10-13 23:20:26 +01:00
Chubby Granny Chaser
6e243822ff ci: prod deployment wrangler 2025-10-13 23:16:26 +01:00
Chubby Granny Chaser
12274b8c57 ci: prod deployment wrangler 2025-10-13 23:13:23 +01:00
Chubby Granny Chaser
a8bbb76190 ci: testing build 2025-10-13 22:59:09 +01:00
Chubby Granny Chaser
59d4545476 ci: testing build 2025-10-13 22:54:15 +01:00
Chubby Granny Chaser
621adbb1ab ci: testing build 2025-10-13 22:51:53 +01:00
Zamitto
d530c384c9 Merge pull request #1809 from whintersnow0/fix/get-user-data-db
fix: race condition in getUserData database update
2025-10-13 18:03:01 -03:00
Zamitto
d8e30a3f2f Merge pull request #1808 from hydralauncher/fix/game-not-auto-detecting-exe-on-linux
fix: game not auto detecting exe on linux
2025-10-13 18:02:42 -03:00
Chubby Granny Chaser
5e59e1a7d1 feat: adding uuid library 2025-10-13 18:12:07 +01:00
Chubby Granny Chaser
fc541daaed feat: adding uuid library 2025-10-13 18:08:41 +01:00
Chubby Granny Chaser
53d81018e9 feat: adding uuid library 2025-10-13 18:05:48 +01:00
Chubby Granny Chaser
25758a540f ci: adding dockerfile 2025-10-13 17:45:51 +01:00
Chubby Granny Chaser
e5659543ce ci: adding dockerfile 2025-10-13 17:01:45 +01:00
whintersnow0
612350ac19 fix: race condition in getUserData database update 2025-10-13 16:42:31 +02:00
Zamitto
b9c7f992dc fix: aria2 process not being killed 2025-10-13 09:10:31 -03:00
Zamitto
97dc7653b0 chore: bump deps 2025-10-13 09:10:12 -03:00
Zamitto
330f38776f feat: change disk usage library as old one did not compile with more recent electron 2025-10-13 08:18:54 -03:00
Zamitto
b874138641 fix: also test executable name without extension 2025-10-13 08:17:50 -03:00
Chubby Granny Chaser
a439095260 fix: fixing hu translation
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-12 22:51:41 +01:00
Chubby Granny Chaser
5ff263e8c8 Merge pull request #1807 from Stormm232/main
Hungarian Translation: Aligned to latest update.
2025-10-12 22:46:06 +01:00
Kiwo.2
864c47ee46 Missing "," fix 2025-10-12 23:45:34 +02:00
Kiwo.2
523e19d97a Merge branch 'main' into main 2025-10-12 23:41:08 +02:00
Zamitto
03b2c8d07f fix: missing abortController after assets promise 2025-10-12 18:32:06 -03:00
Kiwo.2
d296830533 Matching *HU* translation to Latest 2025-10-12 23:30:52 +02:00
Kiwo.2
7fc4e17547 Merge branch 'hydralauncher:main' into main 2025-10-12 23:26:16 +02:00
Chubby Granny Chaser
636cf287c9 Merge branch 'main' of github.com:hydralauncher/hydra 2025-10-12 22:06:16 +01:00
Chubby Granny Chaser
84be238988 ci: version bump 2025-10-12 22:05:53 +01:00
Chubby Granny Chaser
494a2a0da5 Merge pull request #1806 from hydralauncher/fix/game_asset_changing_path
Fix: game asset changing path
2025-10-12 20:56:00 +01:00
Moyase
4e912b3b8d Merge branch 'main' into fix/game_asset_changing_path 2025-10-12 22:52:45 +03:00
Moyasee
e71211f1aa Fix: extracted ternary operations 2025-10-12 22:51:35 +03:00
Moyasee
a946f3bd5a Fix: extracted ternary operations 2025-10-12 22:48:33 +03:00
Chubby Granny Chaser
374b62983b feat: adding 2h constraint 2025-10-12 20:48:13 +01:00
Moyasee
0cd4c3ccf6 Fix: extracted ternary operations 2025-10-12 22:45:20 +03:00
Moyasee
7b97663b3a Merge branch 'fix/game_asset_changing_path' of https://github.com/hydralauncher/hydra into fix/game_asset_changing_path 2025-10-12 22:40:07 +03:00
Moyasee
68e2e2a772 Fix: conditional structure 2025-10-12 22:39:26 +03:00
Moyase
39979292e2 Merge branch 'main' into fix/game_asset_changing_path 2025-10-12 22:37:28 +03:00
Moyasee
60ae7d40fa Fix: Image path persists upon clearing image 2025-10-12 22:34:05 +03:00
Chubby Granny Chaser
63b6b0b44e Merge pull request #1805 from hydralauncher/feat/reviews-and-commenting
fix: fixing search on sources modal
2025-10-12 20:17:22 +01:00
Chubby Granny Chaser
6b6dfc7adc Merge branch 'main' into feat/reviews-and-commenting 2025-10-12 20:17:11 +01:00
Chubby Granny Chaser
7c33c43d9c fix: fixing search on sources modal 2025-10-12 20:16:37 +01:00
Moyasee
82c0dc0d97 Merge branch 'main' of https://github.com/hydralauncher/hydra into fix/game_asset_changing_path 2025-10-12 22:13:09 +03:00
Moyasee
1cba3f350c formatting 2025-10-12 22:12:15 +03:00
Chubby Granny Chaser
5d0f036de2 Merge pull request #1801 from hydralauncher/feat/reviews-and-commenting
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
Feat/reviews and commenting
2025-10-12 19:05:45 +01:00
Chubby Granny Chaser
c2e5bc0e91 fix: fixing translations 2025-10-12 19:04:10 +01:00
Chubby Granny Chaser
030b3b8f7c fix: fixing translations 2025-10-12 19:03:30 +01:00
Chubby Granny Chaser
38b04ee991 fix: fixing translations 2025-10-12 19:01:07 +01:00
Chubby Granny Chaser
5f643ecd5f fix: fixing translations 2025-10-12 18:59:19 +01:00
Chubby Granny Chaser
366ce953d4 Merge pull request #1803 from Wkeynhk/patch-4
Update Russian Translation
2025-10-12 18:53:20 +01:00
Chubby Granny Chaser
34aea2b0c4 feat: adding api call for decky plugin 2025-10-12 18:51:47 +01:00
Chubby Granny Chaser
dcec33ada1 feat: adding review styling 2025-10-12 18:40:27 +01:00
Chubby Granny Chaser
5877c8c798 feat: adding review styling 2025-10-12 18:40:05 +01:00
Chubby Granny Chaser
14204f1fbe feat: adding review styling 2025-10-12 18:39:41 +01:00
Moyasee
602b2fef91 Fix: TipTap formatting not displaying on the review message 2025-10-12 19:57:12 +03:00
Moyasee
2240a8c9fb Fix: TipTap formatting not displaying on the review message 2025-10-12 19:54:45 +03:00
Moyasee
6c34a1fcc0 Merge branch 'feat/reviews-and-commenting' of https://github.com/hydralauncher/hydra into feat/reviews-and-commenting 2025-10-12 19:52:12 +03:00
Moyasee
741f9de85c Fix: formatting 2025-10-12 19:51:26 +03:00
Zamitto
5510bb9c9e Merge pull request #1804 from hydralauncher/feat/separate-game-stats-from-assets
feat: separate game stats from assets
2025-10-12 12:19:20 -03:00
Zamitto
df6d9df31d feat: update cache time 2025-10-12 12:18:43 -03:00
Chubby Granny Chaser
1e779a32c1 Merge branch 'main' into patch-4 2025-10-12 12:16:21 +01:00
Zamitto
b21c97ea66 feat: remove import 2025-10-11 11:59:54 -03:00
Zamitto
9bada771df feat: separate game assets from game stats 2025-10-11 11:26:05 -03:00
Zamitto
7b8f7fc070 feat: add alternative steam path on linux
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-10 15:17:27 -03:00
Zamitto
6146a1fbf1 Merge branch 'main' into feat/reviews-and-commenting 2025-10-10 14:50:46 -03:00
Zamitto
91fd5932da fix: lastTimePlayed not updating correctly when merging games 2025-10-10 14:46:21 -03:00
Zamitto
a9b67ad1e6 feat: handle and log game backup errors 2025-10-10 14:29:00 -03:00
Kiwo.2
aa4def327a New lines added for update 2025-10-07 19:39:25 +02:00
Moyasee
47ac8e63ac fix: fixed button layout in prompt message and fixed rating display in stats in game page 2025-10-06 18:30:56 +03:00
Moyasee
3d71dded3d fix: formatting 2025-10-06 15:48:19 +03:00
Moyasee
055be6b10a Fix: review prompt banner appearing in all games 2025-10-06 15:41:12 +03:00
Wkeynhk
b0d9d18c6c Update translation.json 2025-10-06 00:35:24 +03:00
Wkeynhk
e6d5a2e871 Update translation.json 2025-10-05 21:38:43 +03:00
Moyasee
063e97e0ec Fix: marked props read-only and catch error 2025-10-05 20:36:20 +03:00
Moyasee
6667e00c91 Feat: added rating showing in game card in categories, fixed maybe later button, changed empty state, fixed copy issue, added karma showing, added remove review text, added empty state for games with no reviews, fixed sorting buttons, fixed shift in the page 2025-10-05 20:32:41 +03:00
Moyasee
8653e62dce fix: using proper input type instead of the button role 2025-10-04 20:25:28 +03:00
Moyasee
71f391c8e8 fix: refactoring function, using proper attributes and extracted ternary operation 2025-10-04 20:22:08 +03:00
Moyasee
1f7947f50f fix: refactoring function, using proper attributes and extracted ternary operation 2025-10-04 20:20:48 +03:00
Moyasee
72562b13ef Feat: Rating score display redesign, Rating choosing redesign, added avg rating on the game page 2025-10-04 20:14:27 +03:00
Moyasee
1f05dc8f78 Feat: Rating score display redesign, Rating choosing redesign, added avg rating on the game page 2025-10-04 20:12:01 +03:00
Moyasee
52d3750acc fix: multiple imports and other minor issues 2025-10-03 16:20:32 +03:00
Moyasee
b91306e70e fix: possible DoS 2025-10-03 16:16:33 +03:00
Moyasee
e3fb325b7b fix: eslint fix 2025-10-03 15:57:06 +03:00
Moyasee
2e68018059 fix: eslint error 2025-10-03 15:56:02 +03:00
Moyasee
f11296f3a9 Fix: eslint error 2025-10-03 15:54:40 +03:00
Moyasee
a92563509b Fix: fixed zalgo text + html formatting inside of the review message 2025-10-03 15:52:40 +03:00
Moyasee
899f68318f Fix: multiple imports, ambigious spacing and unexpected negated condition 2025-10-03 03:06:37 +03:00
Moyasee
1b5f70a075 Fix: replaced array index with review.id and marked props as read-only 2025-10-03 03:01:37 +03:00
Moyasee
fab02c4d16 Fix: Format-check fail and translations. Feat: added animations to upvote and downvote buttons 2025-10-03 02:55:41 +03:00
Moyasee
8d5b169166 Feat: updated input field design, fixed text overflow 2025-10-02 21:22:09 +03:00
Moyasee
3160ee68f1 Merge branch 'feat/reviews-and-commenting' of https://github.com/hydralauncher/hydra into feat/reviews-and-commenting 2025-10-02 19:31:43 +03:00
Moyasee
80275dc08f Fix: Review delete modal button color + added missing translation 2025-10-02 19:29:50 +03:00
Moyase
958e66d795 Merge branch 'main' into feat/reviews-and-commenting 2025-10-02 19:08:34 +03:00
Moyasee
4116459577 Fix: Typescript error regarding missing karma property 2025-10-02 19:07:58 +03:00
Moyasee
449ea92268 feat: Added karma to user profile, added warning modal before deleting review, fixed sorting buttons for reviews 2025-10-02 19:04:25 +03:00
Chubby Granny Chaser
f1a1270230 Merge pull request #1792 from caduHD4/feat/context_game_menu
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
Feat: context game menu in sidebar
2025-10-02 10:50:38 +01:00
Chubby Granny Chaser
63fe7acd0d Merge branch 'main' into feat/context_game_menu 2025-10-02 10:50:13 +01:00
Chubby Granny Chaser
2beb9c469a Merge pull request #1800 from ItsYeBoi20/fix-outdated-pr-1452
All-debrid support (fixed conflicts)
2025-10-02 10:43:56 +01:00
Moyasee
19cf24ef48 feat: changed profile pictures in reviews to squares, changed sorting buttons 2025-10-02 01:30:44 +03:00
Moyasee
461da55070 fix: push fix 2025-10-02 00:45:33 +03:00
Moyasee
f08ad361ed feat: added review functionality 2025-10-02 00:43:49 +03:00
ItsYeBoi20
abe2314c38 Fix Game type to resolve typecheck errors 2025-10-01 06:15:41 +02:00
ItsYeBoi20
cb9b120093 Fix Game type to resolve typecheck errors 2025-10-01 05:43:03 +02:00
Carlos Eduardo Mariano Garcia Pereira
3058a05ca8 Merge branch 'main' into feat/context_game_menu 2025-10-01 00:34:48 -03:00
ItsYeBoi20
aacf9abc6a Update PR #1452 to latest HydraLauncher and fix conflicts 2025-10-01 05:04:34 +02:00
Chubby Granny Chaser
79498abdb5 feat: improving ui for download source filter
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-09-30 20:37:44 +01:00
Chubby Granny Chaser
2aebbb8fa2 Merge pull request #1791 from caduHD4/feat/source-filter
feat: add filter by source in modal repacks
2025-09-30 20:21:16 +01:00
Chubby Granny Chaser
c3ce92a48e Merge branch 'main' into feat/source-filter 2025-09-30 20:21:07 +01:00
Chubby Granny Chaser
8eb15900fe Merge pull request #1799 from Stormm232/main
Add Hungarian Translation (Re-Translated, please read!)
2025-09-30 20:19:42 +01:00
Chubby Granny Chaser
9812f455c9 Merge branch 'main' into main 2025-09-30 20:19:29 +01:00
Chubby Granny Chaser
74983520ed Merge pull request #1798 from hydralauncher/feat/revert-title-and-cleanup-fix
feat: added ability to reset assets for custom game
2025-09-30 20:19:23 +01:00
Carlos Eduardo Mariano Garcia Pereira
e3cd596fb2 Fix "," for 'no_repacks_found' key 2025-09-30 14:46:16 -03:00
Kiwo.2
37db88f48f Fix: "catalogue": { was duplicated at game_details 2025-09-30 19:44:15 +02:00
Carlos Eduardo Mariano Garcia Pereira
0e999496e3 Merge branch 'main' into feat/source-filter 2025-09-30 13:51:49 -03:00
Moyasee
9c87964e16 fix: added missing translations 2025-09-30 19:19:39 +03:00
Kiwo.2
45903d778e Adjustment to Translation 2025-09-30 16:12:29 +02:00
Kiwo.2
937a3d189e Fixed an error 2025-09-30 15:11:09 +02:00
Kiwo.2
406b455960 Update build.yml 2025-09-30 14:48:20 +02:00
Kiwo.2
c39f8c703b Merge branch 'main' of https://github.com/Stormm232/hydra 2025-09-30 14:23:19 +02:00
Kiwo.2
01b8f0c3af Adjusted again - Drop Hero Image and such r left untranslated due to no lines in json 2025-09-30 14:21:58 +02:00
Kiwo.2
5930132de4 Merge branch 'hydralauncher:main' into main 2025-09-30 14:20:47 +02:00
Moyasee
776859c58e fix: extracted ternary operations 2025-09-30 14:29:40 +03:00
Moyasee
cb0fc82644 feat: added ability to reset assets for custom game 2025-09-30 14:24:00 +03:00
Kiwo.2
3826294337 Fixes 2025-09-30 12:42:29 +02:00
Chubby Granny Chaser
a625541125 fix: fixing modal issue
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-09-30 06:52:48 +01:00
Chubby Granny Chaser
300cff2be6 ci: changing windows version
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-09-30 05:37:01 +01:00
Kiwo.2
3c502679a6 Adjustment 2025-09-30 06:14:52 +02:00
Kiwo.2
701226d25d Correction of Translation for upcoming update 2025-09-30 06:04:04 +02:00
Chubby Granny Chaser
4f5c345c42 ci: testing webhook 2025-09-30 05:00:22 +01:00
Chubby Granny Chaser
d513377f1c Merge pull request #1797 from hydralauncher/feat/custom-games-support
Feat/custom games support
2025-09-30 04:02:06 +01:00
Kiwo.2
46491af539 Fix to the translation 2025-09-30 03:08:39 +02:00
Kiwo.2
f9f110bd1c Typo 2025-09-30 02:50:32 +02:00
Kiwo.2
e14c125a43 Small typo 2025-09-30 02:42:58 +02:00
Kiwo.2
d8947e5ab8 Merge branch 'main' of https://github.com/Stormm232/hydra 2025-09-30 02:40:06 +02:00
Stormm232
5648e393bf Deleted file. 2025-09-30 02:39:24 +02:00
Kiwo.2
d7e47323e5 Update to latest 2025-09-30 02:38:09 +02:00
Carlos Eduardo Mariano Garcia Pereira
30e4d694cd Merge branch 'main' into feat/source-filter 2025-09-29 14:26:21 -03:00
Carlos Eduardo Mariano Garcia Pereira
0dbf904bb8 Merge branch 'main' into feat/context_game_menu 2025-09-29 14:25:35 -03:00
Stormm232
e678cc3801 MAIN_VITE_AUTH_URL.env 2025-09-29 04:38:20 +02:00
Stormm232
d7d920a7b7 Add files via upload 2025-09-29 02:52:28 +02:00
Kiwo.2
3afd30765f Merge branch 'main' of https://github.com/Stormm232/hydra 2025-09-29 00:08:06 +02:00
Kiwo.2
5bf5ca3504 Adjustment 2025-09-29 00:07:49 +02:00
Stormm232
6e2d7f5c8a Merge branch 'hydralauncher:main' into main 2025-09-28 23:37:32 +02:00
Carlos Eduardo Mariano Garcia Pereira
a003153239 Merge branch 'main' into feat/context_game_menu 2025-09-28 18:32:33 -03:00
Carlos Eduardo Mariano Garcia Pereira
40aaa5d3ca Merge branch 'main' into feat/source-filter 2025-09-28 18:13:49 -03:00
Kiwo.2
0e2cc2b25c Adjustment to the translation 2025-09-28 19:18:43 +02:00
Kiwo.2
6f258aecee Merge branch 'main' of https://github.com/Stormm232/hydra 2025-09-28 16:48:20 +02:00
Kiwo.2
35ce07c5f4 Webstore was left untranslated 2025-09-28 16:47:48 +02:00
Chubby Granny Chaser
7514177423 Merge branch 'main' into feat/context_game_menu 2025-09-28 15:18:16 +01:00
Stormm232
4f3113002b Update build.yml 2025-09-28 15:48:15 +02:00
Stormm232
e43f80b830 Update build.yml 2025-09-28 15:47:50 +02:00
caduHD4
6c34e5516f fix: add newline at the end of repacks-modal.scss 2025-09-28 02:33:05 -03:00
caduHD4
81f001ade4 fix: add 'no sources found for this game' message in translation files 2025-09-28 02:18:35 -03:00
caduHD4
688ae215d7 Refactor: Remove translation keys (keeping pt-br and en) and clean up repacks modal styles and logic 2025-09-28 02:04:31 -03:00
caduHD4
6f5baf0df5 refactor: duplicate code 2025-09-28 01:16:33 -03:00
caduHD4
1d5be940f9 feat: add filter by source in modal repacks 2025-09-28 01:00:02 -03:00
caduHD4
cd25fa715e Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/context_menu-and-enhancement-actions 2025-09-28 00:22:01 -03:00
caduHD4
13a2a8161c Merge branch 'feat/context_menu-and-enhancement-actions' of https://github.com/caduHD4/hydra into feat/context_menu-and-enhancement-actions 2025-09-28 00:14:36 -03:00
Kiwo.2
7ba65b3357 Slight Adjustment to 'Hot Now' 2025-09-28 02:20:33 +02:00
Kiwo.2
bd56c48cb3 Improved Hungarian Translation 2025-09-28 02:16:26 +02:00
Chubby Granny Chaser
1525ccc438 Merge branch 'main' into feat/context_menu-and-enhancement-actions 2025-09-28 00:52:38 +01:00
Chubby Granny Chaser
08702d3157 Merge branch 'main' into feat/context_menu-and-enhancement-actions 2025-09-25 17:48:13 +01:00
Carlos Eduardo Mariano Garcia Pereira
8dba05eafe fix: lint 2025-09-24 00:01:49 -03:00
Carlos Eduardo Mariano Garcia Pereira
5e217bf086 Fix: Enhance repacks modal with filter toggle feature
Added filter toggle functionality with icons to repacks modal.
2025-09-23 23:53:06 -03:00
Carlos Eduardo Mariano Garcia Pereira
349da9536a Refactor SCSS for repacks modal layout and styles 2025-09-23 23:49:29 -03:00
caduHD4
dad493f558 feat: add a toggle for the filter 2025-09-23 23:40:53 -03:00
caduHD4
9939b68fec fix: add missing newline at end of context-menu.scss, lint... 2025-09-21 18:32:03 -03:00
caduHD4
d00fe8dedc fix: more lint 2025-09-21 18:26:43 -03:00
caduHD4
f9673b1a5a fix: adjusting lint formatting 2025-09-21 18:14:35 -03:00
caduHD4
3ff8ed735e feat: add new translations for properties and favorites in multiple languages 2025-09-21 16:22:04 -03:00
caduHD4
ca6331e45b fix: improve context menu shortcut styling 2025-09-21 01:48:22 -03:00
caduHD4
0d19328798 feat: add context menu for game items
- Added ContextMenu and GameContextMenu with actions (play, favorite, manage).
- Integrated useGameActions hook and updated SidebarGameItem for right-click menu.
- Enhanced game details and RepacksModal (filters, styling, disabled states).
2025-09-20 22:50:16 -03:00
156 changed files with 8741 additions and 1730 deletions

40
.github/workflows/build-renderer.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Build Renderer
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.0
- name: Install dependencies
run: yarn --frozen-lockfile --ignore-scripts
- name: Build Renderer
run: yarn build
env:
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
- name: Deploy to Cloudflare Pages
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx --yes wrangler@3 pages deploy out/renderer \
--project-name="hydra" \
--commit-dirty=true \
--branch="main"

View File

@@ -10,7 +10,7 @@ jobs:
build:
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
os: [windows-2022, ubuntu-latest]
fail-fast: false
runs-on: ${{ matrix.os }}
@@ -22,7 +22,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.0
node-version: 20.18.3
- name: Install dependencies
run: yarn --frozen-lockfile
@@ -58,7 +58,7 @@ jobs:
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
- name: Build Windows
if: matrix.os == 'windows-latest'
if: matrix.os == 'windows-2022'
run: yarn build:win
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}

View File

@@ -17,7 +17,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.0
node-version: 20.18.3
- name: Install dependencies
run: yarn --frozen-lockfile

View File

@@ -6,13 +6,13 @@ concurrency:
on:
push:
branches: main
branches: [main]
jobs:
build:
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
os: [windows-2022, ubuntu-latest]
runs-on: ${{ matrix.os }}
@@ -23,7 +23,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.0
node-version: 20.18.3
- name: Install dependencies
run: yarn --frozen-lockfile
@@ -57,9 +57,10 @@ jobs:
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }}
- name: Build Windows
if: matrix.os == 'windows-latest'
if: matrix.os == 'windows-2022'
run: yarn build:win
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
@@ -73,6 +74,7 @@ jobs:
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }}
- name: Create artifact
uses: actions/upload-artifact@v4

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.6.8",
"version": "3.7.0",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -32,17 +32,24 @@
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@fontsource/noto-sans": "^5.1.0",
"@hookform/resolvers": "^3.9.1",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource/noto-sans": "^5.2.10",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.6.0",
"@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@reduxjs/toolkit": "^2.2.3",
"@tiptap/extension-bold": "^3.6.2",
"@tiptap/extension-italic": "^3.6.2",
"@tiptap/extension-link": "^3.6.2",
"@tiptap/extension-underline": "^3.6.2",
"@tiptap/react": "^3.6.2",
"@tiptap/starter-kit": "^3.6.2",
"auto-launch": "^5.0.6",
"axios": "^1.7.9",
"axios": "^1.12.2",
"axios-cookiejar-support": "^5.0.5",
"check-disk-space": "^3.4.0",
"classic-level": "^2.0.0",
"classnames": "^2.5.1",
"color": "^4.2.3",
@@ -51,8 +58,7 @@
"create-desktop-shortcuts": "^1.11.1",
"date-fns": "^3.6.0",
"dexie": "^4.0.10",
"diskusage": "^1.2.0",
"electron-log": "^5.2.4",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
@@ -63,6 +69,7 @@
"jsdom": "^24.0.0",
"jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21",
"lucide-react": "^0.544.0",
"parse-torrent": "^11.0.18",
"rc-virtual-list": "^3.18.3",
"react-dnd": "^16.0.1",
@@ -80,6 +87,7 @@
"tar": "^7.4.3",
"tough-cookie": "^5.1.1",
"user-agents": "^1.1.387",
"uuid": "^13.0.0",
"winreg": "^1.2.5",
"ws": "^8.18.1",
"yaml": "^2.6.1",
@@ -109,7 +117,7 @@
"@types/winreg": "^1.2.36",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.2.1",
"electron": "^32.3.3",
"electron": "^33.4.11",
"electron-builder": "^26.0.12",
"electron-vite": "^3.0.0",
"eslint": "^8.56.0",
@@ -123,8 +131,8 @@
"sass-embedded": "^1.80.6",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vite-plugin-svgr": "^4.2.0"
"vite": "5.4.20",
"vite-plugin-svgr": "^4.5.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -0,0 +1,151 @@
import aria2p
from aria2p.client import ClientException as DownloadNotFound
class HttpMultiLinkDownloader:
def __init__(self):
self.downloads = []
self.completed_downloads = []
self.total_size = None
self.aria2 = aria2p.API(
aria2p.Client(
host="http://localhost",
port=6800,
secret=""
)
)
def start_download(self, urls: list[str], save_path: str, header: str = None, out: str = None, total_size: int = None):
"""Add multiple URLs to download queue with same options"""
options = {"dir": save_path}
if header:
options["header"] = header
if out:
options["out"] = out
# Clear any existing downloads first
self.cancel_download()
self.completed_downloads = []
self.total_size = total_size
for url in urls:
try:
added_downloads = self.aria2.add(url, options=options)
self.downloads.extend(added_downloads)
except Exception as e:
print(f"Error adding download for URL {url}: {str(e)}")
def pause_download(self):
"""Pause all active downloads"""
if self.downloads:
try:
self.aria2.pause(self.downloads)
except Exception as e:
print(f"Error pausing downloads: {str(e)}")
def cancel_download(self):
"""Cancel and remove all downloads"""
if self.downloads:
try:
# First try to stop the downloads
self.aria2.remove(self.downloads)
except Exception as e:
print(f"Error removing downloads: {str(e)}")
finally:
# Clear the downloads list regardless of success/failure
self.downloads = []
self.completed_downloads = []
def get_download_status(self):
"""Get status for all tracked downloads, auto-remove completed/failed ones"""
if not self.downloads and not self.completed_downloads:
return []
total_completed = 0
current_download_speed = 0
active_downloads = []
to_remove = []
# First calculate sizes from completed downloads
for completed in self.completed_downloads:
total_completed += completed['size']
# Then check active downloads
for download in self.downloads:
try:
current_download = self.aria2.get_download(download.gid)
# Skip downloads that are not properly initialized
if not current_download or not current_download.files:
to_remove.append(download)
continue
# Add to completed size and speed calculations
total_completed += current_download.completed_length
current_download_speed += current_download.download_speed
# If download is complete, move it to completed_downloads
if current_download.status == 'complete':
self.completed_downloads.append({
'name': current_download.name,
'size': current_download.total_length
})
to_remove.append(download)
else:
active_downloads.append({
'name': current_download.name,
'size': current_download.total_length,
'completed': current_download.completed_length,
'speed': current_download.download_speed
})
except DownloadNotFound:
to_remove.append(download)
continue
except Exception as e:
print(f"Error getting download status: {str(e)}")
continue
# Clean up completed/removed downloads from active list
for download in to_remove:
try:
if download in self.downloads:
self.downloads.remove(download)
except ValueError:
pass
# Return aggregate status
if self.total_size or active_downloads or self.completed_downloads:
# Use the first active download's name as the folder name, or completed if none active
folder_name = None
if active_downloads:
folder_name = active_downloads[0]['name']
elif self.completed_downloads:
folder_name = self.completed_downloads[0]['name']
if folder_name and '/' in folder_name:
folder_name = folder_name.split('/')[0]
# Use provided total size if available, otherwise sum from downloads
total_size = self.total_size
if not total_size:
total_size = sum(d['size'] for d in active_downloads) + sum(d['size'] for d in self.completed_downloads)
# Calculate completion status based on total downloaded vs total size
is_complete = len(active_downloads) == 0 and total_completed >= (total_size * 0.99) # Allow 1% margin for size differences
# If all downloads are complete, clear the completed_downloads list to prevent status updates
if is_complete:
self.completed_downloads = []
return [{
'folderName': folder_name,
'fileSize': total_size,
'progress': total_completed / total_size if total_size > 0 else 0,
'downloadSpeed': current_download_speed,
'numPeers': 0,
'numSeeds': 0,
'status': 'complete' if is_complete else 'active',
'bytesDownloaded': total_completed,
}]
return []

View File

@@ -3,6 +3,7 @@ import sys, json, urllib.parse, psutil
from torrent_downloader import TorrentDownloader
from http_downloader import HttpDownloader
from profile_image_processor import ProfileImageProcessor
from http_multi_link_downloader import HttpMultiLinkDownloader
import libtorrent as lt
app = Flask(__name__)
@@ -24,7 +25,15 @@ if start_download_payload:
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
downloading_game_id = initial_download['game_id']
if initial_download['url'].startswith('magnet'):
if isinstance(initial_download['url'], list):
# Handle multiple URLs using HttpMultiLinkDownloader
http_multi_downloader = HttpMultiLinkDownloader()
downloads[initial_download['game_id']] = http_multi_downloader
try:
http_multi_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out"))
except Exception as e:
print("Error starting multi-link download", e)
elif initial_download['url'].startswith('magnet'):
torrent_downloader = TorrentDownloader(torrent_session)
downloads[initial_download['game_id']] = torrent_downloader
try:
@@ -62,12 +71,23 @@ def status():
return auth_error
downloader = downloads.get(downloading_game_id)
if downloader:
status = downloads.get(downloading_game_id).get_download_status()
return jsonify(status), 200
else:
if not downloader:
return jsonify(None)
status = downloader.get_download_status()
if not status:
return jsonify(None)
if isinstance(status, list):
if not status: # Empty list
return jsonify(None)
# For multi-link downloader, use the aggregated status
# The status will already be aggregated by the HttpMultiLinkDownloader
return jsonify(status[0]), 200
return jsonify(status), 200
@app.route("/seed-status", methods=["GET"])
def seed_status():
auth_error = validate_rpc_password()
@@ -81,10 +101,24 @@ def seed_status():
continue
response = downloader.get_download_status()
if response is None:
if not response:
continue
if response.get('status') == 5:
if isinstance(response, list):
# For multi-link downloader, check if all files are complete
if response and all(item['status'] == 'complete' for item in response):
seed_status.append({
'gameId': game_id,
'status': 'complete',
'folderName': response[0]['folderName'],
'fileSize': sum(item['fileSize'] for item in response),
'bytesDownloaded': sum(item['bytesDownloaded'] for item in response),
'downloadSpeed': 0,
'numPeers': 0,
'numSeeds': 0,
'progress': 1.0
})
elif response.get('status') == 5: # Original torrent seeding check
seed_status.append({
'gameId': game_id,
**response,
@@ -143,7 +177,15 @@ def action():
existing_downloader = downloads.get(game_id)
if url.startswith('magnet'):
if isinstance(url, list):
# Handle multiple URLs using HttpMultiLinkDownloader
if existing_downloader and isinstance(existing_downloader, HttpMultiLinkDownloader):
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
else:
http_multi_downloader = HttpMultiLinkDownloader()
downloads[game_id] = http_multi_downloader
http_multi_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
elif url.startswith('magnet'):
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
existing_downloader.start_download(url, data['save_path'])
else:

View File

@@ -70,7 +70,25 @@
"edit_game_modal_icon_resolution": "Recommended resolution: 256x256px",
"edit_game_modal_logo_resolution": "Recommended resolution: 640x360px",
"edit_game_modal_hero_resolution": "Recommended resolution: 1920x620px",
"edit_game_modal_assets": "Assets"
"edit_game_modal_assets": "Assets",
"edit_game_modal_drop_icon_image_here": "Drop icon image here",
"edit_game_modal_drop_logo_image_here": "Drop logo image here",
"edit_game_modal_drop_hero_image_here": "Drop hero image here",
"edit_game_modal_drop_to_replace_icon": "Drop to replace icon",
"edit_game_modal_drop_to_replace_logo": "Drop to replace logo",
"edit_game_modal_drop_to_replace_hero": "Drop to replace hero",
"install_decky_plugin": "Install Decky Plugin",
"update_decky_plugin": "Update Decky Plugin",
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
"install_decky_plugin_title": "Install Hydra Decky Plugin",
"install_decky_plugin_message": "This will download and install the Hydra plugin for Decky Loader. This may require elevated permissions. Continue?",
"update_decky_plugin_title": "Update Hydra Decky Plugin",
"update_decky_plugin_message": "A new version of the Hydra Decky plugin is available. Would you like to update it now?",
"decky_plugin_installed": "Decky plugin v{{version}} installed successfully",
"decky_plugin_installation_failed": "Failed to install Decky plugin: {{error}}",
"decky_plugin_installation_error": "Error installing Decky plugin: {{error}}",
"confirm": "Confirm",
"cancel": "Cancel"
},
"header": {
"search": "Search games",
@@ -160,11 +178,13 @@
"open_folder": "Open folder",
"open_download_location": "See downloaded files",
"create_shortcut": "Create desktop shortcut",
"create_shortcut_simple": "Create shortcut",
"clear": "Clear",
"remove_files": "Remove files",
"remove_from_library_title": "Are you sure?",
"remove_from_library_description": "This will remove {{game}} from your library",
"options": "Options",
"properties": "Properties",
"executable_section_title": "Executable",
"executable_section_description": "Path of the file that will be executed when \"Play\" is clicked",
"downloads_section_title": "Downloads",
@@ -178,6 +198,13 @@
"create_shortcut_success": "Shortcut created successfully",
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
"create_shortcut_error": "Error creating shortcut",
"add_to_favorites": "Add to favorites",
"remove_from_favorites": "Remove from favorites",
"failed_update_favorites": "Failed to update favorites",
"game_removed_from_library": "Game removed from library",
"failed_remove_from_library": "Failed to remove from library",
"files_removed_success": "Files removed successfully",
"failed_remove_files": "Failed to remove files",
"nsfw_content_title": "This game contains inappropriate content",
"nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?",
"allow_nsfw_content": "Continue",
@@ -185,6 +212,7 @@
"stats": "Stats",
"download_count": "Downloads",
"player_count": "Active players",
"rating_count": "Rating",
"download_error": "This download option is not available",
"download": "Download",
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
@@ -192,6 +220,39 @@
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util it's completed. If Hydra closes before completing, you will lose your progress.",
"achievements": "Achievements",
"achievements_count": "Achievements {{unlockedCount}}/{{achievementsCount}}",
"show_more": "Show more",
"show_less": "Show less",
"reviews": "Reviews",
"leave_a_review": "Leave a Review",
"write_review_placeholder": "Share your thoughts about this game...",
"sort_newest": "Newest",
"no_reviews_yet": "No reviews yet",
"be_first_to_review": "Be the first to share your thoughts about this game!",
"sort_oldest": "Oldest",
"sort_highest_score": "Highest Score",
"sort_lowest_score": "Lowest Score",
"sort_most_voted": "Most Voted",
"rating": "Rating",
"rating_stats": "Rating",
"rating_very_negative": "Very Negative",
"rating_negative": "Negative",
"rating_neutral": "Neutral",
"rating_positive": "Positive",
"rating_very_positive": "Very Positive",
"submit_review": "Submit",
"submitting": "Submitting...",
"review_submitted_successfully": "Review submitted successfully!",
"review_submission_failed": "Failed to submit review. Please try again.",
"review_cannot_be_empty": "Review text field cannot be empty.",
"review_deleted_successfully": "Review deleted successfully.",
"review_deletion_failed": "Failed to delete review. Please try again.",
"loading_reviews": "Loading reviews...",
"loading_more_reviews": "Loading more reviews...",
"load_more_reviews": "Load More Reviews",
"you_seemed_to_enjoy_this_game": "You've seemed to enjoy this game",
"would_you_recommend_this_game": "Would you like to leave a review to this game?",
"yes": "Yes",
"maybe_later": "Maybe Later",
"cloud_save": "Cloud save",
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
"backups": "Backups",
@@ -204,6 +265,7 @@
"uploading_backup": "Uploading backup…",
"no_backups": "You haven't created any backups for this game yet",
"backup_uploaded": "Backup uploaded",
"backup_failed": "Backup failed",
"backup_deleted": "Backup deleted",
"backup_restored": "Backup restored",
"see_all_achievements": "See all achievements",
@@ -286,7 +348,16 @@
"historical_keyshop": "Historical keyshop",
"language": "Language",
"caption": "Caption",
"audio": "Audio"
"audio": "Audio",
"filter_by_source": "Filter by source",
"no_repacks_found": "No sources found for this game",
"delete_review": "Delete review",
"remove_review": "Remove Review",
"delete_review_modal_title": "Are you sure you want to delete your review?",
"delete_review_modal_description": "This action cannot be undone.",
"delete_review_modal_delete_button": "Delete",
"delete_review_modal_cancel_button": "Cancel",
"vote_failed": "Failed to register your vote. Please try again."
},
"activation": {
"title": "Activate Hydra",
@@ -324,6 +395,7 @@
"stop_seeding": "Stop seeding",
"resume_seeding": "Resume seeding",
"options": "Manage",
"alldebrid_size_not_supported": "Download info for AllDebrid is not supported yet",
"extract": "Extract files",
"extracting": "Extracting files…"
},
@@ -427,12 +499,25 @@
"delete_theme_description": "This will delete the theme {{theme}}",
"cancel": "Cancel",
"appearance": "Appearance",
"debrid": "Debrid",
"debrid_description": "Debrid services are premium unrestricted downloaders that allow you to quickly download files hosted on various file hosting services, only limited by your internet speed.",
"enable_torbox": "Enable TorBox",
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
"torbox_account_linked": "TorBox account linked",
"create_real_debrid_account": "Click here if you don't have a Real-Debrid account yet",
"create_torbox_account": "Click here if you don't have a TorBox account yet",
"real_debrid_account_linked": "Real-Debrid account linked",
"enable_all_debrid": "Enable All-Debrid",
"all_debrid_description": "All-Debrid is an unrestricted downloader that allows you to quickly download files from various sources.",
"all_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to All-Debrid",
"all_debrid_account_linked": "All-Debrid account linked successfully",
"alldebrid_missing_key": "Please provide an API key",
"alldebrid_invalid_key": "Invalid API key",
"alldebrid_blocked": "Your API key is geo-blocked or IP-blocked",
"alldebrid_banned": "This account has been banned",
"alldebrid_unknown_error": "An unknown error occurred",
"alldebrid_invalid_response": "Invalid response from All-Debrid",
"alldebrid_network_error": "Network error. Please check your connection",
"name_min_length": "Theme name must be at least 3 characters long",
"import_theme": "Import theme",
"import_theme_description": "You will import {{theme}} from the theme store",
@@ -490,7 +575,8 @@
"game_card": {
"available_one": "Available",
"available_other": "Available",
"no_downloads": "No downloads available"
"no_downloads": "No downloads available",
"calculating": "Calculating"
},
"binary_not_found_modal": {
"title": "Programs not installed",
@@ -593,7 +679,10 @@
"error_adding_friend": "Could not send friend request. Please check friend code",
"friend_code_length_error": "Friend code must have 8 characters",
"game_removed_from_pinned": "Game removed from pinned",
"game_added_to_pinned": "Game added to pinned"
"game_added_to_pinned": "Game added to pinned",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Earned from positive likes on reviews"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",

View File

@@ -204,6 +204,7 @@
"uploading_backup": "Subiendo copia de seguridad…",
"no_backups": "No has creado ninguna copia de seguridad para este juego todavía",
"backup_uploaded": "Copia de seguridad subida",
"backup_failed": "Copia de seguridad fallida",
"backup_deleted": "Copia de seguridad eliminada",
"backup_restored": "Copia de seguridad restaurada",
"see_all_achievements": "Ver todos los logros",

View File

@@ -1,40 +1,131 @@
{
"language_name": "Magyar",
"app": {
"successfully_signed_in": "Sikeresen bejelentkeztél"
},
"home": {
"surprise_me": "Lepj meg",
"no_results": "Nem található"
"no_results": "Nincs találat",
"start_typing": "Kereséshez gépelj...",
"hot": "Most felkapott",
"weekly": "📅 A hét felkapott játékai",
"achievements": "🏆 Achievement támogatott"
},
"sidebar": {
"catalogue": "Katalógus",
"downloads": "Letöltések",
"settings": "Beállítások",
"my_library": "Könyvtáram",
"downloading_metadata": "{{title}} (Metadata letöltése…)",
"paused": "{{title}} (Szünet)",
"downloading_metadata": "{{title}} (metaadatai letöltése…)",
"paused": "{{title}} (Szüneteltetve)",
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése",
"home": "Főoldal",
"favorites": "Kedvenc játékok"
"queued": "A(z) {{title}} (Várakozósorban van)",
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
"sign_in": "Bejelentkezés",
"friends": "Barátok",
"need_help": "Elakadtál?",
"favorites": "Kedvenc játékok",
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
"add_custom_game_tooltip": "Saját játék hozzáadása",
"show_playable_only_tooltip": "Csak játszható játék mutatása",
"custom_game_modal": "Saját játék hozzáadása:",
"custom_game_modal_description": "Adj meg egy futtatható fájlt",
"custom_game_modal_executable_path": "Futtatható fájl",
"custom_game_modal_select_executable": "Fájl útvonala",
"custom_game_modal_title": "Játékcím",
"custom_game_modal_enter_title": "Játék elnevezése",
"custom_game_modal_browse": "Tallózás",
"custom_game_modal_cancel": "Mégse",
"custom_game_modal_add": "Játék hozzáadása",
"custom_game_modal_adding": "Játék hozzáadása...",
"custom_game_modal_success": "Saját játék sikeresen hozzáadva",
"custom_game_modal_failed": "Saját játék hozzáadása sikertelen",
"custom_game_modal_executable": "Futtatható fájl",
"edit_game_modal": "Játékmegjelenítése:",
"edit_game_modal_description": "Játékcím és vizuális elemek módosítása",
"edit_game_modal_title": "Játékcím",
"edit_game_modal_enter_title": "Játék elnevezése",
"edit_game_modal_image": "Kép",
"edit_game_modal_select_image": "Kép útvonala",
"edit_game_modal_browse": "Tallózás",
"edit_game_modal_image_preview": "Kép előnézete",
"edit_game_modal_icon": "Ikon",
"edit_game_modal_select_icon": "Ikon útvonala",
"edit_game_modal_icon_preview": "Ikon előnézete",
"edit_game_modal_logo": "Logó",
"edit_game_modal_select_logo": "Logó útvonala",
"edit_game_modal_logo_preview": "Logó előnézete",
"edit_game_modal_hero": "Borítókép",
"edit_game_modal_select_hero": "Borítókép útvonala",
"edit_game_modal_hero_preview": "Borítókép előnézete",
"edit_game_modal_cancel": "Mégse",
"edit_game_modal_update": "Frissít",
"edit_game_modal_updating": "Frissítés...",
"edit_game_modal_fill_required": "Kérlek töltsd ki az összes kötelező mezőt",
"edit_game_modal_success": "Játék megjelenés frissítése sikeres",
"edit_game_modal_failed": "Játék megjelenés frissítése sikertelen",
"edit_game_modal_image_filter": "Kép",
"edit_game_modal_icon_resolution": "Ajánlott felbontás: 256x256px",
"edit_game_modal_logo_resolution": "Ajánlott felbontás: 640x360px",
"edit_game_modal_hero_resolution": "Ajánlott felbontás: 1920x620px",
"edit_game_modal_assets": "Vizuális elemek:",
"edit_game_modal_drop_icon_image_here": "Húzd ide az ikon képét",
"edit_game_modal_drop_logo_image_here": "Húzd ide a logó képét",
"edit_game_modal_drop_hero_image_here": "Húzd ide a borítókép képét",
"edit_game_modal_drop_to_replace_icon": "Ikon kicserélése ráhúzással",
"edit_game_modal_drop_to_replace_logo": "Logó kicserélése ráhúzással",
"edit_game_modal_drop_to_replace_hero": "Borítókép kicserélése ráhúzással",
"install_decky_plugin": "Decky Plugin Telepítése",
"update_decky_plugin": "Decky Plugin Frissítése",
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
"install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint",
"install_decky_plugin_message": "Ez letölti és telepíteni fogja a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?",
"update_decky_plugin_title": "Hydra Decky Plugin Frissítése",
"update_decky_plugin_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?",
"decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve",
"decky_plugin_installation_failed": "Decky plugin telepítése sikertelen: {{error}}",
"decky_plugin_installation_error": "Decky plugin telepítése hibával járt el: {{error}}",
"confirm": "Megerősít",
"cancel": "Mégse"
},
"header": {
"search": "Keresés",
"home": "Főoldal",
"catalogue": "Katalógus",
"downloads": "Letöltések",
"search_results": "Keresési eredmények",
"settings": "Beállítások"
"search_results": "Keresési találatok",
"settings": "Beállítások",
"version_available_install": "A(z) {{version}} verzió elérhető. Kattints ide az újraindításhoz és telepítéshez.",
"version_available_download": "A(z) {{version}} verzió elérhető. A letöltéshez kattints ide."
},
"bottom_panel": {
"no_downloads_in_progress": "Nincsenek folyamatban lévő letöltések",
"no_downloads_in_progress": "Nincs folyamatban lévő letöltés",
"downloading_metadata": "{{title}} metaadatainak letöltése…",
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}"
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Telepítés befejezve",
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
},
"catalogue": {
"search": "Szűrés…",
"developers": "Fejlesztők",
"genres": "Műfajok",
"tags": "Címkék",
"publishers": "Kiadók",
"download_sources": "Letöltési források",
"result_count": "{{resultCount}} találatok",
"filter_count": "{{filterCount}} elérhető",
"clear_filters": "{{filterCount}} kiválaszott szűrő törlése"
},
"catalogue": {},
"game_details": {
"open_download_options": "Letöltési lehetőségek",
"download_options_zero": "Nincs letöltési lehetőség",
"download_options_one": "{{count}} letöltési lehetőség",
"download_options_other": "{{count}} letöltési lehetőség",
"open_download_options": "Letöltési opciók megnyitása",
"download_options_zero": "Nincs letöltési opció",
"download_options_one": "{{count}} letöltési opció",
"download_options_other": "{{count}} letöltési opció",
"updated_at": "Frissítve: {{updated_at}}",
"install": "Letöltés",
"resume": "Folytatás",
@@ -43,11 +134,13 @@
"remove": "Eltávolítás",
"space_left_on_disk": "{{space}} szabad hely a lemezen",
"eta": "Befejezés {{eta}}",
"downloading_metadata": "Metaadatok letöltése…",
"calculating_eta": "Hátralevő idő kiszámítása…",
"downloading_metadata": "Metaadat letöltése",
"filter": "Repackek szűrése",
"requirements": "Rendszerkövetelmények",
"minimum": "Minimális",
"recommended": "Ajánlott",
"paused": "Szüneteltetve",
"release_date": "Megjelenés: {{date}}",
"publisher": "Kiadta: {{publisher}}",
"hours": "óra",
@@ -55,29 +148,223 @@
"amount_hours": "{{amount}} óra",
"amount_minutes": "{{amount}} perc",
"accuracy": "{{accuracy}}% pontosság",
"add_to_library": "Hozzáadás a könyvtárhoz",
"add_to_library": "Könyvtárba helyezés",
"already_in_library": "Már könyvtárban",
"remove_from_library": "Eltávolítás a könyvtárból",
"no_downloads": "Nincs elérhető letöltés",
"play_time": "Játszva: {{amount}}",
"last_time_played": "Utoljára játszva {{period}}",
"not_played_yet": "{{title}} még nem játszottál",
"last_time_played": "Utoljára játszva: {{period}}",
"not_played_yet": "Ezzel a játékkal még nem játszottál: {{title}}",
"next_suggestion": "Következő javaslat",
"play": "Játék",
"deleting": "Telepítő törlése…",
"close": "Bezárás",
"playing_now": "Jelenleg játszva",
"playing_now": "Játékban: ",
"change": "Változtatás",
"repacks_modal_description": "Choose the repack you want to download",
"select_folder_hint": "Ahhoz, hogy megváltoztasd a helyet, hozzákell férned a",
"download_now": "Töltsd le most"
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
"select_folder_hint": "A letöltési mappát a <0>Beállítások</0> menüjében változtathatod meg",
"download_now": "Letöltés",
"no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók",
"download_path": "Letöltis hely",
"previous_screenshot": "Előző screenshot",
"next_screenshot": "Következő screenshot",
"screenshot": "Screenshot {{number}}",
"open_screenshot": "Screenshot megnyitása {{number}}",
"download_settings": "Letöltési beállítások",
"downloader": "Letöltési mód",
"select_executable": "Tallózás",
"no_executable_selected": "Nincs futtatható fájl tallózva",
"open_folder": "Mappa megnyitása",
"open_download_location": "Letöltött fájlok megtekintése",
"create_shortcut": "Asztali parancsikon létrehozása",
"create_shortcut_simple": "Parancsikon létrehozása",
"clear": "Visszavon",
"remove_files": "Fájlok eltávolítása",
"remove_from_library_title": "Biztos vagy ebben?",
"remove_from_library_description": "Ezzel eltávolítod a játékot {{game}} a könyvtáradból",
"options": "Beállítások",
"properties": "További beállítások",
"executable_section_title": "Futtatható fájl",
"executable_section_description": "A fájl amely futtatásra fog kerülni amikor a \"Játék\" lenyomásra kerül",
"downloads_section_title": "Letöltések",
"downloads_section_description": "Csekkold le a játék frissítéseit vagy más verzióit",
"danger_zone_section_title": "Veszélyzóna",
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
"download_in_progress": "Letöltés folyamatban",
"download_paused": "Letöltés szüneteltetve",
"last_downloaded_option": "Utoljára letöltött",
"create_steam_shortcut": "Steam parancsikon létrehozása",
"create_shortcut_success": "A parancsikon létrehozása sikeres",
"you_might_need_to_restart_steam": "Lehetséges hogy újrakell indítsd a Steamet hogy lásd a változást.",
"create_shortcut_error": "Hiba lépett fel létrehozás közben",
"add_to_favorites": "Kedvencekhez adás",
"remove_from_favorites": "Eltávolítás a kedvencek közül",
"failed_update_favorites": "Kedvencek frissítése sikertelen",
"game_removed_from_library": "Játék eltávolítva a könyvtárból",
"failed_remove_from_library": "Játék eltávolítása a könyvtárból sikertelen",
"files_removed_success": "Fájlok eltávolítása sikeres",
"failed_remove_files": "Fájlok eltávolítása sikertelen",
"nsfw_content_title": "Ez a játék tartalmaz nem megfelelő tartalmat",
"nsfw_content_description": "A(z) {{title}} tartalma lehetséges hogy nem megfelelő minden korosztály számára. Biztosan folytatni szeretnéd?",
"allow_nsfw_content": "Folytatás",
"refuse_nsfw_content": "Vissza",
"stats": "Statisztikák",
"download_count": "Letöltések",
"player_count": "Aktív játékosok",
"rating_count": "Értékelés",
"download_error": "Ez a letöltési opció nem elérhető",
"download": "Letöltés",
"executable_path_in_use": "Ez a futtatható fájl már használatban van a(z) \"{{game}}\" által",
"warning": "Figyelmeztetés:",
"hydra_needs_to_remain_open": "ehhez a letöltéshez, a Hydrának muszáj nyitva maradnia hogy letöltődjön. Ha a Hydra bezáródik letöltés előtt, a letöltés elveszik.",
"achievements": "Achievementek",
"achievements_count": "Achievementek {{unlockedCount}}/{{achievementsCount}}",
"show_more": "Mutass többet",
"show_less": "Mutass kevesebbet",
"reviews": "Vélemények",
"leave_a_review": "Hagyd itt a véleményed",
"write_review_placeholder": "Oszd meg a gondolataid a játékról...",
"sort_newest": "Legújabb",
"no_reviews_yet": "Még nem lett vélemény megosztva",
"be_first_to_review": "Légy az első, aki megossza a véleményét a játékról!",
"sort_oldest": "Legrégibb",
"sort_highest_score": "Legmagasabb Pontszám",
"sort_lowest_score": "Legalacsonyabb Pontszám",
"sort_most_voted": "Legszavazottabb",
"rating": "Értékelés",
"rating_stats": "Értékelés",
"rating_very_negative": "Nagyon Negatív",
"rating_negative": "Negatív",
"rating_neutral": "Átlagos",
"rating_positive": "Pozitív",
"rating_very_positive": "Nagyon Pozitív",
"submit_review": "Küldés",
"submitting": "Küldés alatt...",
"review_submitted_successfully": "Vélemény beküldve sikeresen!",
"review_submission_failed": "Vélemény beküldése sikertelen. Kérlek próbáld újra.",
"review_cannot_be_empty": "A vélemény mező nem lehet üres.",
"review_deleted_successfully": "Vélemény sikeresen törölve.",
"review_deletion_failed": "Vélemény törlése sikertelen. Kérlek próbáld újra.",
"loading_reviews": "Vélemények betöltése...",
"loading_more_reviews": "Több vélemény betöltése...",
"load_more_reviews": "Több vélemény betöltése",
"you_seemed_to_enjoy_this_game": "Úgy látszik élvezted ezt a játékot",
"would_you_recommend_this_game": "Szeretnél véleményt írni erről a játékról?",
"yes": "Igen",
"maybe_later": "Talán Később",
"cloud_save": "Mentés felhőben",
"cloud_save_description": "Mentsd el az előrehaladásod a felhőben, majd folytasd egy másik eszközön",
"backups": "Biztonsági másolatok",
"install_backup": "Telepít",
"delete_backup": "Töröl",
"create_backup": "Biztonsági másolat létrehozása",
"last_backup_date": "Utolsó biztonsági mentés {{date}}",
"no_backup_preview": "Ehhez a címhez nem található mentett játék",
"restoring_backup": "Biztonsági mentés helyreállítás: ({{progress}} kész)…",
"uploading_backup": "Biztonsági mentés feltöltése…",
"no_backups": "Még nem hoztál létre biztonsági másolatot ehhez a játékhoz",
"backup_uploaded": "Biztonsági mentés feltöltve",
"backup_failed": "Biztonsági mentés sikertelen",
"backup_deleted": "Biztonsági mentés törölve",
"backup_restored": "Biztonsági mentés helyreállítva",
"see_all_achievements": "Achievementlista megtekintése",
"sign_in_to_see_achievements": "Jelentkezz be hogy lásd az achievementjeid",
"mapping_method_automatic": "Automatikus",
"mapping_method_manual": "Kézi",
"mapping_method_label": "Térképezési módszer",
"files_automatically_mapped": "Fájlok automatikusan térképezve",
"no_backups_created": "Ehhez a játékhoz nincs biztonsági másolat létrehozva",
"manage_files": "Fájlok kezelése",
"loading_save_preview": "Mentett játék keresése…",
"wine_prefix": "Wine Prefix",
"wine_prefix_description": "A Wine környezet, amiben a játék fut",
"launch_options": "Indítási opciók",
"launch_options_description": "Indítási opciók testreszabása haladó felhasználóknak (kísérleti funkció)",
"launch_options_placeholder": "Nincs paraméter megadva",
"no_download_option_info": "Nincs elérhető információ",
"backup_deletion_failed": "Biztonsági mentés törlése sikertelen",
"max_number_of_artifacts_reached": "A játék biztonsági mentéseinek száma elérte a határt",
"achievements_not_sync": "Tekintsd meg hogyan kell szinkronizálni az achievementjeid",
"manage_files_description": "Kezeld mely fájlokról készül biztonsági másolat, és melyek állíthatók vissza",
"select_folder": "Mappa tallózása",
"backup_from": "Biztonsági másolat: {{date}}",
"automatic_backup_from": "Automatikus másolat: {{date}}",
"enable_automatic_cloud_sync": "Automatikus felhőalapú szinkronizálás engedélyezése",
"custom_backup_location_set": "Egyéni biztonsági mentési hely",
"no_directory_selected": "Nincs mappa tallózva",
"no_write_permission": "Nem lehet a mappába letölteni. Kattints ide további információért.",
"reset_achievements": "Achievementek nullázása",
"reset_achievements_description": "Ez az összes achievementet nullázza a {{game}} játékhoz",
"reset_achievements_title": "Biztos vagy ebben?",
"reset_achievements_success": "Achievementek sikeresen nullázva",
"reset_achievements_error": "Achievementek nullázása sikertelen",
"download_error_gofile_quota_exceeded": "Túllépted a Gofile havi kvótáját. Kérlek, várd meg amíg a kvóta lejár.",
"download_error_real_debrid_account_not_authorized": "A Real-Debrid fiókod nem jogosult új letöltésekre. Kérlek, ellenőrízd a fiókbeállításaidat, majd próbáld újra.",
"download_error_not_cached_on_real_debrid": "Ez a letöltés nem elérhető a Real-Debriden, és lekérdezni letöltési állapotot még nem lehet.",
"update_playtime_title": "Játékidő frissítése",
"update_playtime_description": "A(z) {{game}} játékidejének frissítése manuálisan",
"update_playtime": "Játékidő frissítése",
"update_playtime_success": "Játékidő sikeresen frissítve",
"update_playtime_error": "A Játékidőnek nem sikerült frissülnie",
"update_game_playtime": "Játékidő frissítése",
"manual_playtime_warning": "A Játékidő „Manuálisan frissített”-ként lesz megjelölve, és ez nem visszavonható.",
"manual_playtime_tooltip": "Ez a Játékidő manuálisan lett frissítve",
"download_error_not_cached_on_torbox": "Ez a letöltés nem elérhető a TorBoxon, és lekérdezni letöltési állapotot még nem lehet.",
"download_error_not_cached_on_hydra": "Ez a letöltés nem elérhető a Nimbuson.",
"game_removed_from_favorites": "Játék eltávolítva a kedvencek közül",
"game_added_to_favorites": "Játék hozzáadva a kedvencekhez",
"game_removed_from_pinned": "Játék eltávolítva a kitűzöttek közül",
"game_added_to_pinned": "Játék sikeresen kitűzve",
"automatically_extract_downloaded_files": "Automatikus kibontása a letöltött fájloknak",
"create_start_menu_shortcut": "Start menü parancsikon létrehozása",
"invalid_wine_prefix_path": "Érvénytelen Wine prefix elérési útvonal",
"invalid_wine_prefix_path_description": "Az út a Wine prefixhez érvénytelen. Ellenőrízd az elérési utat, majd próbáld újra.",
"missing_wine_prefix": "Wine prefix szükséges a biztonsági másolat létrehozásához Linux rendszeren.",
"artifact_renamed": "Biztonsági mentés sikeresen átnevezve",
"rename_artifact": "Biztonsági mentés átnevezése",
"rename_artifact_description": "Nevezd át a biztonsági másolatot egy leíróbb névre",
"artifact_name_label": "Biztonsági másolat neve",
"artifact_name_placeholder": "Adj egy nevet a biztonsági mentésnek",
"save_changes": "Változtatások mentése",
"required_field": "Ez a mező kötelező",
"max_length_field": "Ez a mező kevesebb karakter kell legyen mint {{length}}",
"freeze_backup": "Rögzítsd, hogy az automatikus biztonsági mentések ne írják felül",
"unfreeze_backup": "Leválaszt",
"backup_frozen": "Biztonsági mentés rögzítve",
"backup_unfrozen": "Biztonsági mentés leválasztva",
"backup_freeze_failed": "Biztonsági mentés rögzítése sikertelen",
"backup_freeze_failed_description": "Legalább egy szabad helyet kell hagyni az automatikus biztonsági mentéseknek",
"edit_game_modal_button": "Játékadatok testreszabása",
"game_details": "Játék leírása",
"currency_symbol": "Ft",
"currency_country": "hu",
"prices": "Árak",
"no_prices_found": "Nincsenek található árak",
"view_all_prices": "Összes ár megtekintése",
"retail_price": "Bolti ár",
"keyshop_price": "Nem hivatalos ár",
"historical_retail": "Korábbi bolti ár",
"historical_keyshop": "Korábbi nem hivatalos ár",
"language": "Nyelv",
"caption": "Felirat",
"audio": "Hang",
"filter_by_source": "Szűrés forrás szerint",
"no_repacks_found": "Nem található forrás ehhez a játékhoz",
"delete_review": "Vélemény törlése",
"remove_review": "Vélemény eltávolítása",
"delete_review_modal_title": "Biztos vagy abban hogy törölni szeretnéd a véleményed?",
"delete_review_modal_description": "Ez a lépés nem vonható vissza.",
"delete_review_modal_delete_button": "Törlés",
"delete_review_modal_cancel_button": "Mégse"
},
"activation": {
"title": "Hydra Aktiválása",
"installation_id": "Telepítési ID:",
"enter_activation_code": "Add meg az aktiválási kódodat",
"message": "Ha nem tudod, hol kérdezd meg ezt, akkor nem is kellene, hogy legyen ilyened.",
"installation_id": "Telepítési Azonosító:",
"enter_activation_code": "Írd be az aktiválási kódod",
"message": "Ha nem tudod hol kérdezz efelől, akkor nem kéne ilyened legyen.",
"activate": "Aktiválás",
"loading": "Betöltés…"
"loading": "Töltés…"
},
"downloads": {
"resume": "Folytatás",
@@ -86,46 +373,343 @@
"paused": "Szüneteltetve",
"verifying": "Ellenőrzés…",
"completed": "Befejezve",
"removed": "Nincs letöltve",
"cancel": "Mégse",
"filter": "Letöltött játékok szűrése",
"remove": "Eltávolítás",
"remove": "Eltávolít",
"downloading_metadata": "Metaadatok letöltése…",
"deleting": "Telepítő törlése…",
"delete": "Telepítő eltávolítása",
"delete_modal_title": "Biztos vagy benne?",
"delete_modal_description": "Ez eltávolít minden telepítési fájlt a számítógépedről",
"install": "Telepítés"
"delete_modal_title": "Biztos vagy ebben?",
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
"install": "Telepít",
"download_in_progress": "Folyamatban lévő",
"queued_downloads": "Várakozósoron lévő letöltések",
"downloads_completed": "Befejezett",
"queued": "Várakozási sorban",
"no_downloads_title": "Oly üres..",
"no_downloads_description": "Még nem töltöttél le semmit a Hydra segítségével, de soha nem késő elkezdeni.",
"checking_files": "Fájlok ellenőrzése…",
"seeding": "Seedelés",
"stop_seeding": "Seedelés leállítása",
"resume_seeding": "Seedelés folytatása",
"options": "Kezelés",
"alldebrid_size_not_supported": "Letöltési információ az AllDebrid-hez még nem támogatott",
"extract": "Fájlok kibontása",
"extracting": "Fájlok kibontása…"
},
"settings": {
"downloads_path": "Letöltések helye",
"downloads_path": "Letöltési útvonalak",
"change": "Frissítés",
"notifications": "Értesítések",
"enable_download_notifications": "Amikor egy letöltés befejeződik",
"enable_repack_list_notifications": "Amikor új repack kerül feltöltésre",
"real_debrid_api_token_label": "Real-Debrid API token",
"quit_app_instead_hiding": "Hydra elrejtésének tiltása bezáráskor",
"launch_with_system": "Hydra automatikus indítása rendszer indításakor",
"general": "Általános",
"behavior": "Működés",
"download_sources": "Letöltési források",
"language": "Nyelv",
"api_token": "API Token",
"enable_real_debrid": "Real-Debrid Bekapcsolása",
"real_debrid_description": "A Real-Debrid egy korlátozásmentes letöltőprogram, ami lehetővé teszi a fájlok gyors letöltését, és csak az internetkapcsolat sebessége szab határt.",
"debrid_invalid_token": "Érvénytelen API token",
"debrid_api_token_hint": "Az API tokened <0>itt</0> található",
"real_debrid_free_account_error": "Ez a fiók: \"{{username}}\" egy ingyenes fiók. Kérlek iratkozz fel a Real-Debridre",
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése",
"changes_saved": "Változtatások sikeresen mentve",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"validate_download_source": "Érvényesítés",
"remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása",
"download_count_zero": "Nincs letöltési opció",
"download_count_one": "{{countFormatted}} letöltési opció",
"download_count_other": "{{countFormatted}} letöltési opció",
"download_source_url": "URL forrás:",
"add_download_source_description": "Helyezd be a .json fájl URL-jét",
"download_source_up_to_date": "Naprakész",
"download_source_errored": "Hiba történt",
"sync_download_sources": "Források szinkronizálása",
"removed_download_source": "Letöltési forrás eltávolítva",
"removed_download_sources": "Letöltési források eltávolítva",
"cancel_button_confirmation_delete_all_sources": "Nem",
"confirm_button_confirmation_delete_all_sources": "Igen, törölj mindent",
"description_confirmation_delete_all_sources": "Törölni fog minden letöltési forrást",
"title_confirmation_delete_all_sources": "Törölje az összes letöltési forrást",
"removed_download_sources": "Betűtípusok eltávolítva",
"button_delete_all_sources": "Távolítsa el az összes letöltési forrást",
"enable_repack_list_notifications": "Amikor egy új repack hozzáadásra kerül"
"title_confirmation_delete_all_sources": "Az összes letöltési forrás törlése",
"description_confirmation_delete_all_sources": "Az összes letöltési forrást törölni fogod ezáltal",
"button_delete_all_sources": "Összes eltávolítása",
"added_download_source": "Letöltési forrás hozzáadva",
"download_sources_synced": "Az összes letöltési forrás szinkronizálva",
"insert_valid_json_url": "Adj meg egy érvényes JSON url-t",
"found_download_option_zero": "Nincs letöltési opció",
"found_download_option_one": "{{countFormatted}} Letöltési opció találva",
"found_download_option_other": "{{countFormatted}} Letöltési opciók találva",
"import": "Importálás",
"public": "Publikus",
"private": "Privát",
"friends_only": "Csak barátok",
"privacy": "Adatvédelem",
"profile_visibility": "Profil láthatósága",
"profile_visibility_description": "Válaszd ki, ki láthatja a profilod és könyvtárad",
"required_field": "Ez a mező kötelező",
"source_already_exists": "Ez a forrás már használatban",
"must_be_valid_url": "A forrás egy érvényes URL kell legyen",
"blocked_users": "Letiltott felhasználók",
"user_unblocked": "Felhasználó letiltva",
"enable_achievement_notifications": "Amikor egy achievement feloldódik",
"launch_minimized": "Hydra indítása minimalizálva",
"disable_nsfw_alert": "NSFW figyelmeztetés kikapcsolása",
"seed_after_download_complete": "Letöltés utáni seedelés",
"show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt",
"account": "Fiók",
"no_users_blocked": "Nincsenek letiltott felhasználóid",
"subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}",
"manage_subscription": "Előfizetés kezelése",
"update_email": "Email változtatása",
"update_password": "Jelszó változtatása",
"current_email": "Jelenlegi email:",
"no_email_account": "Még nincs beállított emailed",
"account_data_updated_successfully": "Fiókadatok változtatása sikeres",
"renew_subscription": "Hydra Cloud Megújítása",
"subscription_expired_at": "Az előfizetésed lejárt, ekkor: {{date}}",
"no_subscription": "Élvezd a Hydrát a lehető legjobb módon",
"become_subscriber": "Légy Hydra Cloud tag",
"subscription_renew_cancelled": "Automatikus megújítás kikapcsolva",
"subscription_renews_on": "Az előfizetésed megújul, ekkor: {{date}}",
"bill_sent_until": "A következő számlát ezen napon küldjük",
"no_themes": "Úgy látszik nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.",
"editor_tab_code": "Code",
"editor_tab_info": "Info",
"editor_tab_save": "Mentés",
"web_store": "Webáruház",
"clear_themes": "Törlés",
"create_theme": "Létrehozás",
"create_theme_modal_title": "Egyéni téma létrehozása",
"create_theme_modal_description": "Hozz létre egy új témát, hogy testreszabhasd a Hydrát ahogy szeretnéd",
"theme_name": "Téma neve",
"insert_theme_name": "Adj a témádnak nevet",
"set_theme": "Téma beállítása",
"unset_theme": "Téma visszavonása",
"delete_theme": "Téma törlése",
"edit_theme": "Téma szerkesztése",
"delete_all_themes": "Összes téma törlése",
"delete_all_themes_description": "Ez törölni fogja az összes témádat",
"delete_theme_description": "Ez törölni fogja a(z) {{theme}} témát",
"cancel": "Mégsem",
"appearance": "Megjelenés",
"debrid": "Debrid",
"debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, csak az internet sebességed szab határt.",
"enable_torbox": "TorBox bekapcsolása",
"torbox_description": "A TorBox egy olyan premium seedbox szolgáltatás, amely még a piacon elérhető legjobb szerverekkel is felveszi a versenyt.",
"torbox_account_linked": "TorBox fiók összekapcsolva",
"create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod",
"create_torbox_account": "Kattints ide ha még nincs TorBox fiókod",
"real_debrid_account_linked": "Real-Debrid fiók összekapcsolva",
"enable_all_debrid": "All-Debrid bekapcsolása",
"all_debrid_description": "Az All-Debrid egy korlátozásmentes letöltőprogram, ami lehetővé teszi a fájlok gyors letöltését különböző forrásokból.",
"all_debrid_free_account_error": "Ez a fiók: \"{{username}}\" egy ingyenes fiók. Kérlek iratkozz fel az All-Debridre",
"all_debrid_account_linked": "All-Debrid fiók összekapcsolva",
"alldebrid_missing_key": "Kérlek adj meg egy API key-t",
"alldebrid_invalid_key": "Érvénytelen API key",
"alldebrid_blocked": "Az API key-ed Földrajzilag vagy IP-alapján van blokkolva",
"alldebrid_banned": "Ez a fiók kitiltásra került",
"alldebrid_unknown_error": "Egy ismeretlen hiba történt",
"alldebrid_invalid_response": "Érvénytelen válasz az All-Debrid felől",
"alldebrid_network_error": "Hálózati hiba. Ellenőrízd az internetkapcsolatod",
"name_min_length": "A téma neve legalább 3 karakter hosszú legyen",
"import_theme": "Téma importálása",
"import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}",
"error_importing_theme": "Hiba lépett fel a téma importálása közben",
"theme_imported": "Téma sikeresen importálva",
"enable_friend_request_notifications": "Amikor ismerősnek jelölnek",
"enable_auto_install": "Frissítések letöltése automatikusan",
"common_redist": "Alapvető Segédprogramok",
"common_redist_description": "Egyes játékok futtatásához alapvető segédprogram fájlok szükségesek. A problémák elkerülése képpen ajánlott telepíteni őket.",
"install_common_redist": "Telepítés",
"installing_common_redist": "Telepítés alatt…",
"show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése",
"extract_files_by_default": "Fájlok kicsomagolása letöltés után",
"enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése",
"achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése",
"top-left": "Bal felső sarok",
"top-center": "Felső közép",
"top-right": "Jobb felső sarok",
"bottom-left": "Bal alsó sarok",
"bottom-center": "Alsó közép",
"bottom-right": "Jobb alsó sarok",
"enable_achievement_custom_notifications": "Egyéni achievement-értesítések bekapcsolása",
"alignment": "Igazítás",
"variation": "Variáció",
"default": "Alapértelmezett",
"rare": "Ritka",
"platinum": "Platina",
"hidden": "Rejtett",
"test_notification": "Értesítés tesztelése",
"notification_preview": "Achievement Értesítés Előnézete",
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot"
},
"notifications": {
"download_complete": "Letöltés befejeződött",
"game_ready_to_install": "{{title}} telepítésre kész",
"download_complete": "Letöltés befejezve",
"game_ready_to_install": "A(z) {{title}} telepítésre kész",
"repack_list_updated": "Repack lista frissítve",
"repack_count_one": "{{count}} repack hozzáadva",
"repack_count_other": "{{count}} repack hozzáadva"
"repack_count_other": "{{count}} repack hozzáadva",
"new_update_available": "A(z) {{version}} verzió elérhető",
"restart_to_install_update": "Indítsd újra a Hydrát a frissítés telepítéséhez",
"notification_achievement_unlocked_title": "Achievement feloldva: {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} és további {{count}} feloldva",
"new_friend_request_description": "{{displayName}} küldött neked egy barátfelkérést",
"new_friend_request_title": "Új barátfelkérés",
"extraction_complete": "Kicsomagolás befejezve",
"game_extracted": "{{title}} sikeresen kicsomagolva",
"friend_started_playing_game": "{{displayName}} játszani kezdett",
"test_achievement_notification_title": "Ez egy teszt értesítés",
"test_achievement_notification_description": "Elég menő, mi?"
},
"system_tray": {
"open": "Hydra megnyitása",
"quit": "Kilépés"
},
"game_card": {
"no_downloads": "Nincs elérhető letöltés"
"available_one": "Elérhető",
"available_other": "Elérhető",
"no_downloads": "Nincs elérhető letöltés",
"calculating": "Feldolgozás"
},
"binary_not_found_modal": {
"title": "A programok nincsenek telepítve",
"description": "A Wine vagy a Lutris végrehajtható fájljai nem találhatók a rendszereden",
"instructions": "Ellenőrizd a megfelelő telepítési módot bármelyiküknek a Linux disztribúciódon, hogy a játék normálisan fusson"
"description": "Wine vagy Lutris futtatható fájlok nem találhatók a rendszereden",
"instructions": "Ellenőrízd hogy melyiket kell helyesen telepíteni a Linux disztribúciódra, hogy a játék megfelelően fusson"
},
"modal": {
"close": "Bezárás gomb"
},
"forms": {
"toggle_password_visibility": "Jelszó láthatóságának állítása"
},
"user_profile": {
"amount_hours": "{{amount}} óra",
"amount_minutes": "{{amount}} perc",
"amount_hours_short": "{{amount}}ó",
"amount_minutes_short": "{{amount}}p",
"last_time_played": "Utoljára játszva {{period}}",
"activity": "Legutóbbi tevékenység",
"library": "Könyvtár",
"pinned": "Kitűzve",
"achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva",
"playtime": "Játszottidő",
"total_play_time": "Teljes játszottidő",
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve",
"no_recent_activity_title": "Hmmm… itt semmi sincs",
"no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!",
"display_name": "Profilnév",
"saving": "Mentésben",
"save": "Mentés",
"edit_profile": "Profil Szerkesztése",
"saved_successfully": "Sikeresen elmentve",
"try_again": "Kérlek, próbálkozz újra",
"sign_out_modal_title": "Biztos vagy ebben?",
"cancel": "Mégsem",
"successfully_signed_out": "Sikeresen kijelentkezve",
"sign_out": "Kijelentkezés",
"playing_for": "Játékban: {{amount}}",
"sign_out_modal_text": "A könyvtár a jelenlegi fiókodhoz van csatolva. Kijelentkezéskor a könyvtár többé nem lesz látható, és az eddigi előrehaladás nem lesz mentve. Folytatod a kijelentkezést?",
"add_friends": "Barát bejelölés",
"add": "Elküld",
"friend_code": "Barát kód",
"see_profile": "Profil megtekintése",
"sending": "Küldés..",
"friend_request_sent": "Barátfelkérés elküldve",
"friends": "Barátok",
"friends_list": "Barát lista",
"user_not_found": "Felhasználó nem találva",
"block_user": "Felhasználó letiltása",
"add_friend": "Barát bejelölése",
"request_sent": "Kérés elküldve",
"request_received": "Barátfelkérést kaptál",
"accept_request": "Kérés elfogadása",
"ignore_request": "Kérés ignorálása",
"cancel_request": "Kérés visszavonása",
"undo_friendship": "Barát eltávolítása",
"request_accepted": "Barátfelkérés elfogadva",
"user_blocked_successfully": "Felhasználó sikeresen letiltva",
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
"blocked_users": "Letiltott felhasználók",
"unblock": "Tiltás feloldása",
"no_friends_added": "Nincs bejelölt barátod",
"pending": "Függőben",
"no_pending_invites": "Nincs függőben lévő barátfelkérésed",
"no_blocked_users": "Nincs letiltott felhasználó",
"friend_code_copied": "Barát kód kimásolva",
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások</0> menüjébe",
"locked_profile": "Ez a profil privát",
"image_process_failure": "Hiba a kép feldolgozása közben",
"required_field": "Ez a mező kötelező",
"displayname_min_length": "A megjelenített névnek legalább 3 karakter hosszúnak kell lennie",
"displayname_max_length": "A megjelenített név hossza legfeljebb 50 karakter lehet",
"report_profile": "Profil bejelentése",
"report_reason": "Miért jelented ezt a profilt?",
"report_description": "További információ",
"report_description_placeholder": "További információ",
"report": "Bejelentés",
"report_reason_hate": "Gyűlöletbeszéd",
"report_reason_sexual_content": "Szexuális tartalom",
"report_reason_violence": "Fenyegető",
"report_reason_spam": "Spam",
"report_reason_other": "Egyéb",
"profile_reported": "Profil bejelentve",
"your_friend_code": "A barát kódod:",
"upload_banner": "Borítókép feltöltés",
"uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve",
"stats": "Statisztikák",
"achievements": "achievementek",
"games": "Játékok",
"top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "A rangsor hetente frissül.",
"playing": "Játékban: {{game}}",
"achievements_unlocked": "Achievementek feloldva",
"earned_points": "Megszerzett pontok",
"show_achievements_on_profile": "Mutasd az achievementjeid a profilodon",
"show_points_on_profile": "Mutasd a megszerzett pontjaid a profilodon",
"error_adding_friend": "Hiba, barátfelkérés sikertelen. Kérlek ellenőrízd a barát kódot",
"friend_code_length_error": "A barát kódnak 8 karakterből kell állnia",
"game_removed_from_pinned": "Játék eltávolítva a kitűzöttek közül",
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Pozitív értékelésekre kapott pontok alapján"
},
"achievement": {
"achievement_unlocked": "Achievement feloldva",
"user_achievements": "{{displayName}} achievementjei",
"your_achievements": "A te achievementjeid",
"unlocked_at": "Feloldva: {{date}}",
"subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges",
"new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek",
"achievements_unlocked_for_game": "{{achievementCount}} új achievement feloldva itt: {{gameTitle}}",
"hidden_achievement_tooltip": "Ez egy rejtett achievement",
"achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el",
"earned_points": "Megszerzett pontok:",
"available_points": "Elérhető pontok:",
"how_to_earn_achievements_points": "Hogy lehet elérni achievement pontokat?"
},
"hydra_cloud": {
"subscription_tour_title": "Hydra Cloud Előfizetés",
"subscribe_now": "Előfizetés",
"cloud_saving": "Felhőmentés",
"cloud_achievements": "Mentsd az achievementjeid el a felhőben",
"animated_profile_picture": "Animált profilkép",
"premium_support": "Premium Támogatás",
"show_and_compare_achievements": "Jelenítsd és hasonlítsd az elért achievementjeid másokéhoz",
"animated_profile_banner": "Animált profil borítókép",
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!",
"learn_more": "Tudj meg többet",
"debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal"
}
}

View File

@@ -97,7 +97,6 @@
"open_download_location": "Lihat file yang diunduh",
"create_shortcut": "Buat pintasan desktop",
"remove_files": "Hapus file",
"remove_from_library_title": "Apa kamu yakin?",
"remove_from_library_description": "Ini akan menghapus {{game}} dari perpustakaan kamu",
"options": "Opsi",
"executable_section_title": "Eksekusi",

View File

@@ -27,21 +27,68 @@
"friends": "Amigos",
"need_help": "Precisa de ajuda?",
"favorites": "Favoritos",
"playable_button_title": "Mostrar apenas jogos que você pode jogar agora",
"add_custom_game_tooltip": "Adicionar jogo personalizado",
"show_playable_only_tooltip": "Mostrar Apenas Jogáveis",
"custom_game_modal": "Adicionar jogo personalizado",
"custom_game_modal_description": "Adicione um jogo personalizado à sua biblioteca selecionando um arquivo executável",
"custom_game_modal_executable_path": "Caminho do Executável",
"custom_game_modal_select_executable": "Selecionar arquivo executável",
"custom_game_modal_title": "Título",
"custom_game_modal_enter_title": "Insira o título",
"edit_game_modal_title": "Título",
"playable_button_title": "",
"custom_game_modal_add": "Adicionar Jogo",
"custom_game_modal_adding": "Adicionando...",
"custom_game_modal_browse": "Buscar",
"custom_game_modal_cancel": "Cancelar",
"edit_game_modal_assets": "Imagens",
"edit_game_modal_icon": "Ícone",
"edit_game_modal_browse": "Buscar",
"edit_game_modal_cancel": "Cancelar",
"custom_game_modal_add": "Adicionar Jogo",
"custom_game_modal_adding": "Adicionando...",
"custom_game_modal_success": "Jogo personalizado adicionado com sucesso",
"custom_game_modal_failed": "Falha ao adicionar jogo personalizado",
"custom_game_modal_executable": "Executável",
"edit_game_modal": "Personalizar detalhes",
"edit_game_modal_description": "Personalize os recursos e detalhes do jogo",
"edit_game_modal_enter_title": "Insira o título",
"edit_game_modal_image": "Imagem",
"edit_game_modal_select_image": "Selecionar imagem",
"edit_game_modal_browse": "Buscar",
"edit_game_modal_image_preview": "Visualização da imagem",
"edit_game_modal_icon": "Ícone",
"edit_game_modal_select_icon": "Selecionar ícone",
"edit_game_modal_icon_preview": "Visualização do ícone",
"edit_game_modal_logo": "Logo",
"edit_game_modal": "Personalizar detalhes"
"edit_game_modal_select_logo": "Selecionar logo",
"edit_game_modal_logo_preview": "Visualização do logo",
"edit_game_modal_hero": "Hero da Biblioteca",
"edit_game_modal_select_hero": "Selecionar imagem hero da biblioteca",
"edit_game_modal_hero_preview": "Visualização da imagem hero da biblioteca",
"edit_game_modal_cancel": "Cancelar",
"edit_game_modal_update": "Atualizar",
"edit_game_modal_updating": "Atualizando...",
"edit_game_modal_fill_required": "Por favor, preencha todos os campos obrigatórios",
"edit_game_modal_success": "Recursos atualizados com sucesso",
"edit_game_modal_failed": "Falha ao atualizar recursos",
"edit_game_modal_image_filter": "Imagem",
"edit_game_modal_icon_resolution": "Resolução recomendada: 256x256px",
"edit_game_modal_logo_resolution": "Resolução recomendada: 640x360px",
"edit_game_modal_hero_resolution": "Resolução recomendada: 1920x620px",
"edit_game_modal_assets": "Imagens",
"edit_game_modal_drop_icon_image_here": "Solte a imagem do ícone aqui",
"edit_game_modal_drop_logo_image_here": "Solte a imagem do logo aqui",
"edit_game_modal_drop_hero_image_here": "Solte a imagem hero aqui",
"edit_game_modal_drop_to_replace_icon": "Solte para substituir o ícone",
"edit_game_modal_drop_to_replace_logo": "Solte para substituir o logo",
"edit_game_modal_drop_to_replace_hero": "Solte para substituir o hero",
"install_decky_plugin": "Instalar Plugin Decky",
"update_decky_plugin": "Atualizar Plugin Decky",
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
"install_decky_plugin_title": "Instalar Plugin Hydra Decky",
"install_decky_plugin_message": "Isso irá baixar e instalar o plugin Hydra para Decky Loader. Pode ser necessário permissões elevadas. Continuar?",
"update_decky_plugin_title": "Atualizar Plugin Hydra Decky",
"update_decky_plugin_message": "Uma nova versão do plugin Hydra Decky está disponível. Gostaria de atualizar agora?",
"decky_plugin_installed": "Plugin Decky v{{version}} instalado com sucesso",
"decky_plugin_installation_failed": "Falha ao instalar plugin Decky: {{error}}",
"decky_plugin_installation_error": "Erro ao instalar plugin Decky: {{error}}",
"confirm": "Confirmar",
"cancel": "Cancelar"
},
"header": {
"search": "Buscar jogos",
@@ -120,8 +167,10 @@
"open_folder": "Abrir pasta",
"open_download_location": "Ver arquivos baixados",
"create_shortcut": "Criar atalho na área de trabalho",
"create_shortcut_simple": "Criar atalho",
"remove_files": "Remover arquivos",
"options": "Gerenciar",
"properties": "Propriedades",
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
"remove_from_library_title": "Tem certeza?",
"executable_section_title": "Executável",
@@ -163,6 +212,7 @@
"uploading_backup": "Criando backup…",
"no_backups": "Você ainda não fez nenhum backup deste jogo",
"backup_uploaded": "Backup criado",
"backup_failed": "Falha no backup",
"backup_deleted": "Backup apagado",
"backup_restored": "Backup restaurado",
"see_all_achievements": "Ver todas as conquistas",
@@ -204,6 +254,13 @@
"download_error_not_cached_on_hydra": "Este download não está disponível no Nimbus.",
"game_removed_from_favorites": "Jogo removido dos favoritos",
"game_added_to_favorites": "Jogo adicionado aos favoritos",
"add_to_favorites": "Adicionar aos favoritos",
"remove_from_favorites": "Remover dos favoritos",
"failed_update_favorites": "Falha ao atualizar favoritos",
"game_removed_from_library": "Jogo removido da biblioteca",
"failed_remove_from_library": "Falha ao remover da biblioteca",
"files_removed_success": "Arquivos removidos com sucesso",
"failed_remove_files": "Falha ao remover arquivos",
"automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados",
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
"invalid_wine_prefix_path": "Caminho do prefixo Wine inválido",
@@ -235,6 +292,8 @@
"language": "Idioma",
"caption": "Legenda",
"audio": "Áudio",
"filter_by_source": "Filtrar por fonte",
"no_repacks_found": "Nenhuma fonte encontrada para este jogo",
"edit_game_modal_button": "Alterar detalhes do jogo",
"game_added_to_pinned": "Jogo adicionado aos fixados",
"game_removed_from_pinned": "Jogo removido dos fixados",
@@ -245,7 +304,48 @@
"update_playtime": "Modificar tempo de jogo",
"update_playtime_description": "Atualizar manualmente o tempo de jogo de {{game}}",
"update_playtime_error": "Falha ao atualizar tempo de jogo",
"update_playtime_title": "Atualizar tempo de jogo"
"update_playtime_title": "Atualizar tempo de jogo",
"update_playtime_success": "Tempo de jogo atualizado com sucesso",
"show_more": "Mostrar mais",
"show_less": "Mostrar menos",
"reviews": "Avaliações",
"leave_a_review": "Deixar uma Avaliação",
"write_review_placeholder": "Compartilhe seus pensamentos sobre este jogo...",
"sort_newest": "Mais Recentes",
"sort_oldest": "Mais Antigas",
"sort_highest_score": "Maior Nota",
"sort_lowest_score": "Menor Nota",
"sort_most_voted": "Mais Votadas",
"no_reviews_yet": "Ainda não há avaliações",
"be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!",
"rating": "Avaliação",
"rating_stats": "Avaliação",
"rating_very_negative": "Muito Negativo",
"rating_negative": "Negativo",
"rating_neutral": "Neutro",
"rating_positive": "Positivo",
"rating_very_positive": "Muito Positivo",
"submit_review": "Enviar",
"submitting": "Enviando...",
"review_submitted_successfully": "Avaliação enviada com sucesso!",
"review_submission_failed": "Falha ao enviar avaliação. Por favor, tente novamente.",
"review_cannot_be_empty": "O campo de texto da avaliação não pode estar vazio.",
"review_deleted_successfully": "Avaliação excluída com sucesso.",
"review_deletion_failed": "Falha ao excluir avaliação. Por favor, tente novamente.",
"loading_reviews": "Carregando avaliações...",
"loading_more_reviews": "Carregando mais avaliações...",
"load_more_reviews": "Carregar Mais Avaliações",
"you_seemed_to_enjoy_this_game": "Parece que você gostou deste jogo",
"would_you_recommend_this_game": "Gostaria de deixar uma avaliação para este jogo?",
"yes": "Sim",
"maybe_later": "Talvez Mais Tarde",
"delete_review": "Excluir avaliação",
"remove_review": "Remover Avaliação",
"delete_review_modal_title": "Tem certeza de que deseja excluir sua avaliação?",
"delete_review_modal_description": "Esta ação não pode ser desfeita.",
"delete_review_modal_delete_button": "Excluir",
"delete_review_modal_cancel_button": "Cancelar",
"rating_count": "Avaliação"
},
"activation": {
"title": "Ativação",
@@ -283,6 +383,7 @@
"stop_seeding": "Parar de semear",
"resume_seeding": "Semear",
"options": "Gerenciar",
"alldebrid_size_not_supported": "Informações de download para AllDebrid ainda não são suportadas",
"extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…"
},
@@ -367,6 +468,8 @@
"subscription_renews_on": "Sua assinatura renova dia {{date}}",
"bill_sent_until": "Sua próxima cobrança será enviada até esse dia",
"no_themes": "Parece que você ainda não tem nenhum tema. Não se preocupe, clique aqui para criar sua primeira obra de arte.",
"editor_tab_code": "Código",
"editor_tab_info": "Info",
"editor_tab_save": "Salvar",
"web_store": "Loja de temas",
"clear_themes": "Limpar",
@@ -384,12 +487,25 @@
"delete_theme_description": "Isso irá deletar o tema {{theme}}",
"cancel": "Cancelar",
"appearance": "Aparência",
"debrid": "Debrid",
"debrid_description": "Serviços Debrid são downloaders premium sem restrições que permitem baixar rapidamente arquivos hospedados em vários serviços de hospedagem de arquivos, limitados apenas pela sua velocidade de internet.",
"enable_torbox": "Habilitar TorBox",
"torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
"torbox_account_linked": "Conta do TorBox vinculada",
"create_real_debrid_account": "Clique aqui se você ainda não tem uma conta do Real-Debrid",
"create_torbox_account": "Clique aqui se você ainda não tem uma conta do TorBox",
"real_debrid_account_linked": "Conta Real-Debrid associada",
"enable_all_debrid": "Habilitar All-Debrid",
"all_debrid_description": "All-Debrid é um downloader sem restrições que permite baixar rapidamente arquivos de várias fontes.",
"all_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine o All-Debrid",
"all_debrid_account_linked": "Conta All-Debrid vinculada com sucesso",
"alldebrid_missing_key": "Por favor, forneça uma chave de API",
"alldebrid_invalid_key": "Chave de API inválida",
"alldebrid_blocked": "Sua chave de API está bloqueada por geolocalização ou IP",
"alldebrid_banned": "Esta conta foi banida",
"alldebrid_unknown_error": "Ocorreu um erro desconhecido",
"alldebrid_invalid_response": "Resposta inválida do All-Debrid",
"alldebrid_network_error": "Erro de rede. Por favor, verifique sua conexão",
"name_min_length": "O nome do tema deve ter pelo menos 3 caracteres",
"import_theme": "Importar tema",
"import_theme_description": "Você irá importar {{theme}} da loja de temas",
@@ -420,8 +536,7 @@
"hidden": "Oculta",
"test_notification": "Testar notificação",
"notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"editor_tab_code": "Código"
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo"
},
"notifications": {
"download_complete": "Download concluído",
@@ -437,7 +552,9 @@
"game_extracted": "{{title}} extraído com sucesso",
"friend_started_playing_game": "{{displayName}} começou a jogar",
"test_achievement_notification_title": "Esta é uma notificação de teste",
"test_achievement_notification_description": "Bem legal, né?"
"test_achievement_notification_description": "Bem legal, né?",
"notification_achievement_unlocked_title": "Conquista desbloqueada para {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} e outras {{count}} foram desbloqueadas"
},
"system_tray": {
"open": "Abrir Hydra",
@@ -446,7 +563,8 @@
"game_card": {
"available_one": "Disponível",
"available_other": "Disponíveis",
"no_downloads": "Sem downloads disponíveis"
"no_downloads": "Sem downloads disponíveis",
"calculating": "Calculando"
},
"binary_not_found_modal": {
"title": "Programas não instalados",
@@ -555,10 +673,15 @@
"playtime": "Tempo de jogo",
"played_recently": "Jogado recentemente",
"pinned": "Fixado",
"amount_minutes_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"amount_hours_short": "{{amount}}h",
"game_added_to_pinned": "Jogo adicionado aos fixados",
"achievements_earned": "Conquistas recebidas"
"game_removed_from_pinned": "Jogo removido dos fixados",
"achievements_earned": "Conquistas recebidas",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",

View File

@@ -142,6 +142,7 @@
"uploading_backup": "A criar backup…",
"no_backups": "Ainda não fizeste nenhum backup deste jogo",
"backup_uploaded": "Backup criado",
"backup_failed": "Falha ao criar backup",
"backup_deleted": "Backup apagado",
"backup_restored": "Backup restaurado",
"see_all_achievements": "Ver todas as conquistas",

View File

@@ -135,7 +135,11 @@
"real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid",
"debrid_linked_message": "Contul \"{{username}}\" a fost legat",
"save_changes": "Salvează modificările",
"changes_saved": "Modificările au fost salvate cu succes"
"changes_saved": "Modificările au fost salvate cu succes",
"enable_all_debrid": "Activează All-Debrid",
"all_debrid_description": "All-Debrid este un descărcător fără restricții care îți permite să descarci fișiere din diverse surse.",
"all_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la All-Debrid",
"all_debrid_account_linked": "Contul All-Debrid a fost conectat cu succes"
},
"notifications": {
"download_complete": "Descărcare completă",

View File

@@ -6,8 +6,8 @@
"home": {
"surprise_me": "Удиви меня",
"no_results": "Ничего не найдено",
"hot": "Сейчас популярно",
"start_typing": "Начинаю вводить текст...",
"hot": "Сейчас популярно",
"weekly": "📅 Лучшие игры недели",
"achievements": "🏆 Игры с достижениями"
},
@@ -28,6 +28,8 @@
"need_help": "Нужна помощь?",
"favorites": "Избранное",
"playable_button_title": "Показать только установленные игры.",
"add_custom_game_tooltip": "Добавить пользовательскую игру",
"show_playable_only_tooltip": "Показать только доступные для игры",
"custom_game_modal": "Добавить пользовательскую игру",
"custom_game_modal_description": "Добавьте пользовательскую игру в библиотеку, выбрав исполняемый файл",
"custom_game_modal_executable_path": "Путь к исполняемому файлу",
@@ -67,7 +69,26 @@
"edit_game_modal_image_filter": "Изображение",
"edit_game_modal_icon_resolution": "Рекомендуемое разрешение: 256x256px",
"edit_game_modal_logo_resolution": "Рекомендуемое разрешение: 640x360px",
"edit_game_modal_hero_resolution": "Рекомендуемое разрешение: 1920x620px"
"edit_game_modal_hero_resolution": "Рекомендуемое разрешение: 1920x620px",
"edit_game_modal_assets": "Ресурсы",
"edit_game_modal_drop_icon_image_here": "Перетащите изображение иконки сюда",
"edit_game_modal_drop_logo_image_here": "Перетащите изображение логотипа сюда",
"edit_game_modal_drop_hero_image_here": "Перетащите изображение обложки сюда",
"edit_game_modal_drop_to_replace_icon": "Перетащите для замены иконки",
"edit_game_modal_drop_to_replace_logo": "Перетащите для замены логотипа",
"edit_game_modal_drop_to_replace_hero": "Перетащите для замены обложки",
"install_decky_plugin": "Установить плагин Decky",
"update_decky_plugin": "Обновить плагин Decky",
"decky_plugin_installed_version": "Плагин Decky (v{{version}})",
"install_decky_plugin_title": "Установить плагин Hydra Decky",
"install_decky_plugin_message": "Это загрузит и установит плагин Hydra для Decky Loader. Может потребоваться повышенные разрешения. Продолжить?",
"update_decky_plugin_title": "Обновить плагин Hydra Decky",
"update_decky_plugin_message": "Доступна новая версия плагина Hydra Decky. Хотите обновить его сейчас?",
"decky_plugin_installed": "Плагин Decky v{{version}} успешно установлен",
"decky_plugin_installation_failed": "Не удалось установить плагин Decky: {{error}}",
"decky_plugin_installation_error": "Ошибка установки плагина Decky: {{error}}",
"confirm": "Подтвердить",
"cancel": "Отмена"
},
"header": {
"search": "Поиск",
@@ -128,6 +149,7 @@
"amount_minutes": "{{amount}} минут",
"accuracy": "точность {{accuracy}}%",
"add_to_library": "Добавить в библиотеку",
"already_in_library": "Уже в библиотеке",
"remove_from_library": "Удалить из библиотеки",
"no_downloads": "Нет доступных источников",
"play_time": "Сыграно {{amount}}",
@@ -156,11 +178,13 @@
"open_folder": "Открыть папку",
"open_download_location": "Просмотреть папку загрузок",
"create_shortcut": "Создать ярлык на рабочем столе",
"create_shortcut_simple": "Создать ярлык",
"clear": "Очистить",
"remove_files": "Удалить файлы",
"remove_from_library_title": "Вы уверены?",
"remove_from_library_description": "{{game}} будет удалена из вашей библиотеки.",
"options": "Настройки",
"properties": "Свойства",
"executable_section_title": "Файл",
"executable_section_description": "Путь к файлу, который будет запущен при нажатии на \"Play\"",
"downloads_section_title": "Загрузки",
@@ -170,22 +194,71 @@
"download_in_progress": "Идёт загрузка",
"download_paused": "Загрузка приостановлена",
"last_downloaded_option": "Последний вариант загрузки",
"create_steam_shortcut": "Создать ярлык Steam",
"create_shortcut_success": "Ярлык создан",
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
"create_shortcut_error": "Не удалось создать ярлык",
"allow_nsfw_content": "Продолжить",
"download": "Скачать",
"download_count": "Загрузки",
"download_error": "Этот вариант загрузки недоступен",
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
"nsfw_content_description": "{{title}} содержит контент, который может не подходить для всех возрастов. \nВы уверены, что хотите продолжить?",
"add_to_favorites": "Добавить в избранное",
"remove_from_favorites": "Удалить из избранного",
"failed_update_favorites": "Не удалось обновить избранное",
"game_removed_from_library": "Игра удалена из библиотеки",
"failed_remove_from_library": "Не удалось удалить из библиотеки",
"files_removed_success": "Файлы успешно удалены",
"failed_remove_files": "Не удалось удалить файлы",
"nsfw_content_title": "Эта игра содержит неприемлемый контент",
"nsfw_content_description": "{{title}} содержит контент, который может не подходить для всех возрастов. \nВы уверены, что хотите продолжить?",
"allow_nsfw_content": "Продолжить",
"refuse_nsfw_content": "Назад",
"stats": "Статистика",
"download_count": "Загрузки",
"player_count": "Активные игроки",
"download_error": "Этот вариант загрузки недоступен",
"download": "Скачать",
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
"warning": "Внимание:",
"hydra_needs_to_remain_open": "Для этой загрузки Hydra должна оставаться открытой до завершения. Если Hydra закроется до завершения, вы потеряете прогресс.",
"achievements": "Достижения",
"achievements_count": "Достижения {{unlockedCount}}/{{achievementsCount}}",
"show_more": "Показать больше",
"show_less": "Показать меньше",
"reviews": "Отзывы",
"leave_a_review": "Оставить отзыв",
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые",
"no_reviews_yet": "Пока нет отзывов",
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Сначала старые",
"sort_highest_score": "Высший балл",
"sort_lowest_score": "Низший балл",
"sort_most_voted": "Самые популярные",
"rating": "Оценка",
"rating_stats": "Оценка",
"rating_very_negative": "Очень негативно",
"rating_negative": "Негативно",
"rating_neutral": "Нейтрально",
"rating_positive": "Позитивно",
"rating_very_positive": "Очень позитивно",
"submit_review": "Отправить",
"submitting": "Отправка...",
"review_submitted_successfully": "Отзыв успешно отправлен!",
"review_submission_failed": "Не удалось отправить отзыв. Пожалуйста, попробуйте снова.",
"review_cannot_be_empty": "Текстовое поле отзыва не может быть пустым.",
"review_deleted_successfully": "Отзыв успешно удален.",
"review_deletion_failed": "Не удалось удалить отзыв. Пожалуйста, попробуйте снова.",
"loading_reviews": "Загрузка отзывов...",
"loading_more_reviews": "Загрузка дополнительных отзывов...",
"load_more_reviews": "Загрузить больше отзывов",
"you_seemed_to_enjoy_this_game": "Похоже, вам понравилась эта игра",
"would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?",
"yes": "Да",
"maybe_later": "Возможно позже",
"rating_count": "Оценка",
"delete_review": "Удалить отзыв",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"cloud_save": "Облачное сохранение",
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
"backups": "Резервные копии",
@@ -198,6 +271,7 @@
"uploading_backup": "Загрузка резервной копии…",
"no_backups": "Вы еще не создали резервных копий для этой игры",
"backup_uploaded": "Резервная копия загружена",
"backup_failed": "Ошибка резервного копирования",
"backup_deleted": "Резервная копия удалена",
"backup_restored": "Резервная копия восстановлена",
"see_all_achievements": "Просмотреть все достижения",
@@ -237,26 +311,29 @@
"update_playtime_title": "Обновить время игры",
"update_playtime_description": "Вручную обновите время игры для {{game}}",
"update_playtime": "Обновить время игры",
"update_playtime_success": "Время игры успешно обновлено",
"update_playtime_error": "Не удалось обновить время игры",
"update_game_playtime": "Обновить время игры",
"manual_playtime_warning": "Ваши часы будут отмечены как обновленные вручную. Это действие нельзя отменить.",
"manual_playtime_tooltip": "Это время игры было обновлено вручную",
"download_error_not_cached_on_torbox": "Эта загрузка недоступна на TorBox, и получить статус загрузки с TorBox пока невозможно.",
"game_added_to_favorites": "Игра добавлена в избранное",
"download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus.",
"game_removed_from_favorites": "Игра удалена из избранного",
"game_added_to_favorites": "Игра добавлена в избранное",
"game_removed_from_pinned": "Игра удалена из закрепленных",
"game_added_to_pinned": "Игра добавлена в закрепленные",
"automatically_extract_downloaded_files": "Автоматическая распаковка загруженных файлов",
"create_steam_shortcut": "Создать ярлык Steam",
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
"create_start_menu_shortcut": "Создать ярлык в меню «Пуск»",
"invalid_wine_prefix_path": "Недопустимый путь префикса Wine",
"invalid_wine_prefix_path_description": "Путь к префиксу Wine недействителен. Пожалуйста, проверьте путь и попробуйте снова.",
"missing_wine_prefix": "Префикс Wine необходим для создания резервной копии в Linux",
"download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus.",
"update_playtime_success": "Время игры успешно обновлено",
"update_playtime_error": "Не удалось обновить время игры",
"manual_playtime_warning": "Ваши часы будут отмечены как обновленные вручную. Это действие нельзя отменить.",
"artifact_renamed": "Резервная копия успешно переименована",
"rename_artifact": "Переименовать резервную копию",
"rename_artifact_description": "Переименуйте резервную копию, присвоив ей более описательное имя.",
"artifact_name_label": "Название резервной копии",
"artifact_name_placeholder": "Введите название для резервной копии",
"save_changes": "Сохранить изменения",
"required_field": "Это поле обязательно к заполнению",
"max_length_field": "Это поле должно содержать менее {{length}} символов",
"freeze_backup": "Закрепить, чтобы она не была перезаписана автоматическими резервными копиями",
"unfreeze_backup": "Открепить",
@@ -264,7 +341,22 @@
"backup_unfrozen": "Резервная копия откреплена",
"backup_freeze_failed": "Не удалось закрепить резервную копию",
"backup_freeze_failed_description": "Вы должны оставить как минимум один свободный слот для автоматических резервных копий",
"manual_playtime_tooltip": "Это время игры было обновлено вручную"
"edit_game_modal_button": "Изменить детали игры",
"game_details": "Детали игры",
"currency_symbol": "₽",
"currency_country": "ru",
"prices": "Цены",
"no_prices_found": "Цены не найдены",
"view_all_prices": "Нажмите, чтобы посмотреть все цены",
"retail_price": "Розничная цена",
"keyshop_price": "Цена в магазине ключей",
"historical_retail": "Исторические розничные цены",
"historical_keyshop": "Исторические цены в магазинах ключей",
"language": "Язык",
"caption": "Субтитры",
"audio": "Аудио",
"filter_by_source": "Фильтр по источнику",
"no_repacks_found": "Источники для этой игры не найдены"
},
"activation": {
"title": "Активировать Hydra",
@@ -302,6 +394,7 @@
"stop_seeding": "Остановить раздачу",
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"alldebrid_size_not_supported": "Информация о загрузке для AllDebrid пока не поддерживается",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
},
@@ -310,13 +403,10 @@
"change": "Изменить",
"notifications": "Уведомления",
"enable_download_notifications": "По завершении загрузки",
"enable_achievement_notifications": "Когда достижение разблокировано",
"enable_repack_list_notifications": "При добавлении нового репака",
"real_debrid_api_token_label": "Real-Debrid API-токен",
"quit_app_instead_hiding": "Закрывать приложение вместо сворачивания в трей",
"launch_with_system": "Запускать Hydra вместе с системой",
"launch_minimized": "Запустить Hydra в свернутом виде",
"disable_nsfw_alert": "Отключить предупреждение о непристойном контенте",
"general": "Основные",
"behavior": "Поведение",
"download_sources": "Источники загрузки",
@@ -343,11 +433,11 @@
"download_source_errored": "Ошибка",
"sync_download_sources": "Обновить источники",
"removed_download_source": "Источник удален",
"removed_download_sources": "Источники удалены",
"cancel_button_confirmation_delete_all_sources": "Нет",
"confirm_button_confirmation_delete_all_sources": "Да, удалить все",
"description_confirmation_delete_all_sources": "Вы удалите все источники",
"title_confirmation_delete_all_sources": "Удалить все источники",
"removed_download_sources": "Источники удалены",
"description_confirmation_delete_all_sources": "Вы удалите все источники",
"button_delete_all_sources": "Удалить все источники",
"added_download_source": "Источник добавлен",
"download_sources_synced": "Все источники обновлены",
@@ -356,17 +446,20 @@
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
"import": "Импортировать",
"blocked_users": "Заблокированные пользователи",
"friends_only": "Только для друзей",
"must_be_valid_url": "У источника должен быть правильный URL",
"privacy": "Конфиденциальность",
"public": "Публичный",
"private": "Частный",
"friends_only": "Только для друзей",
"privacy": "Конфиденциальность",
"profile_visibility": "Видимость профиля",
"profile_visibility_description": "Выберите, кто может видеть ваш профиль и библиотеку",
"public": "Публичный",
"required_field": "Это поле обязательно к заполнению",
"source_already_exists": "Этот источник уже добавлен",
"must_be_valid_url": "У источника должен быть правильный URL",
"blocked_users": "Заблокированные пользователи",
"user_unblocked": "Пользователь разблокирован",
"enable_achievement_notifications": "Когда достижение разблокировано",
"launch_minimized": "Запускать Hydra в свернутом виде",
"disable_nsfw_alert": "Отключить предупреждение о непристойном контенте",
"seed_after_download_complete": "Раздавать после завершения загрузки",
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением",
"account": "Аккаунт",
@@ -405,12 +498,25 @@
"delete_theme_description": "Это приведет к удалению темы {{theme}}",
"cancel": "Отменить",
"appearance": "Внешний вид",
"debrid": "Debrid",
"debrid_description": "Сервисы Debrid - это премиум-загрузчики без ограничений, которые позволяют быстро скачивать файлы с различных файлообменников, ограничиваясь только скоростью вашего интернета.",
"enable_torbox": "Включить TorBox",
"torbox_description": "TorBox - это ваш премиум-сервис, конкурирующий даже с лучшими серверами на рынке.",
"torbox_account_linked": "Аккаунт TorBox привязан",
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
"create_real_debrid_account": "Нажмите здесь, если у вас еще нет аккаунта Real-Debrid",
"create_torbox_account": "Нажмите здесь, если у вас еще нет учетной записи TorBox",
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
"enable_all_debrid": "Включить All-Debrid",
"all_debrid_description": "All-Debrid - это неограниченный загрузчик, который позволяет быстро скачивать файлы из различных источников.",
"all_debrid_free_account_error": "Аккаунт \"{{username}}\" является бесплатным. Пожалуйста, оформите подписку на All-Debrid",
"all_debrid_account_linked": "Аккаунт All-Debrid успешно привязан",
"alldebrid_missing_key": "Пожалуйста, предоставьте API ключ",
"alldebrid_invalid_key": "Неверный API ключ",
"alldebrid_blocked": "Ваш API ключ заблокирован по геолокации или IP",
"alldebrid_banned": "Этот аккаунт был заблокирован",
"alldebrid_unknown_error": "Произошла неизвестная ошибка",
"alldebrid_invalid_response": "Неверный ответ от All-Debrid",
"alldebrid_network_error": "Ошибка сети. Пожалуйста, проверьте соединение",
"name_min_length": "Название темы должно содержать не менее 3 символов",
"import_theme": "Импортировать тему",
"import_theme_description": "Вы импортируете {{theme}} из магазина тем",
@@ -424,6 +530,7 @@
"installing_common_redist": "Установка…",
"show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду",
"extract_files_by_default": "Извлекать файлы по умолчанию после загрузки",
"enable_steam_achievements": "Включить поиск достижений Steam",
"achievement_custom_notification_position": "Позиция уведомлений достижений",
"top-left": "Верхний левый угол",
"top-center": "Верхний центр",
@@ -440,8 +547,7 @@
"hidden": "Скрытый",
"test_notification": "Тестовое уведомление",
"notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"enable_steam_achievements": "Включить поиск достижений Steam"
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
},
"notifications": {
"download_complete": "Загрузка завершена",
@@ -453,13 +559,13 @@
"restart_to_install_update": "Перезапустите Hydra для установки обновления",
"notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}",
"notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}",
"new_friend_request_description": "{{displayName}} отправил вам запрос в друзья",
"new_friend_request_title": "Новый запрос на добавление в друзья",
"extraction_complete": "Распаковка завершена",
"game_extracted": "{{title}} успешно распакован",
"friend_started_playing_game": "{{displayName}} начал играть в игру",
"test_achievement_notification_title": "Это тестовое уведомление",
"test_achievement_notification_description": "Довольно круто, да?",
"new_friend_request_description": "{{displayName}} отправил вам запрос в друзья"
"test_achievement_notification_description": "Довольно круто, да?"
},
"system_tray": {
"open": "Открыть Hydra",
@@ -468,7 +574,8 @@
"game_card": {
"available_one": "Доступный",
"available_other": "Доступный",
"no_downloads": "Нет доступных источников"
"no_downloads": "Нет доступных источников",
"calculating": "Вычисление"
},
"binary_not_found_modal": {
"title": "Программы не установлены",
@@ -489,6 +596,10 @@
"last_time_played": "Последняя игра {{period}}",
"activity": "Недавняя активность",
"library": "Библиотека",
"pinned": "Закрепленные",
"achievements_earned": "Заработанные достижения",
"played_recently": "Недавно сыгранные",
"playtime": "Время игры",
"total_play_time": "Всего сыграно",
"manual_playtime_tooltip": "Время игры было обновлено вручную",
"no_recent_activity_title": "Хммм... Тут ничего нет",
@@ -532,24 +643,24 @@
"no_pending_invites": "У вас нет запросов ожидающих ответа",
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
"friend_code_copied": "Код друга скопирован",
"displayname_max_length": "Отображаемое имя должно содержать не более 50 символов.",
"displayname_min_length": "Отображаемое имя должно содержать не менее 3 символов.",
"image_process_failure": "Сбой при обработке изображения",
"locked_profile": "Этот профиль является частным",
"undo_friendship_modal_text": "Это отменит вашу дружбу с {{displayName}}.",
"privacy_hint": "Чтобы указать, кто может это видеть, перейдите в <0>Настройки</0>.",
"profile_reported": "Профиль сообщил",
"report": "Отчет",
"report_description": "Дополнительная информация",
"report_description_placeholder": "Дополнительная информация",
"locked_profile": "Этот профиль является частным",
"image_process_failure": "Сбой при обработке изображения",
"required_field": "Это поле обязательно к заполнению",
"displayname_min_length": "Отображаемое имя должно содержать не менее 3 символов.",
"displayname_max_length": "Отображаемое имя должно содержать не более 50 символов.",
"report_profile": "Пожаловаться на этот профиль",
"report_reason": "Почему вы жалуетесь на этот профиль?",
"report_description": "Дополнительная информация",
"report_description_placeholder": "Дополнительная информация",
"report": "Пожаловаться",
"report_reason_hate": "Разжигание ненависти",
"report_reason_other": "Другой",
"report_reason_sexual_content": "Сексуальный контент",
"report_reason_spam": "Спам",
"report_reason_violence": "Насилие",
"required_field": "Это поле обязательно к заполнению",
"undo_friendship_modal_text": "Это отменит вашу дружбу с {{displayName}}.",
"report_reason_spam": "Спам",
"report_reason_other": "Другое",
"profile_reported": "Жалоба на профиль отправлена",
"your_friend_code": "Код вашего друга:",
"upload_banner": "Загрузить баннер",
"uploading_banner": "Загрузка баннера...",
@@ -565,7 +676,12 @@
"show_achievements_on_profile": "Покажите свои достижения в профиле",
"show_points_on_profile": "Показывать заработанные очки в своем профиле",
"error_adding_friend": "Не удалось отправить запрос в друзья. Пожалуйста, проверьте код друга",
"friend_code_length_error": "Код друга должен содержать 8 символов"
"friend_code_length_error": "Код друга должен содержать 8 символов",
"game_removed_from_pinned": "Игра удалена из закрепленных",
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",

View File

@@ -42,3 +42,14 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
export const MAIN_LOOP_INTERVAL = 2000;
export const DECKY_PLUGINS_LOCATION = path.join(
SystemPath.getPath("home"),
"homebrew",
"plugins"
);
export const HYDRA_DECKY_PLUGIN_LOCATION = path.join(
DECKY_PLUGINS_LOCATION,
"Hydra"
);

View File

@@ -1,22 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { CatalogueCategory } from "@shared";
import { ShopAssets } from "@types";
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
category: CatalogueCategory
) => {
const params = new URLSearchParams({
take: "12",
skip: "0",
});
return HydraApi.get<ShopAssets[]>(
`/catalogue/${category}?${params.toString()}`,
{},
{ needsAuth: false }
);
};
registerEvent("getCatalogue", getCatalogue);

View File

@@ -1,10 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const getDevelopers = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<string[]>(`/catalogue/developers`, null, {
needsAuth: false,
});
};
registerEvent("getDevelopers", getDevelopers);

View File

@@ -0,0 +1,51 @@
import type { GameShop, ShopAssets } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours
export const getGameAssets = async (objectId: string, shop: GameShop) => {
const cachedAssets = await gamesShopAssetsSublevel.get(
levelKeys.game(shop, objectId)
);
if (
cachedAssets &&
cachedAssets.updatedAt + LOCAL_CACHE_EXPIRATION > Date.now()
) {
return cachedAssets;
}
return HydraApi.get<ShopAssets | null>(
`/games/${shop}/${objectId}/assets`,
null,
{
needsAuth: false,
}
).then(async (assets) => {
if (!assets) return null;
// Preserve existing title if it differs from the incoming title (indicating it was customized)
const shouldPreserveTitle =
cachedAssets?.title && cachedAssets.title !== assets.title;
await gamesShopAssetsSublevel.put(levelKeys.game(shop, objectId), {
...assets,
title: shouldPreserveTitle ? cachedAssets.title : assets.title,
updatedAt: Date.now(),
});
return assets;
});
};
const getGameAssetsEvent = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
return getGameAssets(objectId, shop);
};
registerEvent("getGameAssets", getGameAssetsEvent);

View File

@@ -21,11 +21,9 @@ const getGameStats = async (
return cachedStats;
}
return HydraApi.get<GameStats>(
`/games/stats`,
{ objectId, shop },
{ needsAuth: false }
).then(async (data) => {
return HydraApi.get<GameStats>(`/games/${shop}/${objectId}/stats`, null, {
needsAuth: false,
}).then(async (data) => {
await gamesStatsCacheSublevel.put(levelKeys.game(shop, objectId), {
...data,
updatedAt: Date.now(),

View File

@@ -1,21 +0,0 @@
import type { GameShop, HowLongToBeatCategory } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const getHowLongToBeat = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
): Promise<HowLongToBeatCategory[] | null> => {
const params = new URLSearchParams({
objectId,
shop,
});
return HydraApi.get(`/games/how-long-to-beat?${params.toString()}`, null, {
needsAuth: false,
});
};
registerEvent("getHowLongToBeat", getHowLongToBeat);

View File

@@ -1,10 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const getPublishers = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<string[]>(`/catalogue/publishers`, null, {
needsAuth: false,
});
};
registerEvent("getPublishers", getPublishers);

View File

@@ -1,22 +0,0 @@
import { db, levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { TrendingGame } from "@types";
const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf8",
})
.then((language) => language || "en");
const trendingGames = await HydraApi.get<TrendingGame[]>(
"/games/featured",
{ language },
{ needsAuth: false }
).catch(() => []);
return trendingGames.slice(0, 1);
};
registerEvent("getTrendingGames", getTrendingGames);

View File

@@ -1,25 +0,0 @@
import type { GameShop, ShopAssets } from "@types";
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
const saveGameShopAssets = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
assets: ShopAssets
): Promise<void> => {
const key = levelKeys.game(shop, objectId);
const existingAssets = await gamesShopAssetsSublevel.get(key);
// Preserve existing title if it differs from the incoming title (indicating it was customized)
const shouldPreserveTitle =
existingAssets?.title && existingAssets.title !== assets.title;
return gamesShopAssetsSublevel.put(key, {
...existingAssets,
...assets,
title: shouldPreserveTitle ? existingAssets.title : assets.title,
});
};
registerEvent("saveGameShopAssets", saveGameShopAssets);

View File

@@ -1,18 +0,0 @@
import type { CatalogueSearchPayload } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const searchGames = async (
_event: Electron.IpcMainInvokeEvent,
payload: CatalogueSearchPayload,
take: number,
skip: number
) => {
return HydraApi.post(
"/catalogue/search",
{ ...payload, take, skip },
{ needsAuth: false }
);
};
registerEvent("searchGames", searchGames);

View File

@@ -1,12 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const deleteGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
gameArtifactId: string
) =>
HydraApi.delete<{ ok: boolean }>(
`/profile/games/artifacts/${gameArtifactId}`
);
registerEvent("deleteGameArtifact", deleteGameArtifact);

View File

@@ -1,33 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
import type { GameArtifact, GameShop } from "@types";
import { SubscriptionRequiredError, UserNotLoggedInError } from "@shared";
const getGameArtifacts = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const params = new URLSearchParams({
objectId,
shop,
});
return HydraApi.get<GameArtifact[]>(
`/profile/games/artifacts?${params.toString()}`,
{},
{ needsSubscription: true }
).catch((err) => {
if (err instanceof SubscriptionRequiredError) {
return [];
}
if (err instanceof UserNotLoggedInError) {
return [];
}
throw err;
});
};
registerEvent("getGameArtifacts", getGameArtifacts);

View File

@@ -1,14 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const renameGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
gameArtifactId: string,
label: string
) => {
await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}`, {
label,
});
};
registerEvent("renameGameArtifact", renameGameArtifact);

View File

@@ -1,16 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const toggleArtifactFreeze = async (
_event: Electron.IpcMainInvokeEvent,
gameArtifactId: string,
freeze: boolean
) => {
if (freeze) {
await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}/freeze`);
} else {
await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}/unfreeze`);
}
};
registerEvent("toggleArtifactFreeze", toggleArtifactFreeze);

View File

@@ -1,10 +1,13 @@
import disk from "diskusage";
import { DiskUsage } from "@types";
import { registerEvent } from "../register-event";
import checkDiskSpace from "check-disk-space";
const getDiskFreeSpace = async (
_event: Electron.IpcMainInvokeEvent,
path: string
) => disk.check(path);
): Promise<DiskUsage> => {
const result = await checkDiskSpace(path);
return { free: result.free, total: result.size };
};
registerEvent("getDiskFreeSpace", getDiskFreeSpace);

View File

@@ -1,16 +1,9 @@
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
import { ipcMain } from "electron";
import "./catalogue/get-catalogue";
import "./catalogue/get-game-shop-details";
import "./catalogue/save-game-shop-assets";
import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game";
import "./catalogue/search-games";
import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games";
import "./catalogue/get-publishers";
import "./catalogue/get-developers";
import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";
@@ -46,13 +39,15 @@ import "./library/copy-custom-game-asset";
import "./misc/open-checkout";
import "./misc/open-external";
import "./misc/show-open-dialog";
import "./misc/get-features";
import "./misc/show-item-in-folder";
import "./misc/get-badges";
import "./misc/install-common-redist";
import "./misc/can-install-common-redist";
import "./misc/save-temp-file";
import "./misc/delete-temp-file";
import "./misc/install-hydra-decky-plugin";
import "./misc/get-hydra-decky-plugin-info";
import "./misc/check-homebrew-folder-exists";
import "./misc/hydra-api-call";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
@@ -66,38 +61,23 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-all-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/put-download-source";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-user";
import "./user/get-user-library";
import "./user/get-blocked-users";
import "./user/block-user";
import "./user/unblock-user";
import "./user/get-user-friends";
import "./user/get-auth";
import "./user/get-user-stats";
import "./user/report-user";
import "./user/get-unlocked-achievements";
import "./user/get-compared-unlocked-achievements";
import "./profile/get-friend-requests";
import "./profile/get-me";
import "./profile/undo-friendship";
import "./profile/update-friend-request";
import "./profile/update-profile";
import "./profile/process-profile-image";
import "./profile/send-friend-request";
import "./profile/sync-friend-requests";
import "./cloud-save/download-game-artifact";
import "./cloud-save/get-game-artifacts";
import "./cloud-save/get-game-backup-preview";
import "./cloud-save/upload-save-game";
import "./cloud-save/delete-game-artifact";
import "./cloud-save/select-game-backup-path";
import "./cloud-save/toggle-artifact-freeze";
import "./cloud-save/rename-game-artifact";
import "./notifications/publish-new-repacks-notification";
import "./notifications/update-achievement-notification-window";
import "./notifications/show-achievement-test-notification";

View File

@@ -27,6 +27,7 @@ const addCustomGameToLibrary = async (
}
const assets = {
updatedAt: Date.now(),
objectId,
shop,
title,

View File

@@ -1,12 +1,11 @@
import { registerEvent } from "../register-event";
import type { GameShop, GameStats } from "@types";
import type { GameShop, ShopAssets } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
import {
composeSteamShortcut,
getSteamLocation,
getSteamShortcuts,
getSteamUsersIds,
HydraApi,
logger,
SystemPath,
writeSteamShortcuts,
@@ -15,6 +14,7 @@ import fs from "node:fs";
import axios from "axios";
import path from "node:path";
import { ASSETS_PATH } from "@main/constants";
import { getGameAssets } from "../catalogue/get-game-assets";
const downloadAsset = async (downloadPath: string, url?: string | null) => {
try {
@@ -41,7 +41,7 @@ const downloadAsset = async (downloadPath: string, url?: string | null) => {
const downloadAssetsFromSteam = async (
shop: GameShop,
objectId: string,
assets: GameStats["assets"]
assets: ShopAssets | null
) => {
const gameAssetsPath = path.join(ASSETS_PATH, `${shop}-${objectId}`);
@@ -86,9 +86,7 @@ const createSteamShortcut = async (
throw new Error("No executable path found for game");
}
const { assets } = await HydraApi.get<GameStats>(
`/games/stats?objectId=${objectId}&shop=${shop}`
);
const assets = await getGameAssets(objectId, shop);
const steamUserIds = await getSteamUsersIds();

View File

@@ -0,0 +1,13 @@
import { registerEvent } from "../register-event";
import { DECKY_PLUGINS_LOCATION } from "@main/constants";
import fs from "node:fs";
import path from "node:path";
const checkHomebrewFolderExists = async (
_event: Electron.IpcMainInvokeEvent
): Promise<boolean> => {
const homebrewPath = path.dirname(DECKY_PLUGINS_LOCATION);
return fs.existsSync(homebrewPath);
};
registerEvent("checkHomebrewFolderExists", checkHomebrewFolderExists);

View File

@@ -1,22 +0,0 @@
import { Badge } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { db, levelKeys } from "@main/level";
const getBadges = async (_event: Electron.IpcMainInvokeEvent) => {
const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf8",
})
.then((language) => language || "en");
const params = new URLSearchParams({
locale: language,
});
return HydraApi.get<Badge[]>(`/badges?${params.toString()}`, null, {
needsAuth: false,
});
};
registerEvent("getBadges", getBadges);

View File

@@ -1,8 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const getFeatures = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<string[]>("/features", null, { needsAuth: false });
};
registerEvent("getFeatures", getFeatures);

View File

@@ -0,0 +1,94 @@
import { registerEvent } from "../register-event";
import { logger, HydraApi } from "@main/services";
import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants";
import fs from "node:fs";
import path from "node:path";
interface DeckyReleaseInfo {
version: string;
downloadUrl: string;
}
const getHydraDeckyPluginInfo = async (
_event: Electron.IpcMainInvokeEvent
): Promise<{
installed: boolean;
version: string | null;
path: string;
outdated: boolean;
expectedVersion: string | null;
}> => {
try {
// Fetch the expected version from API
let expectedVersion: string | null = null;
try {
const releaseInfo = await HydraApi.get<DeckyReleaseInfo>(
"/decky/release",
{},
{ needsAuth: false }
);
expectedVersion = releaseInfo.version;
} catch (error) {
logger.error("Failed to fetch Decky release info:", error);
}
// Check if plugin folder exists
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
logger.log("Hydra Decky plugin not installed");
return {
installed: false,
version: null,
path: HYDRA_DECKY_PLUGIN_LOCATION,
outdated: true,
expectedVersion,
};
}
// Check if package.json exists
const packageJsonPath = path.join(
HYDRA_DECKY_PLUGIN_LOCATION,
"package.json"
);
if (!fs.existsSync(packageJsonPath)) {
logger.log("Hydra Decky plugin package.json not found");
return {
installed: false,
version: null,
path: HYDRA_DECKY_PLUGIN_LOCATION,
outdated: true,
expectedVersion,
};
}
// Read and parse package.json
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
const packageJson = JSON.parse(packageJsonContent);
const version = packageJson.version;
const outdated = expectedVersion ? version !== expectedVersion : false;
logger.log(
`Hydra Decky plugin installed, version: ${version}, expected: ${expectedVersion}, outdated: ${outdated}`
);
return {
installed: true,
version,
path: HYDRA_DECKY_PLUGIN_LOCATION,
outdated,
expectedVersion,
};
} catch (error) {
logger.error("Failed to get plugin info:", error);
return {
installed: false,
version: null,
path: HYDRA_DECKY_PLUGIN_LOCATION,
outdated: true,
expectedVersion: null,
};
}
};
registerEvent("getHydraDeckyPluginInfo", getHydraDeckyPluginInfo);

View File

@@ -0,0 +1,38 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
interface HydraApiCallPayload {
method: "get" | "post" | "put" | "patch" | "delete";
url: string;
data?: unknown;
params?: unknown;
options?: {
needsAuth?: boolean;
needsSubscription?: boolean;
ifModifiedSince?: Date;
};
}
const hydraApiCall = async (
_event: Electron.IpcMainInvokeEvent,
payload: HydraApiCallPayload
) => {
const { method, url, data, params, options } = payload;
switch (method) {
case "get":
return HydraApi.get(url, params, options);
case "post":
return HydraApi.post(url, data, options);
case "put":
return HydraApi.put(url, data, options);
case "patch":
return HydraApi.patch(url, data, options);
case "delete":
return HydraApi.delete(url, options);
default:
throw new Error(`Unsupported HTTP method: ${method}`);
}
};
registerEvent("hydraApiCall", hydraApiCall);

View File

@@ -0,0 +1,50 @@
import { registerEvent } from "../register-event";
import { logger, DeckyPlugin } from "@main/services";
import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants";
const installHydraDeckyPlugin = async (
_event: Electron.IpcMainInvokeEvent
): Promise<{
success: boolean;
path: string;
currentVersion: string | null;
expectedVersion: string;
error?: string;
}> => {
try {
logger.log("Installing/updating Hydra Decky plugin...");
const result = await DeckyPlugin.checkPluginVersion();
if (result.exists && !result.outdated) {
logger.log("Plugin installed successfully");
return {
success: true,
path: HYDRA_DECKY_PLUGIN_LOCATION,
currentVersion: result.currentVersion,
expectedVersion: result.expectedVersion,
};
} else {
logger.error("Failed to install plugin");
return {
success: false,
path: HYDRA_DECKY_PLUGIN_LOCATION,
currentVersion: result.currentVersion,
expectedVersion: result.expectedVersion,
error: "Plugin installation failed",
};
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("Failed to install plugin:", error);
return {
success: false,
path: HYDRA_DECKY_PLUGIN_LOCATION,
currentVersion: null,
expectedVersion: "unknown",
error: errorMessage,
};
}
};
registerEvent("installHydraDeckyPlugin", installHydraDeckyPlugin);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { FriendRequest } from "@types";
const getFriendRequests = async (
_event: Electron.IpcMainInvokeEvent
): Promise<FriendRequest[]> => {
return HydraApi.get(`/profile/friend-requests`).catch(() => []);
};
registerEvent("getFriendRequests", getFriendRequests);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const sendFriendRequest = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
return HydraApi.post("/profile/friend-requests", { friendCode: userId });
};
registerEvent("sendFriendRequest", sendFriendRequest);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const undoFriendship = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.delete(`/profile/friends/${userId}`);
};
registerEvent("undoFriendship", undoFriendship);

View File

@@ -1,19 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { FriendRequestAction } from "@types";
const updateFriendRequest = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
action: FriendRequestAction
) => {
if (action == "CANCEL") {
return HydraApi.delete(`/profile/friend-requests/${userId}`);
}
return HydraApi.patch(`/profile/friend-requests/${userId}`, {
requestState: action,
});
};
registerEvent("updateFriendRequest", updateFriendRequest);

View File

@@ -93,14 +93,9 @@ const startGameDownload = async (
await Promise.all([
createGame(updatedGame!).catch(() => {}),
HydraApi.post(
"/games/download",
{
objectId,
shop,
},
{ needsAuth: false }
).catch(() => {}),
HydraApi.post(`/games/${shop}/${objectId}/download`, null, {
needsAuth: false,
}).catch(() => {}),
]);
return { ok: true };

View File

@@ -0,0 +1,17 @@
import { AllDebridClient } from "@main/services/download/all-debrid";
import { registerEvent } from "../register-event";
const authenticateAllDebrid = async (
_event: Electron.IpcMainInvokeEvent,
apiKey: string
) => {
AllDebridClient.authorize(apiKey);
const result = await AllDebridClient.getUser();
if ("error_code" in result) {
return { error_code: result.error_code };
}
return result.user;
};
registerEvent("authenticateAllDebrid", authenticateAllDebrid);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const blockUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.post(`/users/${userId}/block`);
};
registerEvent("blockUser", blockUser);

View File

@@ -1,19 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import type { UserBlocks } from "@types";
export const getBlockedUsers = async (
_event: Electron.IpcMainInvokeEvent,
take: number,
skip: number
): Promise<UserBlocks> => {
return HydraApi.get(`/profile/blocks`, { take, skip }).catch((err) => {
if (err instanceof UserNotLoggedInError) {
return { blocks: [] };
}
throw err;
});
};
registerEvent("getBlockedUsers", getBlockedUsers);

View File

@@ -1,32 +0,0 @@
import { db } from "@main/level";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { User, UserFriends } from "@types";
import { levelKeys } from "@main/level/sublevels";
export const getUserFriends = async (
userId: string,
take: number,
skip: number
): Promise<UserFriends> => {
const user = await db.get<string, User>(levelKeys.user, {
valueEncoding: "json",
});
if (user?.id === userId) {
return HydraApi.get(`/profile/friends`, { take, skip });
}
return HydraApi.get(`/users/${userId}/friends`, { take, skip });
};
const getUserFriendsEvent = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
take: number,
skip: number
) => {
return getUserFriends(userId, take, skip);
};
registerEvent("getUserFriends", getUserFriendsEvent);

View File

@@ -1,28 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { UserLibraryResponse } from "@types";
const getUserLibrary = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
take: number = 12,
skip: number = 0,
sortBy?: string
): Promise<UserLibraryResponse | null> => {
const params = new URLSearchParams();
params.append("take", take.toString());
params.append("skip", skip.toString());
if (sortBy) {
params.append("sortBy", sortBy);
}
const queryString = params.toString();
const baseUrl = `/users/${userId}/library`;
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
return HydraApi.get<UserLibraryResponse>(url).catch(() => null);
};
registerEvent("getUserLibrary", getUserLibrary);

View File

@@ -1,12 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { UserStats } from "@types";
export const getUserStats = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
): Promise<UserStats> => {
return HydraApi.get(`/users/${userId}/stats`);
};
registerEvent("getUserStats", getUserStats);

View File

@@ -1,12 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { UserProfile } from "@types";
const getUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
): Promise<UserProfile | null> => {
return HydraApi.get<UserProfile>(`/users/${userId}`).catch(() => null);
};
registerEvent("getUser", getUser);

View File

@@ -1,16 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
export const reportUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
reason: string,
description: string
): Promise<void> => {
return HydraApi.post(`/users/${userId}/report`, {
reason,
description,
});
};
registerEvent("reportUser", reportUser);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const unblockUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.post(`/users/${userId}/unblock`);
};
registerEvent("unblockUser", unblockUser);

View File

@@ -9,6 +9,7 @@ import {
clearGamesPlaytime,
WindowManager,
Lock,
Aria2,
} from "@main/services";
import resources from "@locales";
import { PythonRPC } from "./services/python-rpc";
@@ -222,6 +223,7 @@ app.on("before-quit", async (e) => {
e.preventDefault();
/* Disconnects libtorrent */
PythonRPC.kill();
Aria2.kill();
await clearGamesPlaytime();
canAppBeClosed = true;
app.quit();

View File

@@ -3,9 +3,9 @@ import type { ShopAssets } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gamesShopAssetsSublevel = db.sublevel<string, ShopAssets>(
levelKeys.gameShopAssets,
{
valueEncoding: "json",
}
);
export const gamesShopAssetsSublevel = db.sublevel<
string,
ShopAssets & { updatedAt: number }
>(levelKeys.gameShopAssets, {
valueEncoding: "json",
});

View File

@@ -8,6 +8,7 @@ import {
CommonRedistManager,
TorBoxClient,
RealDebridClient,
AllDebridClient,
Aria2,
DownloadManager,
HydraApi,
@@ -15,6 +16,7 @@ import {
startMainLoop,
Ludusavi,
Lock,
DeckyPlugin,
} from "@main/services";
export const loadState = async () => {
@@ -37,6 +39,10 @@ export const loadState = async () => {
RealDebridClient.authorize(userPreferences.realDebridApiToken);
}
if (userPreferences?.allDebridApiKey) {
AllDebridClient.authorize(userPreferences.allDebridApiKey);
}
if (userPreferences?.torBoxApiToken) {
TorBoxClient.authorize(userPreferences.torBoxApiToken);
}
@@ -44,6 +50,10 @@ export const loadState = async () => {
Ludusavi.copyConfigFileToUserData();
Ludusavi.copyBinaryToUserData();
if (process.platform === "linux") {
DeckyPlugin.checkAndUpdateIfOutdated();
}
await HydraApi.setupApi().then(() => {
uploadGamesBatch();
// WSClient.connect();

View File

@@ -45,10 +45,8 @@ export const getGameAchievementData = async (
.then((language) => language || "en");
return HydraApi.get<SteamAchievement[]>(
"/games/achievements",
`/games/${shop}/${objectId}/achievements`,
{
shop,
objectId,
language,
},
{

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import cp from "node:child_process";
import { app } from "electron";
import { logger } from "./logger";
export class Aria2 {
private static process: cp.ChildProcess | null = null;
@@ -23,6 +24,9 @@ export class Aria2 {
}
public static kill() {
this.process?.kill();
if (this.process) {
logger.log("Killing aria2 process");
this.process.kill();
}
}
}

View File

@@ -80,7 +80,7 @@ export class CloudSync {
try {
await fs.promises.rm(backupPath, { recursive: true });
} catch (error) {
logger.error("Failed to remove backup path", error);
logger.error("Failed to remove backup path", { backupPath, error });
}
}
@@ -163,7 +163,7 @@ export class CloudSync {
try {
await fs.promises.unlink(bundleLocation);
} catch (error) {
logger.error("Failed to remove tar file", error);
logger.error("Failed to remove tar file", { bundleLocation, error });
}
}
}

View File

@@ -0,0 +1,400 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import axios from "axios";
import sudo from "sudo-prompt";
import { app } from "electron";
import {
HYDRA_DECKY_PLUGIN_LOCATION,
DECKY_PLUGINS_LOCATION,
} from "@main/constants";
import { logger } from "./logger";
import { SevenZip } from "./7zip";
import { SystemPath } from "./system-path";
import { HydraApi } from "./hydra-api";
interface DeckyReleaseInfo {
version: string;
downloadUrl: string;
}
export class DeckyPlugin {
private static releaseInfo: DeckyReleaseInfo | null = null;
private static async getDeckyReleaseInfo(): Promise<DeckyReleaseInfo> {
if (this.releaseInfo) {
return this.releaseInfo;
}
try {
const response = await HydraApi.get<DeckyReleaseInfo>(
"/decky/release",
{},
{ needsAuth: false }
);
this.releaseInfo = response;
return response;
} catch (error) {
logger.error("Failed to fetch Decky release info:", error);
throw error;
}
}
private static getPackageJsonPath(): string {
return path.join(HYDRA_DECKY_PLUGIN_LOCATION, "package.json");
}
private static async downloadPlugin(): Promise<string> {
logger.log("Downloading Hydra Decky plugin...");
const releaseInfo = await this.getDeckyReleaseInfo();
const tempDir = SystemPath.getPath("temp");
const zipPath = path.join(tempDir, "Hydra.zip");
const response = await axios.get(releaseInfo.downloadUrl, {
responseType: "arraybuffer",
});
await fs.promises.writeFile(zipPath, response.data);
logger.log(`Plugin downloaded to: ${zipPath}`);
return zipPath;
}
private static async extractPlugin(zipPath: string): Promise<string> {
logger.log("Extracting Hydra Decky plugin...");
const tempDir = SystemPath.getPath("temp");
const extractPath = path.join(tempDir, "hydra-decky-plugin");
if (fs.existsSync(extractPath)) {
await fs.promises.rm(extractPath, { recursive: true, force: true });
}
await fs.promises.mkdir(extractPath, { recursive: true });
return new Promise((resolve, reject) => {
SevenZip.extractFile(
{
filePath: zipPath,
outputPath: extractPath,
},
() => {
logger.log(`Plugin extracted to: ${extractPath}`);
resolve(extractPath);
},
() => {
reject(new Error("Failed to extract plugin"));
}
);
});
}
private static needsSudo(): boolean {
try {
if (fs.existsSync(DECKY_PLUGINS_LOCATION)) {
fs.accessSync(DECKY_PLUGINS_LOCATION, fs.constants.W_OK);
return false;
}
const parentDir = path.dirname(DECKY_PLUGINS_LOCATION);
if (fs.existsSync(parentDir)) {
fs.accessSync(parentDir, fs.constants.W_OK);
return false;
}
return true;
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error.code === "EACCES" || error.code === "EPERM")
) {
return true;
}
throw error;
}
}
private static async installPluginWithSudo(
extractPath: string
): Promise<void> {
logger.log("Installing plugin with sudo...");
const username = os.userInfo().username;
const sourcePath = path.join(extractPath, "Hydra");
return new Promise((resolve, reject) => {
const command = `mkdir -p "${DECKY_PLUGINS_LOCATION}" && rm -rf "${HYDRA_DECKY_PLUGIN_LOCATION}" && cp -r "${sourcePath}" "${HYDRA_DECKY_PLUGIN_LOCATION}" && chown -R ${username}: "${DECKY_PLUGINS_LOCATION}"`;
sudo.exec(
command,
{ name: app.getName() },
(sudoError, _stdout, stderr) => {
if (sudoError) {
logger.error("Failed to install plugin with sudo:", sudoError);
reject(sudoError);
} else {
logger.log("Plugin installed successfully with sudo");
if (stderr) {
logger.log("Sudo stderr:", stderr);
}
resolve();
}
}
);
});
}
private static async installPluginWithoutSudo(
extractPath: string
): Promise<void> {
logger.log("Installing plugin without sudo...");
const sourcePath = path.join(extractPath, "Hydra");
if (!fs.existsSync(DECKY_PLUGINS_LOCATION)) {
await fs.promises.mkdir(DECKY_PLUGINS_LOCATION, { recursive: true });
}
if (fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
await fs.promises.rm(HYDRA_DECKY_PLUGIN_LOCATION, {
recursive: true,
force: true,
});
}
await fs.promises.cp(sourcePath, HYDRA_DECKY_PLUGIN_LOCATION, {
recursive: true,
});
logger.log("Plugin installed successfully");
}
private static async installPlugin(extractPath: string): Promise<void> {
if (this.needsSudo()) {
await this.installPluginWithSudo(extractPath);
} else {
await this.installPluginWithoutSudo(extractPath);
}
}
private static async updatePlugin(): Promise<void> {
let zipPath: string | null = null;
let extractPath: string | null = null;
try {
zipPath = await this.downloadPlugin();
extractPath = await this.extractPlugin(zipPath);
await this.installPlugin(extractPath);
logger.log("Plugin update completed successfully");
} catch (error) {
logger.error("Failed to update plugin:", error);
throw error;
} finally {
if (zipPath) {
try {
await fs.promises.rm(zipPath, { force: true });
logger.log("Cleaned up downloaded zip file");
} catch (cleanupError) {
logger.error("Failed to clean up zip file:", cleanupError);
}
}
if (extractPath) {
try {
await fs.promises.rm(extractPath, { recursive: true, force: true });
logger.log("Cleaned up extraction directory");
} catch (cleanupError) {
logger.error(
"Failed to clean up extraction directory:",
cleanupError
);
}
}
}
}
public static async checkAndUpdateIfOutdated(): Promise<void> {
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
logger.log("Hydra Decky plugin not installed, skipping update check");
return;
}
const packageJsonPath = this.getPackageJsonPath();
try {
if (!fs.existsSync(packageJsonPath)) {
logger.log(
"Hydra Decky plugin package.json not found, skipping update"
);
return;
}
const releaseInfo = await this.getDeckyReleaseInfo();
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
const packageJson = JSON.parse(packageJsonContent);
const currentVersion = packageJson.version;
const isOutdated = currentVersion !== releaseInfo.version;
if (isOutdated) {
logger.log(
`Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${releaseInfo.version}. Updating...`
);
await this.updatePlugin();
logger.log("Hydra Decky plugin updated successfully");
} else {
logger.log(`Hydra Decky plugin is up to date (${currentVersion})`);
}
} catch (error) {
logger.error(`Error checking/updating Hydra Decky plugin: ${error}`);
}
}
public static async checkPluginVersion(): Promise<{
exists: boolean;
outdated: boolean;
currentVersion: string | null;
expectedVersion: string;
}> {
try {
const releaseInfo = await this.getDeckyReleaseInfo();
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
logger.log("Hydra Decky plugin folder not found, installing...");
try {
await this.updatePlugin();
// Read the actual installed version from package.json
const packageJsonPath = this.getPackageJsonPath();
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = fs.readFileSync(
packageJsonPath,
"utf-8"
);
const packageJson = JSON.parse(packageJsonContent);
return {
exists: true,
outdated: false,
currentVersion: packageJson.version,
expectedVersion: releaseInfo.version,
};
}
return {
exists: true,
outdated: false,
currentVersion: releaseInfo.version,
expectedVersion: releaseInfo.version,
};
} catch (error) {
logger.error("Failed to install plugin:", error);
return {
exists: false,
outdated: true,
currentVersion: null,
expectedVersion: releaseInfo.version,
};
}
}
const packageJsonPath = this.getPackageJsonPath();
try {
if (!fs.existsSync(packageJsonPath)) {
logger.log(
"Hydra Decky plugin package.json not found, installing..."
);
await this.updatePlugin();
// Read the actual installed version from package.json
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = fs.readFileSync(
packageJsonPath,
"utf-8"
);
const packageJson = JSON.parse(packageJsonContent);
return {
exists: true,
outdated: false,
currentVersion: packageJson.version,
expectedVersion: releaseInfo.version,
};
}
return {
exists: true,
outdated: false,
currentVersion: releaseInfo.version,
expectedVersion: releaseInfo.version,
};
}
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
const packageJson = JSON.parse(packageJsonContent);
const currentVersion = packageJson.version;
const isOutdated = currentVersion !== releaseInfo.version;
if (isOutdated) {
logger.log(
`Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${releaseInfo.version}`
);
await this.updatePlugin();
if (fs.existsSync(packageJsonPath)) {
const updatedPackageJsonContent = fs.readFileSync(
packageJsonPath,
"utf-8"
);
const updatedPackageJson = JSON.parse(updatedPackageJsonContent);
return {
exists: true,
outdated: false,
currentVersion: updatedPackageJson.version,
expectedVersion: releaseInfo.version,
};
}
return {
exists: true,
outdated: false,
currentVersion: releaseInfo.version,
expectedVersion: releaseInfo.version,
};
} else {
logger.log(`Hydra Decky plugin is up to date (${currentVersion})`);
}
return {
exists: true,
outdated: isOutdated,
currentVersion,
expectedVersion: releaseInfo.version,
};
} catch (error) {
logger.error(`Error checking Hydra Decky plugin version: ${error}`);
return {
exists: false,
outdated: true,
currentVersion: null,
expectedVersion: releaseInfo.version,
};
}
} catch (error) {
logger.error(`Error fetching release info: ${error}`);
return {
exists: false,
outdated: true,
currentVersion: null,
expectedVersion: "unknown",
};
}
}
}

View File

@@ -0,0 +1,315 @@
import axios, { AxiosInstance } from "axios";
import type { AllDebridUser } from "@types";
import { logger } from "@main/services";
interface AllDebridMagnetStatus {
id: number;
filename: string;
size: number;
status: string;
statusCode: number;
downloaded: number;
uploaded: number;
seeders: number;
downloadSpeed: number;
uploadSpeed: number;
uploadDate: number;
completionDate: number;
links: Array<{
link: string;
filename: string;
size: number;
}>;
}
interface AllDebridError {
code: string;
message: string;
}
interface AllDebridDownloadUrl {
link: string;
size?: number;
filename?: string;
}
export class AllDebridClient {
private static instance: AxiosInstance;
private static readonly baseURL = "https://api.alldebrid.com/v4";
static authorize(apiKey: string) {
logger.info("[AllDebrid] Authorizing with key:", apiKey ? "***" : "empty");
this.instance = axios.create({
baseURL: this.baseURL,
params: {
agent: "hydra",
apikey: apiKey,
},
});
}
static async getUser() {
try {
const response = await this.instance.get<{
status: string;
data?: { user: AllDebridUser };
error?: AllDebridError;
}>("/user");
logger.info("[AllDebrid] API Response:", response.data);
if (response.data.status === "error") {
const error = response.data.error;
logger.error("[AllDebrid] API Error:", error);
if (error?.code === "AUTH_MISSING_APIKEY") {
return { error_code: "alldebrid_missing_key" };
}
if (error?.code === "AUTH_BAD_APIKEY") {
return { error_code: "alldebrid_invalid_key" };
}
if (error?.code === "AUTH_BLOCKED") {
return { error_code: "alldebrid_blocked" };
}
if (error?.code === "AUTH_USER_BANNED") {
return { error_code: "alldebrid_banned" };
}
return { error_code: "alldebrid_unknown_error" };
}
if (!response.data.data?.user) {
logger.error("[AllDebrid] No user data in response");
return { error_code: "alldebrid_invalid_response" };
}
logger.info(
"[AllDebrid] Successfully got user:",
response.data.data.user.username
);
return { user: response.data.data.user };
} catch (error: any) {
logger.error("[AllDebrid] Request Error:", error);
if (error.response?.data?.error) {
return { error_code: "alldebrid_invalid_key" };
}
return { error_code: "alldebrid_network_error" };
}
}
private static async uploadMagnet(magnet: string) {
try {
logger.info("[AllDebrid] Uploading magnet with params:", { magnet });
const response = await this.instance.get("/magnet/upload", {
params: {
magnets: [magnet],
},
});
logger.info(
"[AllDebrid] Upload Magnet Raw Response:",
JSON.stringify(response.data, null, 2)
);
if (response.data.status === "error") {
throw new Error(response.data.error?.message || "Unknown error");
}
const magnetInfo = response.data.data.magnets[0];
logger.info(
"[AllDebrid] Magnet Info:",
JSON.stringify(magnetInfo, null, 2)
);
if (magnetInfo.error) {
throw new Error(magnetInfo.error.message);
}
return magnetInfo.id;
} catch (error: any) {
logger.error("[AllDebrid] Upload Magnet Error:", error);
throw error;
}
}
private static async checkMagnetStatus(
magnetId: number
): Promise<AllDebridMagnetStatus> {
try {
logger.info("[AllDebrid] Checking magnet status for ID:", magnetId);
const response = await this.instance.get(`/magnet/status`, {
params: {
id: magnetId,
},
});
logger.info(
"[AllDebrid] Check Magnet Status Raw Response:",
JSON.stringify(response.data, null, 2)
);
if (!response.data) {
throw new Error("No response data received");
}
if (response.data.status === "error") {
throw new Error(response.data.error?.message || "Unknown error");
}
// Verificăm noua structură a răspunsului
const magnetData = response.data.data?.magnets;
if (!magnetData || typeof magnetData !== "object") {
logger.error(
"[AllDebrid] Invalid response structure:",
JSON.stringify(response.data, null, 2)
);
throw new Error("Invalid magnet status response format");
}
// Convertim răspunsul în formatul așteptat
const magnetStatus: AllDebridMagnetStatus = {
id: magnetData.id,
filename: magnetData.filename,
size: magnetData.size,
status: magnetData.status,
statusCode: magnetData.statusCode,
downloaded: magnetData.downloaded,
uploaded: magnetData.uploaded,
seeders: magnetData.seeders,
downloadSpeed: magnetData.downloadSpeed,
uploadSpeed: magnetData.uploadSpeed,
uploadDate: magnetData.uploadDate,
completionDate: magnetData.completionDate,
links: magnetData.links.map((link) => ({
link: link.link,
filename: link.filename,
size: link.size,
})),
};
logger.info(
"[AllDebrid] Magnet Status:",
JSON.stringify(magnetStatus, null, 2)
);
return magnetStatus;
} catch (error: any) {
logger.error("[AllDebrid] Check Magnet Status Error:", error);
throw error;
}
}
private static async unlockLink(link: string) {
try {
const response = await this.instance.get<{
status: string;
data?: { link: string };
error?: AllDebridError;
}>("/link/unlock", {
params: {
link,
},
});
if (response.data.status === "error") {
throw new Error(response.data.error?.message || "Unknown error");
}
const unlockedLink = response.data.data?.link;
if (!unlockedLink) {
throw new Error("No download link received from AllDebrid");
}
return unlockedLink;
} catch (error: any) {
logger.error("[AllDebrid] Unlock Link Error:", error);
throw error;
}
}
public static async getDownloadUrls(
uri: string
): Promise<AllDebridDownloadUrl[]> {
try {
logger.info("[AllDebrid] Getting download URLs for URI:", uri);
if (uri.startsWith("magnet:")) {
logger.info("[AllDebrid] Detected magnet link, uploading...");
// 1. Upload magnet
const magnetId = await this.uploadMagnet(uri);
logger.info("[AllDebrid] Magnet uploaded, ID:", magnetId);
// 2. Verificăm statusul până când avem link-uri
let retries = 0;
let magnetStatus: AllDebridMagnetStatus;
do {
magnetStatus = await this.checkMagnetStatus(magnetId);
logger.info(
"[AllDebrid] Magnet status:",
magnetStatus.status,
"statusCode:",
magnetStatus.statusCode
);
if (magnetStatus.statusCode === 4) {
// Ready
// Deblocăm fiecare link în parte și aruncăm eroare dacă oricare eșuează
const unlockedLinks = await Promise.all(
magnetStatus.links.map(async (link) => {
try {
const unlockedLink = await this.unlockLink(link.link);
logger.info(
"[AllDebrid] Successfully unlocked link:",
unlockedLink
);
return {
link: unlockedLink,
size: link.size,
filename: link.filename,
};
} catch (error) {
logger.error(
"[AllDebrid] Failed to unlock link:",
link.link,
error
);
throw new Error("Failed to unlock all links");
}
})
);
logger.info(
"[AllDebrid] Got unlocked download links:",
unlockedLinks
);
console.log("[AllDebrid] FINAL LINKS →", unlockedLinks);
return unlockedLinks;
}
if (retries++ > 30) {
// Maximum 30 de încercări
throw new Error("Timeout waiting for magnet to be ready");
}
await new Promise((resolve) => setTimeout(resolve, 2000)); // Așteptăm 2 secunde între verificări
} while (magnetStatus.statusCode !== 4);
} else {
logger.info("[AllDebrid] Regular link, unlocking...");
// Pentru link-uri normale, doar debridam link-ul
const downloadUrl = await this.unlockLink(uri);
logger.info("[AllDebrid] Got unlocked download URL:", downloadUrl);
return [
{
link: downloadUrl,
},
];
}
} catch (error: any) {
logger.error("[AllDebrid] Get Download URLs Error:", error);
throw error;
}
return []; // Add default return for TypeScript
}
}

View File

@@ -17,6 +17,7 @@ import {
} from "./types";
import { calculateETA, getDirSize } from "./helpers";
import { RealDebridClient } from "./real-debrid";
import { AllDebridClient } from "./all-debrid";
import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
@@ -40,6 +41,7 @@ export class DownloadManager {
})
: undefined,
downloadsToSeed?.map((download) => ({
action: "seed",
game_id: levelKeys.game(download.shop, download.objectId),
url: download.uri,
save_path: download.downloadPath,
@@ -377,6 +379,27 @@ export class DownloadManager {
allow_multiple_connections: true,
};
}
case Downloader.AllDebrid: {
const downloadUrls = await AllDebridClient.getDownloadUrls(
download.uri
);
if (!downloadUrls.length)
throw new Error(DownloadError.NotCachedInAllDebrid);
const totalSize = downloadUrls.reduce(
(total, url) => total + (url.size || 0),
0
);
return {
action: "start",
game_id: downloadId,
url: downloadUrls.map((d) => d.link),
save_path: download.downloadPath,
total_size: totalSize,
};
}
case Downloader.TorBox: {
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);

View File

@@ -17,17 +17,24 @@ export const calculateETA = (
};
export const getDirSize = async (dir: string): Promise<number> => {
const getItemSize = async (filePath: string): Promise<number> => {
const stat = await fs.promises.stat(filePath);
try {
const stat = await fs.promises.stat(dir);
if (stat.isDirectory()) {
return getDirSize(filePath);
// If it's a file, return its size directly
if (!stat.isDirectory()) {
return stat.size;
}
return stat.size;
};
const getItemSize = async (filePath: string): Promise<number> => {
const stat = await fs.promises.stat(filePath);
if (stat.isDirectory()) {
return getDirSize(filePath);
}
return stat.size;
};
try {
const files = await fs.promises.readdir(dir);
const filePaths = files.map((file) => path.join(dir, file));
const sizes = await Promise.all(filePaths.map(getItemSize));

View File

@@ -1,3 +1,4 @@
export * from "./download-manager";
export * from "./real-debrid";
export * from "./all-debrid";
export * from "./torbox";

View File

@@ -12,7 +12,7 @@ import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
interface HydraApiOptions {
export interface HydraApiOptions {
needsAuth?: boolean;
needsSubscription?: boolean;
ifModifiedSince?: Date;

View File

@@ -17,3 +17,4 @@ export * from "./system-path";
export * from "./library-sync";
export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";

View File

@@ -22,7 +22,8 @@ export const mergeWithRemoteGames = async () => {
const updatedLastTimePlayed =
localGame.lastTimePlayed == null ||
(game.lastTimePlayed &&
new Date(game.lastTimePlayed) > localGame.lastTimePlayed)
new Date(game.lastTimePlayed) >
new Date(localGame.lastTimePlayed))
? game.lastTimePlayed
: localGame.lastTimePlayed;
@@ -57,7 +58,11 @@ export const mergeWithRemoteGames = async () => {
});
}
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey);
await gamesShopAssetsSublevel.put(gameKey, {
updatedAt: Date.now(),
...localGameShopAsset,
shop: game.shop,
objectId: game.objectId,
title: localGame?.title || game.title, // Preserve local title if it exists

View File

@@ -10,7 +10,7 @@ import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
import { WindowManager } from "../window-manager";
import type { Game, GameStats, UserPreferences, UserProfile } from "@types";
import type { Game, UserPreferences, UserProfile } from "@types";
import { db, levelKeys } from "@main/level";
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
import { SystemPath } from "../system-path";
@@ -108,15 +108,14 @@ export const publishNewFriendRequestNotification = async (
};
export const publishFriendStartedPlayingGameNotification = async (
friend: UserProfile,
game: GameStats
friend: UserProfile
) => {
new Notification({
title: t("friend_started_playing_game", {
ns: "notifications",
displayName: friend.displayName,
}),
body: game.assets?.title,
body: friend?.currentGame?.title,
icon: friend?.profileImageUrl
? await downloadImage(friend.profileImageUrl)
: trayIcon,

View File

@@ -79,11 +79,18 @@ const findGamePathByProcess = async (
const executables = gameExecutables[gameId];
for (const executable of executables) {
const pathSet = processMap.get(executable.exe);
const executablewithoutExtension = executable.exe.replace(/\.exe$/i, "");
const pathSet =
processMap.get(executable.exe) ??
processMap.get(executablewithoutExtension);
if (pathSet) {
for (const path of pathSet) {
if (path.toLowerCase().endsWith(executable.name)) {
if (
path.toLowerCase().endsWith(executable.name) ||
path.toLowerCase().endsWith(executablewithoutExtension)
) {
const gameKey = levelKeys.game("steam", gameId);
const game = await gamesSublevel.get(gameKey);
@@ -124,7 +131,6 @@ const getSystemProcessMap = async () => {
if (!key || !value) return;
const STEAM_COMPAT_DATA_PATH = process.environ?.STEAM_COMPAT_DATA_PATH;
if (STEAM_COMPAT_DATA_PATH) {
winePrefixMap.set(value, STEAM_COMPAT_DATA_PATH);
}

View File

@@ -11,9 +11,13 @@ import { app, dialog } from "electron";
import { db, levelKeys } from "@main/level";
interface GamePayload {
action: string;
game_id: string;
url: string;
url: string | string[];
save_path: string;
header?: string;
out?: string;
total_size?: number;
}
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {

View File

@@ -10,7 +10,7 @@ export const requestSteam250 = async (path: string) => {
const { window } = new JSDOM(response.data);
const { document } = window;
return Array.from(document.querySelectorAll(".appline .title a"))
return Array.from(document.querySelectorAll("a[data-title]"))
.map(($title) => {
const steamGameUrl = ($title as HTMLAnchorElement).href;
if (!steamGameUrl) return null;

View File

@@ -19,7 +19,12 @@ export interface SteamAppDetailsResponse {
export const getSteamLocation = async () => {
if (process.platform === "linux") {
return path.join(SystemPath.getPath("home"), ".local", "share", "Steam");
const possiblePaths = [
path.join(SystemPath.getPath("home"), ".steam", "steam"),
path.join(SystemPath.getPath("home"), ".local", "share", "Steam"),
];
return possiblePaths.find((p) => fs.existsSync(p)) || possiblePaths[0];
}
if (process.platform === "darwin") {
@@ -76,7 +81,11 @@ export const getSteamAppDetails = async (
return null;
})
.catch((err) => {
logger.error(err, { method: "getSteamAppDetails" });
logger.error("Error on getSteamAppDetails", {
message: err?.message,
code: err?.code,
name: err?.name,
});
return null;
});
};

View File

@@ -8,58 +8,65 @@ import { levelKeys } from "@main/level/sublevels";
export const getUserData = async () => {
return HydraApi.get<UserDetails>(`/profile/me`)
.then(async (me) => {
db.get<string, User>(levelKeys.user, { valueEncoding: "json" }).then(
(user) => {
return db.put<string, User>(
levelKeys.user,
{
...user,
id: me.id,
displayName: me.displayName,
profileImageUrl: me.profileImageUrl,
backgroundImageUrl: me.backgroundImageUrl,
subscription: me.subscription,
},
{ valueEncoding: "json" }
);
}
);
try {
const user = await db.get<string, User>(levelKeys.user, {
valueEncoding: "json",
});
await db.put<string, User>(
levelKeys.user,
{
...user,
id: me.id,
displayName: me.displayName,
profileImageUrl: me.profileImageUrl,
backgroundImageUrl: me.backgroundImageUrl,
subscription: me.subscription,
},
{ valueEncoding: "json" }
);
} catch (error) {
logger.error("Failed to update user in DB", error);
}
return me;
})
.catch(async (err) => {
if (err instanceof UserNotLoggedInError) {
return null;
}
logger.error("Failed to get logged user");
const loggedUser = await db.get<string, User>(levelKeys.user, {
valueEncoding: "json",
});
logger.error("Failed to get logged user", err);
if (loggedUser) {
return {
...loggedUser,
username: "",
bio: "",
email: null,
profileVisibility: "PUBLIC" as ProfileVisibility,
quirks: {
backupsPerGameLimit: 0,
},
subscription: loggedUser.subscription
? {
id: loggedUser.subscription.id,
status: loggedUser.subscription.status,
plan: {
id: loggedUser.subscription.plan.id,
name: loggedUser.subscription.plan.name,
},
expiresAt: loggedUser.subscription.expiresAt,
}
: null,
featurebaseJwt: "",
} as UserDetails;
try {
const loggedUser = await db.get<string, User>(levelKeys.user, {
valueEncoding: "json",
});
if (loggedUser) {
return {
...loggedUser,
username: "",
bio: "",
email: null,
profileVisibility: "PUBLIC" as ProfileVisibility,
quirks: {
backupsPerGameLimit: 0,
},
subscription: loggedUser.subscription
? {
id: loggedUser.subscription.id,
status: loggedUser.subscription.status,
plan: {
id: loggedUser.subscription.plan.id,
name: loggedUser.subscription.plan.name,
},
expiresAt: loggedUser.subscription.expiresAt,
}
: null,
featurebaseJwt: "",
} as UserDetails;
}
} catch (dbError) {
logger.error("Failed to read user from DB", dbError);
}
return null;

View File

@@ -54,20 +54,37 @@ export class WindowManager {
show: false,
};
private static loadMainWindowURL(hash = "") {
private static async loadWindowURL(window: BrowserWindow, hash: string = "") {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.mainWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`
);
} else {
this.mainWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
window.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`);
} else if (process.env["MAIN_VITE_RENDERER_URL"]) {
// Try to load from remote URL in production
try {
await window.loadURL(
`${import.meta.env.MAIN_VITE_RENDERER_URL}#/${hash}`
);
} catch (error) {
// Fall back to local file if remote URL fails
console.error(
"Failed to load from MAIN_VITE_RENDERER_URL, falling back to local file:",
error
);
window.loadFile(path.join(__dirname, "../renderer/index.html"), {
hash,
}
);
});
}
} else {
window.loadFile(path.join(__dirname, "../renderer/index.html"), {
hash,
});
}
}
private static async loadMainWindowURL(hash: string = "") {
if (this.mainWindow) {
await this.loadWindowURL(this.mainWindow, hash);
}
}
@@ -268,17 +285,8 @@ export class WindowManager {
}
private static loadNotificationWindowURL() {
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.notificationWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification`
);
} else {
this.notificationWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash: "achievement-notification",
}
);
if (this.notificationWindow) {
this.loadWindowURL(this.notificationWindow, "achievement-notification");
}
}
@@ -450,19 +458,10 @@ export class WindowManager {
editorWindow.removeMenu();
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
editorWindow.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/theme-editor?themeId=${themeId}`
);
} else {
editorWindow.loadFile(path.join(__dirname, "../renderer/index.html"), {
hash: `theme-editor?themeId=${themeId}`,
});
}
this.loadWindowURL(editorWindow, `theme-editor?themeId=${themeId}`);
editorWindow.once("ready-to-show", () => {
editorWindow.show();
this.mainWindow?.webContents.openDevTools();
if (!app.isPackaged || isStaging) {
editorWindow.webContents.openDevTools();
}
@@ -470,12 +469,11 @@ export class WindowManager {
editorWindow.webContents.on("before-input-event", (_event, input) => {
if (input.key === "F12") {
this.mainWindow?.webContents.toggleDevTools();
editorWindow.webContents.toggleDevTools();
}
});
editorWindow.on("close", () => {
this.mainWindow?.webContents.closeDevTools();
this.editorWindows.delete(themeId);
});
}

View File

@@ -2,7 +2,7 @@ import type { FriendGameSession } from "@main/generated/envelope";
import { db, levelKeys } from "@main/level";
import { HydraApi } from "@main/services/hydra-api";
import { publishFriendStartedPlayingGameNotification } from "@main/services/notifications";
import type { GameStats, UserPreferences, UserProfile } from "@types";
import type { UserPreferences, UserProfile } from "@types";
export const friendGameSessionEvent = async (payload: FriendGameSession) => {
const userPreferences = await db.get<string, UserPreferences | null>(
@@ -14,14 +14,9 @@ export const friendGameSessionEvent = async (payload: FriendGameSession) => {
if (userPreferences?.friendStartGameNotificationsEnabled === false) return;
const [friend, gameStats] = await Promise.all([
HydraApi.get<UserProfile>(`/users/${payload.friendId}`),
HydraApi.get<GameStats>(
`/games/stats?objectId=${payload.objectId}&shop=steam`
),
]).catch(() => [null, null]);
const friend = await HydraApi.get<UserProfile>(`/users/${payload.friendId}`);
if (friend && gameStats) {
publishFriendStartedPlayingGameNotification(friend, gameStats);
if (friend) {
publishFriendStartedPlayingGameNotification(friend);
}
};

View File

@@ -7,6 +7,8 @@ interface ImportMetaEnv {
readonly MAIN_VITE_CHECKOUT_URL: string;
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
readonly MAIN_VITE_WS_URL: string;
readonly MAIN_VITE_RENDERER_URL: string;
readonly ELECTRON_RENDERER_URL: string;
}
interface ImportMeta {

View File

@@ -11,17 +11,15 @@ import type {
GameRunning,
FriendRequestAction,
UpdateProfileRequest,
CatalogueSearchPayload,
SeedingStatus,
GameAchievement,
Theme,
FriendRequestSync,
ShortcutLocation,
ShopAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import type { AuthPage, CatalogueCategory } from "@shared";
import type { AuthPage } from "@shared";
import type { AxiosProgressEvent } from "axios";
contextBridge.exposeInMainWorld("electron", {
@@ -63,20 +61,13 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("checkDebridAvailability", magnets),
/* Catalogue */
searchGames: (payload: CatalogueSearchPayload, take: number, skip: number) =>
ipcRenderer.invoke("searchGames", payload, take, skip),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
saveGameShopAssets: (objectId: string, shop: GameShop, assets: ShopAssets) =>
ipcRenderer.invoke("saveGameShopAssets", objectId, shop, assets),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
getHowLongToBeat: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getHowLongToBeat", objectId, shop),
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
getGameAssets: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameAssets", objectId, shop),
onUpdateAchievements: (
objectId: string,
shop: GameShop,
@@ -102,6 +93,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("autoLaunch", autoLaunchProps),
authenticateRealDebrid: (apiToken: string) =>
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
authenticateAllDebrid: (apiKey: string) =>
ipcRenderer.invoke("authenticateAllDebrid", apiKey),
authenticateTorBox: (apiToken: string) =>
ipcRenderer.invoke("authenticateTorBox", apiToken),
@@ -284,10 +277,6 @@ contextBridge.exposeInMainWorld("electron", {
downloadOptionTitle: string | null
) =>
ipcRenderer.invoke("uploadSaveGame", objectId, shop, downloadOptionTitle),
toggleArtifactFreeze: (gameArtifactId: string, freeze: boolean) =>
ipcRenderer.invoke("toggleArtifactFreeze", gameArtifactId, freeze),
renameGameArtifact: (gameArtifactId: string, label: string) =>
ipcRenderer.invoke("renameGameArtifact", gameArtifactId, label),
downloadGameArtifact: (
objectId: string,
shop: GameShop,
@@ -298,8 +287,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
getGameBackupPreview: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
deleteGameArtifact: (gameArtifactId: string) =>
ipcRenderer.invoke("deleteGameArtifact", gameArtifactId),
selectGameBackupPath: (
shop: GameShop,
objectId: string,
@@ -356,10 +343,99 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("showOpenDialog", options),
showItemInFolder: (path: string) =>
ipcRenderer.invoke("showItemInFolder", path),
getFeatures: () => ipcRenderer.invoke("getFeatures"),
getBadges: () => ipcRenderer.invoke("getBadges"),
hydraApi: {
get: (
url: string,
options?: {
params?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
ifModifiedSince?: Date;
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "get",
url,
params: options?.params,
options: {
needsAuth: options?.needsAuth,
needsSubscription: options?.needsSubscription,
ifModifiedSince: options?.ifModifiedSince,
},
}),
post: (
url: string,
options?: {
data?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "post",
url,
data: options?.data,
options: {
needsAuth: options?.needsAuth,
needsSubscription: options?.needsSubscription,
},
}),
put: (
url: string,
options?: {
data?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "put",
url,
data: options?.data,
options: {
needsAuth: options?.needsAuth,
needsSubscription: options?.needsSubscription,
},
}),
patch: (
url: string,
options?: {
data?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "patch",
url,
data: options?.data,
options: {
needsAuth: options?.needsAuth,
needsSubscription: options?.needsSubscription,
},
}),
delete: (
url: string,
options?: {
needsAuth?: boolean;
needsSubscription?: boolean;
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "delete",
url,
options: {
needsAuth: options?.needsAuth,
needsSubscription: options?.needsSubscription,
},
}),
},
canInstallCommonRedist: () => ipcRenderer.invoke("canInstallCommonRedist"),
installCommonRedist: () => ipcRenderer.invoke("installCommonRedist"),
installHydraDeckyPlugin: () => ipcRenderer.invoke("installHydraDeckyPlugin"),
getHydraDeckyPluginInfo: () => ipcRenderer.invoke("getHydraDeckyPluginInfo"),
checkHomebrewFolderExists: () =>
ipcRenderer.invoke("checkHomebrewFolderExists"),
platform: process.platform,
/* Auto update */
@@ -390,13 +466,10 @@ contextBridge.exposeInMainWorld("electron", {
/* Profile */
getMe: () => ipcRenderer.invoke("getMe"),
undoFriendship: (userId: string) =>
ipcRenderer.invoke("undoFriendship", userId),
updateProfile: (updateProfile: UpdateProfileRequest) =>
ipcRenderer.invoke("updateProfile", updateProfile),
processProfileImage: (imagePath: string) =>
ipcRenderer.invoke("processProfileImage", imagePath),
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
const listener = (
@@ -409,26 +482,8 @@ contextBridge.exposeInMainWorld("electron", {
},
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
sendFriendRequest: (userId: string) =>
ipcRenderer.invoke("sendFriendRequest", userId),
/* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
getUserLibrary: (
userId: string,
take?: number,
skip?: number,
sortBy?: string
) => ipcRenderer.invoke("getUserLibrary", userId, take, skip, sortBy),
blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId),
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
getUserFriends: (userId: string, take: number, skip: number) =>
ipcRenderer.invoke("getUserFriends", userId, take, skip),
getBlockedUsers: (take: number, skip: number) =>
ipcRenderer.invoke("getBlockedUsers", take, skip),
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
reportUser: (userId: string, reason: string, description: string) =>
ipcRenderer.invoke("reportUser", userId, reason, description),
getComparedUnlockedAchievements: (
objectId: string,
shop: GameShop,

View File

@@ -27,6 +27,7 @@ import { downloadSourcesWorker } from "./workers";
import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { generateUUID } from "./helpers";
import { injectCustomCss, removeCustomCss } from "./helpers";
import "./app.scss";
@@ -212,7 +213,7 @@ export function App() {
useEffect(() => {
updateRepacks();
const id = crypto.randomUUID();
const id = generateUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
channel.onmessage = async (event: MessageEvent<number>) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View File

@@ -8,7 +8,7 @@
&__actions {
display: flex;
align-self: flex-end;
gap: calc(globals.$spacing-unit * 2);
gap: globals.$spacing-unit;
}
&__description {
font-size: 16px;

View File

@@ -42,7 +42,7 @@ export function ConfirmationModal({
{cancelButtonLabel}
</Button>
<Button
theme="danger"
theme="primary"
disabled={buttonsIsDisabled}
onClick={onConfirm}
>

View File

@@ -0,0 +1,158 @@
@use "../../scss/globals.scss";
.context-menu {
position: fixed;
z-index: 1000;
background-color: globals.$background-color;
border: 1px solid globals.$border-color;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
padding: 4px 0;
min-width: 180px;
backdrop-filter: blur(8px);
&__list {
list-style: none;
margin: 0;
padding: 0;
}
&__item-container {
position: relative;
}
&__item {
width: 100%;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1.5);
padding: 8px 12px;
background: transparent;
border: none;
color: globals.$body-color;
cursor: pointer;
font-size: globals.$body-font-size;
text-align: left;
transition: background-color 0.15s ease;
&:hover:not(&--disabled) {
background-color: rgba(255, 255, 255, 0.1);
}
&:active:not(&--disabled) {
background-color: rgba(255, 255, 255, 0.15);
}
&--disabled {
color: globals.$muted-color;
cursor: not-allowed;
opacity: 0.6;
}
&--danger {
color: globals.$danger-color;
&:hover:not(.context-menu__item--disabled) {
background-color: rgba(128, 29, 30, 0.1);
}
.context-menu__item-icon {
color: globals.$danger-color;
}
}
&--has-submenu {
position: relative;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&--active {
background-color: rgba(255, 255, 255, 0.1);
}
}
&__item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
}
&__item-label {
flex: 1;
white-space: nowrap;
}
&__item-arrow {
font-size: 10px;
color: globals.$muted-color;
margin-left: auto;
}
&__submenu {
position: absolute;
left: 100%;
top: 0;
background-color: globals.$background-color;
border: 1px solid globals.$border-color;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
padding: 4px 0;
min-width: 160px;
backdrop-filter: blur(8px);
margin-left: 0;
z-index: 1200;
pointer-events: auto;
max-height: 60vh;
overflow-y: auto;
}
&__content {
border-top: 1px solid globals.$border-color;
padding: 8px 12px;
margin-top: 4px;
}
&__item + &__item {
border-top: 1px solid transparent;
}
&__item--danger:first-of-type {
border-top: 1px solid globals.$border-color;
margin-top: 4px;
}
&--game-not-installed &__submenu &__item--danger:first-of-type {
border-top: none;
margin-top: 0;
}
&__separator {
height: 1px;
background: globals.$border-color;
margin: 6px 8px;
border-radius: 1px;
}
}
.context-menu {
animation: contextMenuFadeIn 0.15s ease-out;
}
@keyframes contextMenuFadeIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}

View File

@@ -0,0 +1,254 @@
import React, { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import cn from "classnames";
import "./context-menu.scss";
export interface ContextMenuItemData {
id: string;
label: string;
icon?: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
danger?: boolean;
separator?: boolean;
submenu?: ContextMenuItemData[];
}
export interface ContextMenuProps {
items: ContextMenuItemData[];
visible: boolean;
position: { x: number; y: number };
onClose: () => void;
children?: React.ReactNode;
className?: string;
}
export function ContextMenu({
items,
visible,
position,
onClose,
children,
className,
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const submenuCloseTimeout = useRef<number | null>(null);
const itemRefs = useRef<Record<string, HTMLLIElement | null>>({});
const [submenuStyles, setSubmenuStyles] = useState<
Record<string, React.CSSProperties>
>({});
useEffect(() => {
if (!visible) return;
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [visible, onClose]);
useEffect(() => {
if (!visible || !menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = position.x;
let adjustedY = position.y;
if (position.x + rect.width > viewportWidth) {
adjustedX = viewportWidth - rect.width - 10;
}
if (position.y + rect.height > viewportHeight) {
adjustedY = viewportHeight - rect.height - 10;
}
setAdjustedPosition({ x: adjustedX, y: adjustedY });
}, [visible, position]);
useEffect(() => {
if (!visible) {
setActiveSubmenu(null);
}
}, [visible]);
const handleItemClick = (item: ContextMenuItemData) => {
if (item.disabled) return;
if (item.submenu) {
setActiveSubmenu(activeSubmenu === item.id ? null : item.id);
return;
}
if (item.onClick) {
item.onClick();
onClose();
}
};
const handleSubmenuMouseEnter = (itemId: string) => {
if (submenuCloseTimeout.current) {
window.clearTimeout(submenuCloseTimeout.current);
submenuCloseTimeout.current = null;
}
setActiveSubmenu(itemId);
};
const handleSubmenuMouseLeave = () => {
if (submenuCloseTimeout.current) {
window.clearTimeout(submenuCloseTimeout.current);
}
submenuCloseTimeout.current = window.setTimeout(() => {
setActiveSubmenu(null);
submenuCloseTimeout.current = null;
}, 120);
};
useEffect(() => {
if (!activeSubmenu) return;
const parentEl = itemRefs.current[activeSubmenu];
if (!parentEl) return;
const submenuEl = parentEl.querySelector(
".context-menu__submenu"
) as HTMLElement | null;
if (!submenuEl) return;
const parentRect = parentEl.getBoundingClientRect();
const submenuWidth = submenuEl.offsetWidth;
const submenuHeight = submenuEl.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const styles: React.CSSProperties = {};
if (parentRect.right + submenuWidth > viewportWidth - 8) {
styles.left = "auto";
styles.right = "100%";
} else {
styles.left = "100%";
styles.right = undefined;
}
const overflowBottom = parentRect.top + submenuHeight - viewportHeight;
if (overflowBottom > 0) {
const topAdjust = Math.min(overflowBottom + 8, parentRect.top - 8);
styles.top = `${-topAdjust}px`;
} else {
styles.top = undefined;
}
setSubmenuStyles((prev) => ({ ...prev, [activeSubmenu]: styles }));
}, [activeSubmenu]);
if (!visible) return null;
const menuContent = (
<div
ref={menuRef}
className={cn("context-menu", className)}
style={{
left: adjustedPosition.x,
top: adjustedPosition.y,
}}
>
<ul className="context-menu__list">
{items.map((item) => (
<li
key={item.id}
ref={(el) => (itemRefs.current[item.id] = el)}
className="context-menu__item-container"
onMouseEnter={() =>
item.submenu && handleSubmenuMouseEnter(item.id)
}
onMouseLeave={() => item.submenu && handleSubmenuMouseLeave()}
>
{item.separator && <div className="context-menu__separator" />}
<button
type="button"
className={cn("context-menu__item", {
"context-menu__item--disabled": item.disabled,
"context-menu__item--danger": item.danger,
"context-menu__item--has-submenu": item.submenu,
"context-menu__item--active": activeSubmenu === item.id,
})}
onClick={() => handleItemClick(item)}
disabled={item.disabled}
>
{item.icon && (
<span className="context-menu__item-icon">{item.icon}</span>
)}
<span className="context-menu__item-label">{item.label}</span>
{item.submenu && (
<span className="context-menu__item-arrow"></span>
)}
</button>
{item.submenu && activeSubmenu === item.id && (
<div
className="context-menu__submenu"
style={submenuStyles[item.id] || undefined}
onMouseEnter={() => handleSubmenuMouseEnter(item.id)}
onMouseLeave={() => handleSubmenuMouseLeave()}
>
<ul className="context-menu__list">
{item.submenu.map((subItem) => (
<li
key={subItem.id}
className="context-menu__item-container"
>
{subItem.separator && (
<div className="context-menu__separator" />
)}
<button
type="button"
className={cn("context-menu__item", {
"context-menu__item--disabled": subItem.disabled,
"context-menu__item--danger": subItem.danger,
})}
onClick={() => handleItemClick(subItem)}
disabled={subItem.disabled}
>
{subItem.icon && (
<span className="context-menu__item-icon">
{subItem.icon}
</span>
)}
<span className="context-menu__item-label">
{subItem.label}
</span>
</button>
</li>
))}
</ul>
</div>
)}
</li>
))}
</ul>
{children && <div className="context-menu__content">{children}</div>}
</div>
);
return createPortal(menuContent, document.body);
}

View File

@@ -72,7 +72,11 @@
display: flex;
color: globals.$muted-color;
font-size: 12px;
align-items: flex-end;
align-items: center;
.star-rating {
align-items: center;
}
}
&__title-container {

View File

@@ -7,6 +7,7 @@ import "./game-card.scss";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { StarRating } from "../star-rating/star-rating";
import { useCallback, useState, useMemo } from "react";
import { useFormat, useRepacks } from "@renderer/hooks";
@@ -107,6 +108,14 @@ export function GameCard({ game, ...props }: GameCardProps) {
{stats ? numberFormatter.format(stats.playerCount) : "…"}
</span>
</div>
<div className="game-card__specifics-item">
<StarRating
rating={stats?.averageScore || null}
size={14}
showCalculating={!!(stats && stats.averageScore === null)}
calculatingText={t("calculating")}
/>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,251 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import {
PlayIcon,
DownloadIcon,
HeartIcon,
HeartFillIcon,
GearIcon,
PencilIcon,
FileDirectoryIcon,
LinkIcon,
TrashIcon,
XIcon,
} from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { LibraryGame } from "@types";
import {
ContextMenu,
ContextMenuItemData,
ContextMenuProps,
ConfirmationModal,
useGameActions,
} from "..";
interface GameContextMenuProps extends Omit<ContextMenuProps, "items"> {
game: LibraryGame;
}
export function GameContextMenu({
game,
visible,
position,
onClose,
}: GameContextMenuProps) {
const { t } = useTranslation("game_details");
const [showConfirmRemoveLibrary, setShowConfirmRemoveLibrary] =
useState(false);
const [showConfirmRemoveFiles, setShowConfirmRemoveFiles] = useState(false);
const {
canPlay,
isDeleting,
isGameDownloading,
isGameRunning,
hasRepacks,
shouldShowCreateStartMenuShortcut,
handlePlayGame,
handleCloseGame,
handleToggleFavorite,
handleCreateShortcut,
handleCreateSteamShortcut,
handleOpenFolder,
handleOpenDownloadOptions,
handleOpenDownloadLocation,
handleRemoveFromLibrary,
handleRemoveFiles,
handleOpenGameOptions,
} = useGameActions(game);
const items: ContextMenuItemData[] = [
{
id: "play",
label: isGameRunning ? t("close") : canPlay ? t("play") : t("download"),
icon: isGameRunning ? (
<XIcon size={16} />
) : canPlay ? (
<PlayIcon size={16} />
) : (
<DownloadIcon size={16} />
),
onClick: () => {
if (isGameRunning) {
void handleCloseGame();
} else {
void handlePlayGame();
}
},
disabled: isDeleting,
},
{
id: "favorite",
label: game.favorite ? t("remove_from_favorites") : t("add_to_favorites"),
icon: game.favorite ? (
<HeartFillIcon size={16} />
) : (
<HeartIcon size={16} />
),
onClick: () => {
void handleToggleFavorite();
},
disabled: isDeleting,
},
...(game.executablePath
? [
{
id: "shortcuts",
label: t("create_shortcut_simple"),
icon: <LinkIcon size={16} />,
disabled: isDeleting,
submenu: [
{
id: "desktop-shortcut",
label: t("create_shortcut"),
icon: <LinkIcon size={16} />,
onClick: () => handleCreateShortcut("desktop"),
disabled: isDeleting,
},
{
id: "steam-shortcut",
label: t("create_steam_shortcut"),
icon: <SteamLogo style={{ width: 16, height: 16 }} />,
onClick: handleCreateSteamShortcut,
disabled: isDeleting,
},
...(shouldShowCreateStartMenuShortcut
? [
{
id: "start-menu-shortcut",
label: t("create_start_menu_shortcut"),
icon: <LinkIcon size={16} />,
onClick: () => handleCreateShortcut("start_menu"),
disabled: isDeleting,
},
]
: []),
],
},
]
: []),
{
id: "manage",
label: t("options"),
icon: <GearIcon size={16} />,
disabled: isDeleting,
submenu: [
...(game.executablePath
? [
{
id: "open-folder",
label: t("open_folder"),
icon: <FileDirectoryIcon size={16} />,
onClick: handleOpenFolder,
disabled: isDeleting,
},
]
: []),
...(game.executablePath
? [
{
id: "download-options",
label: t("open_download_options"),
icon: <PlayIcon size={16} />,
onClick: handleOpenDownloadOptions,
disabled: isDeleting || isGameDownloading || !hasRepacks,
},
]
: []),
...(game.download?.downloadPath
? [
{
id: "download-location",
label: t("open_download_location"),
icon: <FileDirectoryIcon size={16} />,
onClick: handleOpenDownloadLocation,
disabled: isDeleting,
},
]
: []),
{
id: "remove-library",
label: t("remove_from_library"),
icon: <XIcon size={16} />,
onClick: () => setShowConfirmRemoveLibrary(true),
disabled: isDeleting,
danger: true,
},
...(game.download?.downloadPath
? [
{
id: "remove-files",
label: t("remove_files"),
icon: <TrashIcon size={16} />,
onClick: () => setShowConfirmRemoveFiles(true),
disabled: isDeleting || isGameDownloading,
danger: true,
},
]
: []),
],
},
{
id: "properties",
label: t("properties"),
separator: true,
icon: <PencilIcon size={16} />,
onClick: () => handleOpenGameOptions(),
disabled: isDeleting,
},
];
return (
<>
<ContextMenu
items={items}
visible={visible}
position={position}
onClose={onClose}
className={
!game.executablePath ? "context-menu--game-not-installed" : undefined
}
/>
<ConfirmationModal
visible={showConfirmRemoveLibrary}
title={t("remove_from_library_title")}
descriptionText={t("remove_from_library_description", {
game: game.title,
})}
onClose={() => {
setShowConfirmRemoveLibrary(false);
onClose();
}}
onConfirm={async () => {
setShowConfirmRemoveLibrary(false);
onClose();
await handleRemoveFromLibrary();
}}
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
/>
<ConfirmationModal
visible={showConfirmRemoveFiles}
title={t("remove_files")}
descriptionText={t("delete_modal_description", { ns: "downloads" })}
onClose={() => {
setShowConfirmRemoveFiles(false);
onClose();
}}
onConfirm={async () => {
setShowConfirmRemoveFiles(false);
onClose();
await handleRemoveFiles();
}}
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
/>
</>
);
}

View File

@@ -0,0 +1,282 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { LibraryGame, ShortcutLocation } from "@types";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import { useNavigate, useLocation } from "react-router-dom";
import { buildGameDetailsPath } from "@renderer/helpers";
import { logger } from "@renderer/logger";
export function useGameActions(game: LibraryGame) {
const { t } = useTranslation("game_details");
const { showSuccessToast, showErrorToast } = useToast();
const { updateLibrary } = useLibrary();
const navigate = useNavigate();
const location = useLocation();
const {
removeGameInstaller,
removeGameFromLibrary,
isGameDeleting,
lastPacket,
cancelDownload,
} = useDownload();
const [creatingSteamShortcut, setCreatingSteamShortcut] = useState(false);
const [isGameRunning, setIsGameRunning] = useState(false);
const canPlay = Boolean(game.executablePath);
const isDeleting = isGameDeleting(game.id);
const isGameDownloading =
game.download?.status === "active" && lastPacket?.gameId === game.id;
const hasRepacks = true;
const shouldShowCreateStartMenuShortcut =
window.electron.platform === "win32";
useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
const updatedIsGameRunning =
!!game?.id &&
!!gamesIds.find((gameRunning) => gameRunning.id == game.id);
setIsGameRunning(updatedIsGameRunning);
});
return () => {
unsubscribe();
};
}, [game?.id]);
const handlePlayGame = async () => {
if (!canPlay) {
const path = buildGameDetailsPath({
...game,
objectId: game.objectId,
});
if (location.pathname === path) {
try {
window.dispatchEvent(
new CustomEvent("hydra:openRepacks", {
detail: { objectId: game.objectId },
})
);
} catch (e) {
void e;
}
} else {
navigate(path, { state: { openRepacks: true } });
try {
window.dispatchEvent(
new CustomEvent("hydra:openRepacks", {
detail: { objectId: game.objectId },
})
);
} catch (e) {
void e;
}
}
return;
}
try {
await window.electron.openGame(
game.shop,
game.objectId,
game.executablePath!,
game.launchOptions
);
} catch (error) {
showErrorToast("Failed to start game");
logger.error("Failed to start game", error);
}
};
const handleCloseGame = async () => {
try {
await window.electron.closeGame(game.shop, game.objectId);
} catch (error) {
showErrorToast("Failed to close game");
logger.error("Failed to close game", error);
}
};
const handleToggleFavorite = async () => {
try {
if (game.favorite) {
await window.electron.removeGameFromFavorites(game.shop, game.objectId);
showSuccessToast(t("game_removed_from_favorites"));
} else {
await window.electron.addGameToFavorites(game.shop, game.objectId);
showSuccessToast(t("game_added_to_favorites"));
}
updateLibrary();
try {
window.dispatchEvent(
new CustomEvent("hydra:game-favorite-toggled", {
detail: { shop: game.shop, objectId: game.objectId },
})
);
} catch (e) {
void e;
}
} catch (error) {
showErrorToast(t("failed_update_favorites"));
logger.error("Failed to toggle favorite", error);
}
};
const handleCreateShortcut = async (location: ShortcutLocation) => {
try {
const success = await window.electron.createGameShortcut(
game.shop,
game.objectId,
location
);
if (success) {
showSuccessToast(t("create_shortcut_success"));
} else {
showErrorToast(t("create_shortcut_error"));
}
} catch (error) {
showErrorToast(t("create_shortcut_error"));
logger.error("Failed to create shortcut", error);
}
};
const handleCreateSteamShortcut = async () => {
try {
setCreatingSteamShortcut(true);
await window.electron.createSteamShortcut(game.shop, game.objectId);
showSuccessToast(
t("create_shortcut_success"),
t("you_might_need_to_restart_steam")
);
} catch (error) {
logger.error("Failed to create Steam shortcut", error);
showErrorToast(t("create_shortcut_error"));
} finally {
setCreatingSteamShortcut(false);
}
};
const handleOpenFolder = async () => {
try {
await window.electron.openGameExecutablePath(game.shop, game.objectId);
} catch (error) {
showErrorToast("Failed to open folder");
logger.error("Failed to open folder", error);
}
};
const handleOpenDownloadOptions = () => {
const path = buildGameDetailsPath({
...game,
objectId: game.objectId,
});
navigate(path, { state: { openRepacks: true } });
try {
window.dispatchEvent(
new CustomEvent("hydra:openRepacks", {
detail: { objectId: game.objectId },
})
);
} catch (e) {
void e;
}
};
const handleOpenGameOptions = () => {
const path = buildGameDetailsPath({
...game,
objectId: game.objectId,
});
navigate(path, { state: { openGameOptions: true } });
try {
window.dispatchEvent(
new CustomEvent("hydra:openGameOptions", {
detail: { objectId: game.objectId },
})
);
} catch (e) {
void e;
}
};
const handleOpenDownloadLocation = async () => {
try {
await window.electron.openGameInstallerPath(game.shop, game.objectId);
} catch (error) {
showErrorToast("Failed to open download location");
logger.error("Failed to open download location", error);
}
};
const handleRemoveFromLibrary = async () => {
try {
if (isGameDownloading) {
await cancelDownload(game.shop, game.objectId);
}
await removeGameFromLibrary(game.shop, game.objectId);
updateLibrary();
showSuccessToast(t("game_removed_from_library"));
try {
window.dispatchEvent(
new CustomEvent("hydra:game-removed-from-library", {
detail: { shop: game.shop, objectId: game.objectId },
})
);
} catch (e) {
void e;
}
} catch (error) {
showErrorToast(t("failed_remove_from_library"));
logger.error("Failed to remove from library", error);
}
};
const handleRemoveFiles = async () => {
try {
await removeGameInstaller(game.shop, game.objectId);
updateLibrary();
showSuccessToast(t("files_removed_success"));
try {
window.dispatchEvent(
new CustomEvent("hydra:game-files-removed", {
detail: { shop: game.shop, objectId: game.objectId },
})
);
} catch (e) {
void e;
}
} catch (error) {
showErrorToast(t("failed_remove_files"));
logger.error("Failed to remove files", error);
}
};
return {
canPlay,
isDeleting,
isGameDownloading,
isGameRunning,
hasRepacks,
shouldShowCreateStartMenuShortcut,
creatingSteamShortcut,
handlePlayGame,
handleCloseGame,
handleToggleFavorite,
handleCreateShortcut,
handleCreateSteamShortcut,
handleOpenFolder,
handleOpenDownloadOptions,
handleOpenDownloadLocation,
handleRemoveFromLibrary,
handleRemoveFiles,
handleOpenGameOptions,
};
}

View File

@@ -18,10 +18,18 @@ export function Hero() {
useEffect(() => {
setIsLoading(true);
window.electron
.getTrendingGames()
const language = i18n.language.split("-")[0];
window.electron.hydraApi
.get<TrendingGame[]>("/catalogue/featured", {
params: { language },
needsAuth: false,
})
.then((result) => {
setFeaturedGameDetails(result);
setFeaturedGameDetails(result.slice(0, 1));
})
.catch(() => {
setFeaturedGameDetails([]);
})
.finally(() => {
setIsLoading(false);

View File

@@ -15,3 +15,7 @@ export * from "./badge/badge";
export * from "./confirmation-modal/confirmation-modal";
export * from "./suspense-wrapper/suspense-wrapper";
export * from "./debrid-badge/debrid-badge";
export * from "./context-menu/context-menu";
export * from "./game-context-menu/game-context-menu";
export * from "./game-context-menu/use-game-actions";
export * from "./star-rating/star-rating";

View File

@@ -3,6 +3,8 @@ import PlayLogo from "@renderer/assets/play-logo.svg?react";
import { LibraryGame } from "@types";
import cn from "classnames";
import { useLocation } from "react-router-dom";
import { useState } from "react";
import { GameContextMenu } from "..";
interface SidebarGameItemProps {
game: LibraryGame;
@@ -16,6 +18,24 @@ export function SidebarGameItem({
getGameTitle,
}: Readonly<SidebarGameItemProps>) {
const location = useLocation();
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
position: { x: number; y: number };
}>({ visible: false, position: { x: 0, y: 0 } });
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({
visible: true,
position: { x: event.clientX, y: event.clientY },
});
};
const handleCloseContextMenu = () => {
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
};
const isCustomGame = game.shop === "custom";
const sidebarIcon = isCustomGame
@@ -31,34 +51,44 @@ export function SidebarGameItem({
};
return (
<li
key={game.id}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
location.pathname === `/game/${game.shop}/${game.objectId}`,
"sidebar__menu-item--muted": game.download?.status === "removed",
})}
>
<button
type="button"
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
<>
<li
key={game.id}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
location.pathname === `/game/${game.shop}/${game.objectId}`,
"sidebar__menu-item--muted": game.download?.status === "removed",
})}
>
{sidebarIcon ? (
<img
className="sidebar__game-icon"
src={sidebarIcon}
alt={game.title}
loading="lazy"
/>
) : (
getFallbackIcon()
)}
<button
type="button"
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
onContextMenu={handleContextMenu}
>
{sidebarIcon ? (
<img
className="sidebar__game-icon"
src={sidebarIcon}
alt={game.title}
loading="lazy"
/>
) : (
getFallbackIcon()
)}
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
</button>
</li>
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
</button>
</li>
<GameContextMenu
game={game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
</>
);
}

View File

@@ -71,6 +71,23 @@
opacity: 1;
}
}
&--decky {
background: linear-gradient(
135deg,
rgba(22, 177, 149, 0.2) 0%,
rgba(62, 98, 192, 0.1) 100%
);
color: globals.$muted-color;
&:hover {
background: linear-gradient(
135deg,
rgba(22, 177, 149, 0.3) 0%,
rgba(62, 98, 192, 0.15) 100%
);
}
}
}
&__menu-item-button {
@@ -123,6 +140,11 @@
padding-bottom: globals.$spacing-unit;
}
&__bottom-buttons {
display: flex;
flex-direction: column;
}
&__help-button {
color: globals.$muted-color;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);

View File

@@ -5,7 +5,7 @@ import { Tooltip } from "react-tooltip";
import type { LibraryGame } from "@types";
import { TextField } from "@renderer/components";
import { TextField, ConfirmationModal } from "@renderer/components";
import {
useDownload,
useLibrary,
@@ -31,6 +31,7 @@ import { SidebarGameItem } from "./sidebar-game-item";
import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
import { useDispatch } from "react-redux";
import deckyIcon from "@renderer/assets/icons/decky.png";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@@ -47,6 +48,13 @@ export function Sidebar() {
const { t } = useTranslation("sidebar");
const { library, updateLibrary } = useLibrary();
const [deckyPluginInfo, setDeckyPluginInfo] = useState<{
installed: boolean;
version: string | null;
outdated: boolean;
}>({ installed: false, version: null, outdated: false });
const [homebrewFolderExists, setHomebrewFolderExists] = useState(false);
const [showDeckyConfirmModal, setShowDeckyConfirmModal] = useState(false);
const navigate = useNavigate();
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
@@ -66,7 +74,7 @@ export function Sidebar() {
const { lastPacket, progress } = useDownload();
const { showWarningToast } = useToast();
const { showWarningToast, showSuccessToast, showErrorToast } = useToast();
const [showPlayableOnly, setShowPlayableOnly] = useState(false);
const [showAddGameModal, setShowAddGameModal] = useState(false);
@@ -83,10 +91,68 @@ export function Sidebar() {
setShowAddGameModal(false);
};
const loadDeckyPluginInfo = async () => {
if (window.electron.platform !== "linux") return;
try {
const [info, folderExists] = await Promise.all([
window.electron.getHydraDeckyPluginInfo(),
window.electron.checkHomebrewFolderExists(),
]);
setDeckyPluginInfo({
installed: info.installed,
version: info.version,
outdated: info.outdated,
});
setHomebrewFolderExists(folderExists);
} catch (error) {
console.error("Failed to load Decky plugin info:", error);
}
};
const handleInstallHydraDeckyPlugin = () => {
if (deckyPluginInfo.installed && !deckyPluginInfo.outdated) {
return;
}
setShowDeckyConfirmModal(true);
};
const handleConfirmDeckyInstallation = async () => {
setShowDeckyConfirmModal(false);
try {
const result = await window.electron.installHydraDeckyPlugin();
if (result.success) {
showSuccessToast(
t("decky_plugin_installed", {
version: result.currentVersion,
})
);
await loadDeckyPluginInfo();
} else {
showErrorToast(
t("decky_plugin_installation_failed", {
error: result.error || "Unknown error",
})
);
}
} catch (error) {
showErrorToast(
t("decky_plugin_installation_error", { error: String(error) })
);
}
};
useEffect(() => {
updateLibrary();
}, [lastPacket?.gameId, updateLibrary]);
useEffect(() => {
loadDeckyPluginInfo();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onSyncFriendRequests((result) => {
dispatch(setFriendRequestCount(result.friendRequestCount));
@@ -244,6 +310,30 @@ export function Sidebar() {
</button>
</li>
))}
{window.electron.platform === "linux" && homebrewFolderExists && (
<li className="sidebar__menu-item sidebar__menu-item--decky">
<button
type="button"
className="sidebar__menu-item-button"
onClick={handleInstallHydraDeckyPlugin}
>
<img
src={deckyIcon}
alt="Decky"
style={{ width: 16, height: 16 }}
/>
<span>
{deckyPluginInfo.installed && !deckyPluginInfo.outdated
? t("decky_plugin_installed_version", {
version: deckyPluginInfo.version,
})
: deckyPluginInfo.installed && deckyPluginInfo.outdated
? t("update_decky_plugin")
: t("install_decky_plugin")}
</span>
</button>
</li>
)}
</ul>
</section>
@@ -321,18 +411,20 @@ export function Sidebar() {
</div>
</div>
{hasActiveSubscription && (
<button
type="button"
className="sidebar__help-button"
data-open-support-chat
>
<div className="sidebar__help-button-icon">
<CommentDiscussionIcon size={14} />
</div>
<span>{t("need_help")}</span>
</button>
)}
<div className="sidebar__bottom-buttons">
{hasActiveSubscription && (
<button
type="button"
className="sidebar__help-button"
data-open-support-chat
>
<div className="sidebar__help-button-icon">
<CommentDiscussionIcon size={14} />
</div>
<span>{t("need_help")}</span>
</button>
)}
</div>
<button
type="button"
@@ -345,6 +437,24 @@ export function Sidebar() {
onClose={handleCloseAddGameModal}
/>
<ConfirmationModal
visible={showDeckyConfirmModal}
title={
deckyPluginInfo.installed && deckyPluginInfo.outdated
? t("update_decky_plugin_title")
: t("install_decky_plugin_title")
}
descriptionText={
deckyPluginInfo.installed && deckyPluginInfo.outdated
? t("update_decky_plugin_message")
: t("install_decky_plugin_message")
}
onClose={() => setShowDeckyConfirmModal(false)}
onConfirm={handleConfirmDeckyInstallation}
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("confirm")}
/>
<Tooltip id="add-custom-game-tooltip" />
<Tooltip id="show-playable-only-tooltip" />
</aside>

View File

@@ -0,0 +1 @@
export * from "./star-rating";

View File

@@ -0,0 +1,54 @@
@use "../../scss/globals.scss";
.star-rating {
display: flex;
align-items: center;
gap: 2px;
&__star {
color: globals.$muted-color;
transition: color ease 0.2s;
&--filled {
color: #ffffff;
}
&--empty {
color: globals.$muted-color;
}
&--half {
color: #ffffff;
position: absolute;
top: 0;
left: 0;
clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%);
}
}
&__half-star {
position: relative;
display: inline-block;
}
&__value {
margin-left: 4px;
font-size: 12px;
color: globals.$muted-color;
font-weight: 500;
}
&__calculating-text,
&__no-rating-text {
margin-left: 4px;
font-size: 12px;
color: globals.$muted-color;
}
&--calculating,
&--no-rating {
.star-rating__star {
color: globals.$muted-color;
}
}
}

View File

@@ -0,0 +1,77 @@
import { StarIcon, StarFillIcon } from "@primer/octicons-react";
import "./star-rating.scss";
export interface StarRatingProps {
rating: number | null;
maxStars?: number;
size?: number;
showCalculating?: boolean;
calculatingText?: string;
hideIcon?: boolean;
}
export function StarRating({
rating,
maxStars = 5,
size = 12,
showCalculating = false,
calculatingText = "Calculating",
hideIcon = false,
}: Readonly<StarRatingProps>) {
if (rating === null && showCalculating) {
return (
<div className="star-rating star-rating--calculating">
{!hideIcon && <StarIcon size={size} />}
<span className="star-rating__calculating-text">{calculatingText}</span>
</div>
);
}
if (rating === null || rating === undefined) {
return (
<div className="star-rating star-rating--no-rating">
{!hideIcon && <StarIcon size={size} />}
<span className="star-rating__no-rating-text"></span>
</div>
);
}
const filledStars = Math.floor(rating);
const hasHalfStar = rating % 1 >= 0.5;
const emptyStars = maxStars - filledStars - (hasHalfStar ? 1 : 0);
return (
<div className="star-rating">
{Array.from({ length: filledStars }, (_, index) => (
<StarFillIcon
key={`filled-${index}`}
size={size}
className="star-rating__star star-rating__star--filled"
/>
))}
{hasHalfStar && (
<div className="star-rating__half-star" key="half-star">
<StarIcon
size={size}
className="star-rating__star star-rating__star--empty"
/>
<StarFillIcon
size={size}
className="star-rating__star star-rating__star--half"
/>
</div>
)}
{Array.from({ length: emptyStars }, (_, index) => (
<StarIcon
key={`empty-${index}`}
size={size}
className="star-rating__star star-rating__star--empty"
/>
))}
<span className="star-rating__value">{rating.toFixed(1)}</span>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Lumen";
export const VERSION_CODENAME = "Supernova";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",
@@ -11,6 +11,7 @@ export const DOWNLOADER_NAME = {
[Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire",
[Downloader.TorBox]: "TorBox",
[Downloader.AllDebrid]: "All-Debrid",
[Downloader.Hydra]: "Nimbus",
};

View File

@@ -87,7 +87,7 @@ export function CloudSyncContextProvider({
const [loadingPreview, setLoadingPreview] = useState(false);
const [freezingArtifact, setFreezingArtifact] = useState(false);
const { showSuccessToast } = useToast();
const { showSuccessToast, showErrorToast } = useToast();
const downloadGameArtifact = useCallback(
async (gameArtifactId: string) => {
@@ -98,7 +98,18 @@ export function CloudSyncContextProvider({
);
const getGameArtifacts = useCallback(async () => {
const results = await window.electron.getGameArtifacts(objectId, shop);
const params = new URLSearchParams({
objectId,
shop,
});
const results = await window.electron.hydraApi
.get<GameArtifact[]>(`/profile/games/artifacts?${params.toString()}`, {
needsSubscription: true,
})
.catch(() => {
return [];
});
setArtifacts(results);
}, [objectId, shop]);
@@ -122,16 +133,25 @@ export function CloudSyncContextProvider({
const uploadSaveGame = useCallback(
async (downloadOptionTitle: string | null) => {
setUploadingBackup(true);
window.electron.uploadSaveGame(objectId, shop, downloadOptionTitle);
window.electron
.uploadSaveGame(objectId, shop, downloadOptionTitle)
.catch((err) => {
setUploadingBackup(false);
logger.error("Failed to upload save game", { objectId, shop, err });
showErrorToast(t("backup_failed"));
});
},
[objectId, shop]
[objectId, shop, t, showErrorToast]
);
const toggleArtifactFreeze = useCallback(
async (gameArtifactId: string, freeze: boolean) => {
setFreezingArtifact(true);
try {
await window.electron.toggleArtifactFreeze(gameArtifactId, freeze);
const endpoint = freeze ? "freeze" : "unfreeze";
await window.electron.hydraApi.put(
`/profile/games/artifacts/${gameArtifactId}/${endpoint}`
);
getGameArtifacts();
} catch (err) {
logger.error("Failed to toggle artifact freeze", objectId, shop, err);
@@ -179,10 +199,12 @@ export function CloudSyncContextProvider({
const deleteGameArtifact = useCallback(
async (gameArtifactId: string) => {
return window.electron.deleteGameArtifact(gameArtifactId).then(() => {
getGameBackupPreview();
getGameArtifacts();
});
return window.electron.hydraApi
.delete<{ ok: boolean }>(`/profile/games/artifacts/${gameArtifactId}`)
.then(() => {
getGameBackupPreview();
getGameArtifacts();
});
},
[getGameBackupPreview, getGameArtifacts]
);

View File

@@ -26,6 +26,7 @@ import type {
} from "@types";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { GameDetailsContext } from "./game-details.context.types";
import { SteamContentDescriptor } from "@shared";
@@ -91,6 +92,7 @@ export function GameDetailsContextProvider({
}, [getRepacksForObjectId, objectId]);
const { i18n } = useTranslation("game_details");
const location = useLocation();
const dispatch = useAppDispatch();
@@ -140,29 +142,24 @@ export function GameDetailsContextProvider({
}
});
const statsPromise = window.electron
.getGameStats(objectId, shop)
.then((result) => {
if (abortController.signal.aborted) return null;
setStats(result);
return result;
});
window.electron.getGameStats(objectId, shop).then((result) => {
if (abortController.signal.aborted) return;
setStats(result);
});
Promise.all([shopDetailsPromise, statsPromise])
.then(([_, stats]) => {
if (stats) {
const assets = stats.assets;
if (assets) {
window.electron.saveGameShopAssets(objectId, shop, assets);
const assetsPromise = window.electron.getGameAssets(objectId, shop);
setShopDetails((prev) => {
if (!prev) return null;
return {
...prev,
assets,
};
});
}
Promise.all([shopDetailsPromise, assetsPromise])
.then(([_, assets]) => {
if (assets) {
if (abortController.signal.aborted) return;
setShopDetails((prev) => {
if (!prev) return null;
return {
...prev,
assets,
};
});
}
})
.finally(() => {
@@ -177,7 +174,7 @@ export function GameDetailsContextProvider({
if (abortController.signal.aborted) return;
setAchievements(achievements);
})
.catch(() => {});
.catch(() => void 0);
}
}, [
updateGame,
@@ -198,6 +195,19 @@ export function GameDetailsContextProvider({
dispatch(setHeaderTitle(gameTitle));
}, [objectId, gameTitle, dispatch]);
useEffect(() => {
const state =
(location && (location.state as Record<string, unknown>)) || {};
if (state.openRepacks) {
setShowRepacksModal(true);
try {
window.history.replaceState({}, document.title, location.pathname);
} catch (e) {
console.error(e);
}
}
}, [location]);
useEffect(() => {
if (game?.title) {
dispatch(setHeaderTitle(game.title));
@@ -222,6 +232,61 @@ export function GameDetailsContextProvider({
};
}, [game?.id, isGameRunning, updateGame]);
useEffect(() => {
const handler = (ev: Event) => {
try {
const detail = (ev as CustomEvent).detail || {};
if (detail.objectId && detail.objectId === objectId) {
setShowRepacksModal(true);
}
} catch (e) {
void e;
}
};
window.addEventListener("hydra:openRepacks", handler as EventListener);
return () => {
window.removeEventListener("hydra:openRepacks", handler as EventListener);
};
}, [objectId]);
useEffect(() => {
const handler = (ev: Event) => {
try {
const detail = (ev as CustomEvent).detail || {};
if (detail.objectId && detail.objectId === objectId) {
setShowGameOptionsModal(true);
}
} catch (e) {
void e;
}
};
window.addEventListener("hydra:openGameOptions", handler as EventListener);
return () => {
window.removeEventListener(
"hydra:openGameOptions",
handler as EventListener
);
};
}, [objectId]);
useEffect(() => {
const state =
(location && (location.state as Record<string, unknown>)) || {};
if (state.openGameOptions) {
setShowGameOptionsModal(true);
try {
window.history.replaceState({}, document.title, location.pathname);
} catch (_e) {
void _e;
}
}
}, [location]);
const lastDownloadedOption = useMemo(() => {
if (game?.download) {
const repack = repacks.find((repack) =>

View File

@@ -63,6 +63,7 @@ export function SettingsContextProvider({
const [searchParams] = useSearchParams();
const defaultSourceUrl = searchParams.get("urls");
const defaultTab = searchParams.get("tab");
const defaultAppearanceTheme = searchParams.get("theme");
const defaultAppearanceAuthorId = searchParams.get("authorId");
const defaultAppearanceAuthorName = searchParams.get("authorName");
@@ -77,6 +78,13 @@ export function SettingsContextProvider({
}
}, [defaultSourceUrl]);
useEffect(() => {
if (defaultTab) {
const idx = Number(defaultTab);
if (!Number.isNaN(idx)) setCurrentCategoryIndex(idx);
}
}, [defaultTab]);
useEffect(() => {
if (appearance.theme) setCurrentCategoryIndex(3);
}, [appearance.theme]);
@@ -108,7 +116,13 @@ export function SettingsContextProvider({
}, []);
const fetchBlockedUsers = useCallback(async () => {
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
const blockedUsers = await window.electron.hydraApi
.get<UserBlocks>("/profile/blocks", {
params: { take: 12, skip: 0 },
})
.catch(() => {
return { blocks: [] };
});
setBlockedUsers(blockedUsers.blocks);
}, []);

View File

@@ -82,26 +82,38 @@ export function UserProfileContextProvider({
return "";
};
const { t } = useTranslation("user_profile");
const { t, i18n } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const navigate = useNavigate();
const getUserStats = useCallback(async () => {
window.electron.getUserStats(userId).then((stats) => {
setUserStats(stats);
});
window.electron.hydraApi
.get<UserStats>(`/users/${userId}/stats`)
.then((stats) => {
setUserStats(stats);
});
}, [userId]);
const getUserLibraryGames = useCallback(
async (sortBy?: string) => {
try {
const response = await window.electron.getUserLibrary(
userId,
12,
0,
sortBy
);
const params = new URLSearchParams();
params.append("take", "12");
params.append("skip", "0");
if (sortBy) {
params.append("sortBy", sortBy);
}
const queryString = params.toString();
const url = queryString
? `/users/${userId}/library?${queryString}`
: `/users/${userId}/library`;
const response = await window.electron.hydraApi.get<{
library: UserGame[];
pinnedGames: UserGame[];
}>(url);
if (response) {
setLibraryGames(response.library);
@@ -122,26 +134,36 @@ export function UserProfileContextProvider({
getUserStats();
getUserLibraryGames();
return window.electron.getUser(userId).then((userProfile) => {
if (userProfile) {
setUserProfile(userProfile);
return window.electron.hydraApi
.get<UserProfile | null>(`/users/${userId}`)
.then((userProfile) => {
if (userProfile) {
setUserProfile(userProfile);
if (userProfile.profileImageUrl) {
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
(color) => setHeroBackground(color)
);
if (userProfile.profileImageUrl) {
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
(color) => setHeroBackground(color)
);
}
} else {
showErrorToast(t("user_not_found"));
navigate(-1);
}
} else {
showErrorToast(t("user_not_found"));
navigate(-1);
}
});
});
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
const getBadges = useCallback(async () => {
const badges = await window.electron.getBadges();
const language = i18n.language.split("-")[0];
const params = new URLSearchParams({
locale: language,
});
const badges = await window.electron.hydraApi.get<Badge[]>(
`/badges?${params.toString()}`,
{ needsAuth: false }
);
setBadges(badges);
}, []);
}, [i18n]);
useEffect(() => {
setUserProfile(null);

Some files were not shown because too many files have changed in this diff Show More