Compare commits

...

89 Commits

Author SHA1 Message Date
Chubby Granny Chaser
2cec9f6298 Merge branch 'feat/remove-dexie' of github.com:hydralauncher/hydra into feat/remove-dexie 2025-10-15 16:03:27 +01:00
Chubby Granny Chaser
5639c09c22 feat: improving caching 2025-10-15 16:02:50 +01:00
Chubby Granny Chaser
abc7d29e28 Merge branch 'main' into feat/remove-dexie 2025-10-15 15:59:28 +01:00
Chubby Granny Chaser
074d9d4fe2 feat: improving caching 2025-10-15 15:59:03 +01:00
Chubby Granny Chaser
24106eaeab feat: improving caching 2025-10-15 13:58:40 +01:00
Chubby Granny Chaser
136a44473f feat: adding o1 cache 2025-10-14 19:26:39 +01:00
Chubby Granny Chaser
41227b125e feat: improving performance on sources 2025-10-14 17:42:59 +01:00
Chubby Granny Chaser
311555386e fix: fixing bug with sources 2025-10-14 15:49:02 +01:00
Zamitto
a4cc35fc20 chore: update i18n strings
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-14 11:27:40 -03:00
Chubby Granny Chaser
aba206452f feat: improving gallery slider 2025-10-14 14:56:47 +01:00
Chubby Granny Chaser
0a5626c745 Merge branch 'feat/remove-dexie' of github.com:hydralauncher/hydra into feat/remove-dexie 2025-10-14 14:53:18 +01:00
Chubby Granny Chaser
bfa2fd6166 feat: improving download source import code 2025-10-14 14:52:47 +01:00
Chubby Granny Chaser
d530d7918a Merge branch 'main' into feat/remove-dexie 2025-10-14 13:22:33 +01:00
Chubby Granny Chaser
c60753547c feat: removing dexie 2025-10-14 13:18:42 +01:00
Chubby Granny Chaser
1a99305aa0 feat: removing dexie 2025-10-14 13:15:09 +01:00
Zamitto
89a60b7d76 Merge pull request #1811 from whintersnow0/fix/add-missing-uk-translations
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
fix: add missing uk translations
2025-10-14 05:44:49 -03:00
Chubby Granny Chaser
f9c585d12f ci: triggering
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-14 00:37:07 +01:00
Chubby Granny Chaser
594332ba53 ci: triggering 2025-10-14 00:34:01 +01:00
Chubby Granny Chaser
528dfafb93 ci: triggering 2025-10-14 00:23:40 +01:00
Chubby Granny Chaser
7980027a98 ci: triggering 2025-10-14 00:23:31 +01:00
Chubby Granny Chaser
1fedd8ffdd ci: triggering 2025-10-14 00:17:28 +01:00
Chubby Granny Chaser
2cad70a42e ci: triggering 2025-10-14 00:03:46 +01:00
Chubby Granny Chaser
7b2de7b310 Merge pull request #1812 from hydralauncher/feat/ota
Feat/ota
2025-10-13 23:56:39 +01:00
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
whintersnow0
e1ee3a47d6 fix: add missing uk translations 2025-10-14 00:02:06 +02: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
Chubby Granny Chaser
1e779a32c1 Merge branch 'main' into patch-4 2025-10-12 12:16:21 +01: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
Kiwo.2
aa4def327a New lines added for update 2025-10-07 19:39:25 +02: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
158 changed files with 7626 additions and 6832 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

@@ -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

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,7 +6,7 @@ concurrency:
on:
push:
branches: main
branches: [main]
jobs:
build:
@@ -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,6 +57,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: Build Windows
if: matrix.os == 'windows-2022'
@@ -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.1",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -32,13 +32,13 @@
"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",
@@ -47,8 +47,9 @@
"@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",
@@ -56,9 +57,7 @@
"crc": "^4.3.2",
"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",
@@ -87,6 +86,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",
@@ -116,7 +116,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",
@@ -130,8 +130,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

@@ -76,7 +76,19 @@
"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"
"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",
@@ -227,7 +239,7 @@
"rating_neutral": "Neutral",
"rating_positive": "Positive",
"rating_very_positive": "Very Positive",
"submit_review": "Submit Review",
"submit_review": "Submit",
"submitting": "Submitting...",
"review_submitted_successfully": "Review submitted successfully!",
"review_submission_failed": "Failed to submit review. Please try again.",
@@ -236,11 +248,11 @@
"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",
"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",
"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",
@@ -344,7 +356,8 @@
"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"
"delete_review_modal_cancel_button": "Cancel",
"vote_failed": "Failed to register your vote. Please try again."
},
"activation": {
"title": "Activate Hydra",
@@ -382,7 +395,6 @@
"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…"
},
@@ -434,6 +446,7 @@
"found_download_option_one": "Found {{countFormatted}} download option",
"found_download_option_other": "Found {{countFormatted}} download options",
"import": "Import",
"importing": "Importing...",
"public": "Public",
"private": "Private",
"friends_only": "Friends only",
@@ -486,23 +499,14 @@
"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",
@@ -583,6 +587,7 @@
"activity": "Recent Activity",
"library": "Library",
"pinned": "Pinned",
"sort_by": "Sort by:",
"achievements_earned": "Achievements earned",
"played_recently": "Played recently",
"playtime": "Playtime",

View File

@@ -376,6 +376,7 @@
"found_download_option_one": "Encontrada {{countFormatted}} fuente de descarga",
"found_download_option_other": "Encontradas {{countFormatted}} opciones de descargas",
"import": "Importar",
"importing": "Importando...",
"public": "Público",
"private": "Privado",
"friends_only": "Sólo amigos",

View File

@@ -32,8 +32,8 @@
"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": "A fájl útvonala",
"custom_game_modal_select_executable": "Az útvonal",
"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",
@@ -43,7 +43,7 @@
"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és",
"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",
@@ -70,7 +70,25 @@
"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_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",
@@ -120,7 +138,7 @@
"downloading_metadata": "Metaadat letöltése",
"filter": "Repackek szűrése",
"requirements": "Rendszerkövetelmények",
"minimum": "Minimum",
"minimum": "Minimális",
"recommended": "Ajánlott",
"paused": "Szüneteltetve",
"release_date": "Megjelenés: {{date}}",
@@ -144,7 +162,7 @@
"playing_now": "Játékban: ",
"change": "Változtatás",
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
"select_folder_hint": "Hogy megváltoztasd a letöltési mappát, menj a <0>Beállítások</0> menüjébe",
"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",
@@ -160,17 +178,19 @@
"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": "Távolítsd el a játékot könyvtáradból, vagy a fájlokat amit a Hydra töltött le",
"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",
@@ -178,13 +198,21 @@
"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",
"nsfw_content_title": "Ez a játék nem megfelelő tartalmat tartalmaz.",
"nsfw_content_description": "{{title}} tartalmaz tartalmat amely nem megfelelő minden korosztálynak. Biztosan folytatni szeretnéd?",
"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",
@@ -192,6 +220,39 @@
"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",
@@ -204,6 +265,7 @@
"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",
@@ -239,17 +301,17 @@
"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 érhető el a Real-Debridnél, és lekérdezni letöltési állapotot még nem lehet vele.",
"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": "Manuálisan frissíteni a Játékidőt a {{game}} játékhoz",
"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": "Az óráid 'manuálisan frissítve' lesznek 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": "This download is not available on TorBox and polling download status from TorBox is not yet available.",
"download_error_not_cached_on_hydra": "This download is not available on Nimbus.",
"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",
@@ -272,7 +334,7 @@
"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.",
"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",
@@ -286,13 +348,21 @@
"historical_keyshop": "Korábbi nem hivatalos ár",
"language": "Nyelv",
"caption": "Felirat",
"audio": "Hang"
"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 azonosító:",
"title": "Hydra Aktiválása",
"installation_id": "Telepítési Azonosító:",
"enter_activation_code": "Írd be az aktiválási kódod",
"message": "Ha nem tudod kit kérdezz efelől, akkor nem kéne nálad legyen.",
"message": "Ha nem tudod hol kérdezz efelől, akkor nem kéne ilyened legyen.",
"activate": "Aktiválás",
"loading": "Töltés…"
},
@@ -342,10 +412,10 @@
"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, lehetővé teszi a fájlok gyors letöltését, és csak az internetkapcsolat sebessége szab határt.",
"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-Debrid-re",
"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",
@@ -407,7 +477,7 @@
"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átom nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.",
"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",
@@ -427,6 +497,8 @@
"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",
@@ -459,7 +531,7 @@
"variation": "Variáció",
"default": "Alapértelmezett",
"rare": "Ritka",
"platinum": "Platinum",
"platinum": "Platina",
"hidden": "Rejtett",
"test_notification": "Értesítés tesztelése",
"notification_preview": "Achievement Értesítés Előnézete",
@@ -490,12 +562,13 @@
"game_card": {
"available_one": "Elérhető",
"available_other": "Elérhető",
"no_downloads": "Nincs elérhető letöltés"
"no_downloads": "Nincs elérhető letöltés",
"calculating": "Feldolgozás"
},
"binary_not_found_modal": {
"title": "A programok nincsenek telepítve",
"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óra, hogy a játék normálisan fusson"
"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"
@@ -575,7 +648,7 @@
"report_reason_violence": "Fenyegető",
"report_reason_spam": "Spam",
"report_reason_other": "Egyéb",
"profile_reported": "Profil jelentve",
"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…",
@@ -593,17 +666,20 @@
"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"
"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 ekkor: {{date}}",
"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 achievementet oldottál fel {{gameCount}} játékban",
"new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek",
"achievements_unlocked_for_game": "{{achievementCount}} új achievementet oldottál fel a(z) {{gameTitle}} játékban",
"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:",

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",
@@ -165,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",
@@ -256,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",
@@ -345,6 +434,7 @@
"found_download_option_one": "{{countFormatted}} opção de download encontrada",
"found_download_option_other": "{{countFormatted}} opções de download encontradas",
"import": "Importar",
"importing": "Importando...",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
@@ -378,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",
@@ -395,6 +487,8 @@
"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",
@@ -431,8 +525,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",
@@ -448,7 +541,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",
@@ -457,7 +552,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",
@@ -484,10 +580,18 @@
"user_profile": {
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"last_time_played": "Última sessão {{period}}",
"activity": "Atividades recentes",
"library": "Biblioteca",
"pinned": "Fixados",
"sort_by": "Ordenar por:",
"achievements_earned": "Conquistas obtidas",
"played_recently": "Jogados recentemente",
"playtime": "Tempo de jogo",
"total_play_time": "Tempo total de jogo",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"no_recent_activity_title": "Hmmm… nada por aqui",
"no_recent_activity_description": "Parece que você não jogou nada recentemente. Que tal começar agora?",
"display_name": "Nome de exibição",
@@ -569,7 +673,12 @@
"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

@@ -267,6 +267,7 @@
"found_download_option_one": "{{countFormatted}} opção de transferência encontrada",
"found_download_option_other": "{{countFormatted}} opções de transferência encontradas",
"import": "Importar",
"importing": "A importar...",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
@@ -376,10 +377,18 @@
"user_profile": {
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"last_time_played": "Última sessão {{period}}",
"activity": "Atividade recente",
"library": "Biblioteca",
"pinned": "Fixados",
"sort_by": "Ordenar por:",
"achievements_earned": "Conquistas obtidas",
"played_recently": "Jogados recentemente",
"playtime": "Tempo de jogo",
"total_play_time": "Tempo total de jogo",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"no_recent_activity_title": "Hmmm… não há nada por aqui",
"no_recent_activity_description": "Parece que não jogaste nada recentemente. Que tal começar agora?",
"display_name": "Nome de apresentação",

View File

@@ -135,11 +135,7 @@
"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",
"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"
"changes_saved": "Modificările au fost salvate 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": "Путь к исполняемому файлу",
@@ -74,7 +76,19 @@
"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": "Перетащите для замены обложки"
"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": "Поиск",
@@ -135,6 +149,7 @@
"amount_minutes": "{{amount}} минут",
"accuracy": "точность {{accuracy}}%",
"add_to_library": "Добавить в библиотеку",
"already_in_library": "Уже в библиотеке",
"remove_from_library": "Удалить из библиотеки",
"no_downloads": "Нет доступных источников",
"play_time": "Сыграно {{amount}}",
@@ -163,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": "Загрузки",
@@ -177,26 +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": "Активные игроки",
"rating_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": "Резервные копии",
@@ -209,6 +271,7 @@
"uploading_backup": "Загрузка резервной копии…",
"no_backups": "Вы еще не создали резервных копий для этой игры",
"backup_uploaded": "Резервная копия загружена",
"backup_failed": "Ошибка резервного копирования",
"backup_deleted": "Резервная копия удалена",
"backup_restored": "Резервная копия восстановлена",
"see_all_achievements": "Просмотреть все достижения",
@@ -248,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": "Открепить",
@@ -275,41 +341,22 @@
"backup_unfrozen": "Резервная копия откреплена",
"backup_freeze_failed": "Не удалось закрепить резервную копию",
"backup_freeze_failed_description": "Вы должны оставить как минимум один свободный слот для автоматических резервных копий",
"manual_playtime_tooltip": "Это время игры было обновлено вручную",
"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": "Отправка...",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"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": "Может быть позже"
"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",
@@ -355,13 +402,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": "Источники загрузки",
@@ -388,11 +432,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": "Все источники обновлены",
@@ -401,17 +445,21 @@
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
"import": "Импортировать",
"blocked_users": "Заблокированные пользователи",
"friends_only": "Только для друзей",
"must_be_valid_url": "У источника должен быть правильный URL",
"privacy": "Конфиденциальность",
"importing": "Импортируется...",
"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": "Аккаунт",
@@ -450,12 +498,14 @@
"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 привязан",
"name_min_length": "Название темы должно содержать не менее 3 символов",
"import_theme": "Импортировать тему",
"import_theme_description": "Вы импортируете {{theme}} из магазина тем",
@@ -469,6 +519,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": "Верхний центр",
@@ -485,8 +536,7 @@
"hidden": "Скрытый",
"test_notification": "Тестовое уведомление",
"notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"enable_steam_achievements": "Включить поиск достижений Steam"
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
},
"notifications": {
"download_complete": "Загрузка завершена",
@@ -498,13 +548,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",
@@ -535,6 +585,10 @@
"last_time_played": "Последняя игра {{period}}",
"activity": "Недавняя активность",
"library": "Библиотека",
"pinned": "Закрепленные",
"achievements_earned": "Заработанные достижения",
"played_recently": "Недавно сыгранные",
"playtime": "Время игры",
"total_play_time": "Всего сыграно",
"manual_playtime_tooltip": "Время игры было обновлено вручную",
"no_recent_activity_title": "Хммм... Тут ничего нет",
@@ -578,24 +632,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": "Загрузка баннера...",
@@ -616,7 +670,7 @@
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработано от положительных лайков на отзывах"
"karma_description": "Заработана положительными оценками отзывов"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",

View File

@@ -27,7 +27,68 @@
"favorites": "Улюблені",
"friends": "Друзі",
"need_help": "Потрібна допомога?",
"playable_button_title": "Показати лише ігри, які можна грати зараз"
"playable_button_title": "Показати лише ігри, які можна грати зараз",
"add_custom_game_tooltip": "Додати власну гру",
"show_playable_only_tooltip": "Показати лише доступні для гри",
"custom_game_modal": "Додати власну гру",
"custom_game_modal_description": "Додайте власну гру до бібліотеки, вибравши виконуваний файл",
"custom_game_modal_executable_path": "Шлях до виконуваного файлу",
"custom_game_modal_select_executable": "Виберіть виконуваний файл",
"custom_game_modal_title": "Назва гри",
"custom_game_modal_enter_title": "Введіть назву гри",
"custom_game_modal_browse": "Огляд",
"custom_game_modal_cancel": "Скасувати",
"custom_game_modal_add": "Додати гру",
"custom_game_modal_adding": "Додавання гри...",
"custom_game_modal_success": "Власну гру успішно додано",
"custom_game_modal_failed": "Не вдалося додати власну гру",
"custom_game_modal_executable": "Виконуваний файл",
"edit_game_modal": "Налаштувати ресурси",
"edit_game_modal_description": "Налаштуйте ресурси та деталі гри",
"edit_game_modal_title": "Назва",
"edit_game_modal_enter_title": "Введіть назву",
"edit_game_modal_image": "Зображення",
"edit_game_modal_select_image": "Виберіть зображення",
"edit_game_modal_browse": "Огляд",
"edit_game_modal_image_preview": "Попередній перегляд зображення",
"edit_game_modal_icon": "Іконка",
"edit_game_modal_select_icon": "Виберіть іконку",
"edit_game_modal_icon_preview": "Попередній перегляд іконки",
"edit_game_modal_logo": "Логотип",
"edit_game_modal_select_logo": "Виберіть логотип",
"edit_game_modal_logo_preview": "Попередній перегляд логотипу",
"edit_game_modal_hero": "Зображення обкладинки гри",
"edit_game_modal_select_hero": "Виберіть обкладинку гри",
"edit_game_modal_hero_preview": "Попередній перегляд обкладинки гри",
"edit_game_modal_cancel": "Скасувати",
"edit_game_modal_update": "Оновити",
"edit_game_modal_updating": "Оновлення...",
"edit_game_modal_fill_required": "Будь ласка, заповніть всі обов'язкові поля",
"edit_game_modal_success": "Ресурси успішно оновлено",
"edit_game_modal_failed": "Не вдалося оновити ресурси",
"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_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": "Пошук",
@@ -86,6 +147,7 @@
"amount_minutes": "{{amount}} хвилин",
"accuracy": "{{accuracy}}% точність",
"add_to_library": "Додати до бібліотеки",
"already_in_library": "Вже в бібліотеці",
"remove_from_library": "Видалити з бібліотеки",
"no_downloads": "Немає доступних завантажень",
"play_time": "Час гри: {{amount}}",
@@ -102,6 +164,7 @@
"download_now": "Завантажити зараз",
"calculating_eta": "Обчислення залишкового часу…",
"create_shortcut": "Створити ярлик на робочому столі",
"create_shortcut_simple": "Створити ярлик",
"create_shortcut_success": "Ярлик успішно створено",
"create_shortcut_error": "Виникла помилка під час створення ярлику",
"nsfw_content_title": "Ця гра містить неприйнятний контент",
@@ -135,6 +198,7 @@
"open_folder": "Відкрити папку",
"open_screenshot": "Відкрити скріншот",
"options": "Налаштування",
"properties": "Властивості",
"paused": "Призупинено",
"previous_screenshot": "Попередній скріншот",
"remove_files": "Видалити файли",
@@ -171,7 +235,7 @@
"loading_save_preview": "Виконується пошук збережень гри...",
"wine_prefix": "Префікс Wine",
"wine_prefix_description": "Префікс Wine використовувався для запуску цієї гри",
"launch_options": "Параметри загрузки",
"launch_options": "Параметри завантаження",
"launch_options_description": "Досвідчені користувачі можуть ввести власні модифікації до параметрів запуску (експериментальна функція).",
"launch_options_placeholder": "Параметри не вказано",
"no_download_option_info": "Немає інформації",
@@ -198,11 +262,105 @@
"download_error_not_cached_on_hydra": "Це завантаження недоступне через Nimbus.",
"game_removed_from_favorites": "Гра видалена з улюбленних",
"game_added_to_favorites": "Гра була добавлена у улюблені",
"automatically_extract_downloaded_files": "Автоматично розархівувати завантаженні файли"
"automatically_extract_downloaded_files": "Автоматично розархівувати завантаженні файли",
"create_steam_shortcut": "Створити ярлик Steam",
"you_might_need_to_restart_steam": "Можливо, вам знадобиться перезапустити Steam, щоб побачити зміни",
"add_to_favorites": "Додати до улюбленого",
"remove_from_favorites": "Видалити з улюбленого",
"failed_update_favorites": "Не вдалося оновити улюблене",
"game_removed_from_library": "Гру видалено з бібліотеки",
"failed_remove_from_library": "Не вдалося видалити з бібліотеки",
"files_removed_success": "Файли успішно видалено",
"failed_remove_files": "Не вдалося видалити файли",
"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": "Скасувати",
"backup_failed": "Помилка резервного копіювання",
"update_playtime_title": "Оновити час гри",
"update_playtime_description": "Вручну оновіть час гри для {{game}}",
"update_playtime": "Оновити час гри",
"update_playtime_success": "Час гри успішно оновлено",
"update_playtime_error": "Не вдалося оновити час гри",
"update_game_playtime": "Оновити час гри",
"manual_playtime_warning": "Ваші години будуть позначені як оновлені вручну. Цю дію не можна скасувати.",
"manual_playtime_tooltip": "Цей час гри було оновлено вручну",
"game_removed_from_pinned": "Гру видалено із закріплених",
"game_added_to_pinned": "Гру додано до закріплених",
"create_start_menu_shortcut": "Створити ярлик у меню «Пуск»",
"invalid_wine_prefix_path": "Недійсний шлях префікса Wine",
"invalid_wine_prefix_path_description": "Шлях до префікса Wine недійсний. Будь ласка, перевірте шлях і спробуйте знову.",
"missing_wine_prefix": "Префікс Wine необхідний для створення резервної копії в Linux",
"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": "Відкріпити",
"backup_frozen": "Резервну копію закріплено",
"backup_unfrozen": "Резервну копію відкріплено",
"backup_freeze_failed": "Не вдалося закріпити резервну копію",
"backup_freeze_failed_description": "Ви повинні залишити принаймні один вільний слот для автоматичних резервних копій",
"edit_game_modal_button": "Змінити деталі гри",
"game_details": "Деталі гри",
"currency_symbol": "₴",
"currency_country": "ua",
"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",
"installation_id": "ID установки:",
"installation_id": "ID встановлення:",
"enter_activation_code": "Введіть ваш активаційний код",
"message": "Якщо ви не знаєте, де його запросити, то не повинні мати його.",
"activate": "Активувати",
@@ -226,7 +384,7 @@
"install": "Встановити",
"download_in_progress": "В процесі",
"downloads_completed": "Завершено",
"no_downloads_description": "Ви ще нічого не завантажили через Hydra, але ніколи не пізно почати!",
"no_downloads_description": "Ви ще нічого не завантажили через Hydra, але ніколи не пізно почати",
"no_downloads_title": "Тут так пусто...",
"queued": "В черзі",
"queued_downloads": "Завантаження в черзі",
@@ -339,6 +497,8 @@
"delete_theme_description": "Це видалить тему {{theme}}",
"cancel": "Відмінити",
"appearance": "Вигляд",
"debrid": "Debrid",
"debrid_description": "Сервіси Debrid - це преміум-завантажувачі без обмежень, які дозволяють швидко завантажувати файли з різних файлообмінників, обмежуючись лише швидкістю вашого інтернету.",
"enable_torbox": "Включити TorBox",
"torbox_description": "TorBox — це ваш преміум-сервіс для сідінгу, що конкурує навіть з найкращими серверами на ринку.",
"torbox_account_linked": "TorBox акаунт прив'язано",
@@ -357,7 +517,25 @@
"install_common_redist": "Встановити",
"installing_common_redist": "Встановлюється…",
"show_download_speed_in_megabytes": "Показувати швидкість завантаження в мегабайтах на секунду",
"extract_files_by_default": "Розпаковувати файли після завантаження"
"extract_files_by_default": "Розпаковувати файли після завантаження",
"enable_steam_achievements": "Увімкнути пошук досягнень Steam",
"achievement_custom_notification_position": "Позиція сповіщень про досягнення",
"top-left": "Верхній лівий кут",
"top-center": "Верхній центр",
"top-right": "Верхній правий кут",
"bottom-left": "Нижній лівий кут",
"bottom-center": "Нижній центр",
"bottom-right": "Нижній правий кут",
"enable_achievement_custom_notifications": "Увімкнути сповіщення про досягнення",
"alignment": "Вирівнювання",
"variation": "Варіація",
"default": "За замовчуванням",
"rare": "Рідкісне",
"platinum": "Платиновий",
"hidden": "Прихований",
"test_notification": "Тестове сповіщення",
"notification_preview": "Попередній перегляд сповіщення про досягнення",
"enable_friend_start_game_notifications": "Коли друг починає грати в гру"
},
"notifications": {
"download_complete": "Завантаження завершено",
@@ -372,7 +550,10 @@
"new_friend_request_description": "Ви отримали новий запит на дружбу",
"new_friend_request_title": "Новий запит на дружбу",
"extraction_complete": "Витягування завершено",
"game_extracted": "{{title}} успішно витягнуто"
"game_extracted": "{{title}} успішно витягнуто",
"friend_started_playing_game": "{{displayName}} почав грати в гру",
"test_achievement_notification_title": "Це тестове сповіщення",
"test_achievement_notification_description": "Досить круто, чи не так?"
},
"system_tray": {
"open": "Відкрити Hydra",
@@ -381,7 +562,8 @@
"game_card": {
"no_downloads": "Немає доступних завантажень",
"available_one": "Доступний",
"available_other": "Доступні"
"available_other": "Доступні",
"calculating": "Обчислення"
},
"binary_not_found_modal": {
"title": "Програми не встановлені",
@@ -398,11 +580,17 @@
"activity": "Остання активність",
"amount_hours": "{{amount}} годин",
"amount_minutes": "{{amount}} хвилин",
"amount_hours_short": "{{amount}}год",
"amount_minutes_short": "{{amount}}хв",
"cancel": "Скасувати",
"display_name": "Відображуване ім'я",
"edit_profile": "Редагувати профіль",
"last_time_played": "Остання гра {{period}}",
"library": "Бібліотека",
"pinned": "Закріплені",
"achievements_earned": "Зароблені досягнення",
"played_recently": "Недавно зіграні",
"playtime": "Час гри",
"no_recent_activity_description": "Ви давно не грали в ігри. Пора це змінити!",
"no_recent_activity_title": "Хммм... Тут нічого немає",
"playing_for": "Зіграно {{amount}}",
@@ -414,9 +602,10 @@
"sign_out_modal_title": "Ви впевнені?",
"successfully_signed_out": "Успішний вихід з акаунту",
"total_play_time": "Всього зіграно",
"manual_playtime_tooltip": "Час гри було оновлено вручну",
"try_again": "Будь ласка, попробуйте ще раз",
"add_friends": "Добавити друзів",
"add": "Добавити",
"add_friends": "Додати друзів",
"add": "Додати",
"friend_code": "Код друга",
"see_profile": "Переглянути профіль",
"sending": "Надсилання",
@@ -425,7 +614,7 @@
"friends_list": "Список друзів",
"user_not_found": "Користувача не найдено",
"block_user": "Заблокувати користувача",
"add_friend": "Добавити друга",
"add_friend": "Додати друга",
"request_sent": "надіслано запит на дружбу",
"request_received": "Отримано запит на дружбу",
"accept_request": "Прийняти запит",
@@ -473,7 +662,14 @@
"achievements_unlocked": "Досягнень розблоковано",
"earned_points": "Отримано балів",
"show_achievements_on_profile": "Покажіть свої досягнення у своєму профілі",
"show_points_on_profile": "Покажіть ваші отриманні бали у своєму профілі"
"show_points_on_profile": "Покажіть ваші отриманні бали у своєму профілі",
"error_adding_friend": "Не вдалося відправити запит на дружбу. Будь ласка, перевірте код друга",
"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,15 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const checkGameReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
return HydraApi.get(`/games/${shop}/${objectId}/reviews/check`, null, {
needsAuth: true,
});
};
registerEvent("checkGameReview", checkGameReview);

View File

@@ -1,18 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const createGameReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) => {
return HydraApi.post(`/games/${shop}/${objectId}/reviews`, {
reviewHtml,
score,
});
};
registerEvent("createGameReview", createGameReview);

View File

@@ -1,14 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const deleteReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewId: string
) => {
return HydraApi.delete(`/games/${shop}/${objectId}/reviews/${reviewId}`);
};
registerEvent("deleteReview", deleteReview);

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

@@ -1,26 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const getGameReviews = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
take: number = 20,
skip: number = 0,
sortBy: string = "newest"
) => {
const params = new URLSearchParams({
take: take.toString(),
skip: skip.toString(),
sortBy,
});
return HydraApi.get(
`/games/${shop}/${objectId}/reviews?${params.toString()}`,
null,
{ needsAuth: false }
);
};
registerEvent("getGameReviews", getGameReviews);

View File

@@ -1,16 +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> => {
return HydraApi.get(`/games/${shop}/${objectId}/how-long-to-beat`, 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[]>(
"/catalogue/featured",
{ language },
{ needsAuth: false }
).catch(() => []);
return trendingGames.slice(0, 1);
};
registerEvent("getTrendingGames", getTrendingGames);

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,18 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const voteReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewId: string,
voteType: "upvote" | "downvote"
) => {
return HydraApi.put(
`/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`,
{}
);
};
registerEvent("voteReview", voteReview);

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

@@ -0,0 +1,76 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { HydraApi, logger } from "@main/services";
import { importDownloadSourceToLocal } from "./helpers";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const result = await importDownloadSourceToLocal(url, true);
if (!result) {
throw new Error("Failed to import download source");
}
// Verify that repacks were actually written to the database (read-after-write)
// This ensures all async operations are complete before proceeding
let repackCount = 0;
for await (const [, repack] of repacksSublevel.iterator()) {
if (repack.downloadSourceId === result.id) {
repackCount++;
}
}
await HydraApi.post("/profile/download-sources", {
urls: [url],
});
const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds: result.objectIds,
},
{ needsAuth: false }
);
// Update the source with fingerprint
const updatedSource = await downloadSourcesSublevel.get(`${result.id}`);
if (updatedSource) {
await downloadSourcesSublevel.put(`${result.id}`, {
...updatedSource,
fingerprint,
updatedAt: new Date(),
});
}
// Final verification: ensure the source with fingerprint is persisted
const finalSource = await downloadSourcesSublevel.get(`${result.id}`);
if (!finalSource || !finalSource.fingerprint) {
throw new Error("Failed to persist download source with fingerprint");
}
// Verify repacks still exist after fingerprint update
let finalRepackCount = 0;
for await (const [, repack] of repacksSublevel.iterator()) {
if (repack.downloadSourceId === result.id) {
finalRepackCount++;
}
}
if (finalRepackCount !== repackCount) {
logger.warn(
`Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}`
);
} else {
logger.info(
`Final verification passed: ${finalRepackCount} repacks confirmed`
);
}
return {
...result,
fingerprint,
};
};
registerEvent("addDownloadSource", addDownloadSource);

View File

@@ -0,0 +1,17 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
const checkDownloadSourceExists = async (
_event: Electron.IpcMainInvokeEvent,
url: string
): Promise<boolean> => {
for await (const [, source] of downloadSourcesSublevel.iterator()) {
if (source.url === url) {
return true;
}
}
return false;
};
registerEvent("checkDownloadSourceExists", checkDownloadSourceExists);

View File

@@ -1,13 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const createDownloadSources = async (
_event: Electron.IpcMainInvokeEvent,
urls: string[]
) => {
await HydraApi.post("/profile/download-sources", {
urls,
});
};
registerEvent("createDownloadSources", createDownloadSources);

View File

@@ -0,0 +1,13 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { invalidateIdCaches } from "./helpers";
const deleteAllDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
) => {
await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]);
invalidateIdCaches();
};
registerEvent("deleteAllDownloadSources", deleteAllDownloadSources);

View File

@@ -0,0 +1,28 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { invalidateIdCaches } from "./helpers";
const deleteDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => {
const repacksToDelete: string[] = [];
for await (const [key, repack] of repacksSublevel.iterator()) {
if (repack.downloadSourceId === id) {
repacksToDelete.push(key);
}
}
const batch = repacksSublevel.batch();
for (const key of repacksToDelete) {
batch.del(key);
}
await batch.write();
await downloadSourcesSublevel.del(`${id}`);
invalidateIdCaches();
};
registerEvent("deleteDownloadSource", deleteDownloadSource);

View File

@@ -0,0 +1,19 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, DownloadSource } from "@main/level";
const getDownloadSourcesList = async (_event: Electron.IpcMainInvokeEvent) => {
const sources: DownloadSource[] = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
sources.push(source);
}
// Sort by createdAt descending
sources.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return sources;
};
registerEvent("getDownloadSourcesList", getDownloadSourcesList);

View File

@@ -0,0 +1,367 @@
import axios from "axios";
import { z } from "zod";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared";
import crypto from "node:crypto";
import { logger, ResourceCache } from "@main/services";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
export type TitleHashMapping = Record<string, number[]>;
let titleHashMappingCache: TitleHashMapping | null = null;
export const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
if (titleHashMappingCache) {
return titleHashMappingCache;
}
try {
const cached =
ResourceCache.getCachedData<TitleHashMapping>("sources-manifest");
if (cached) {
titleHashMappingCache = cached;
return cached;
}
const fetched = await ResourceCache.fetchAndCache<TitleHashMapping>(
"sources-manifest",
"https://cdn.losbroxas.org/sources-manifest.json",
10000
);
titleHashMappingCache = fetched;
return fetched;
} catch (error) {
logger.error("Failed to fetch title hash mapping:", error);
return {} as TitleHashMapping;
}
};
export const hashTitle = (title: string): string => {
return crypto.createHash("sha256").update(title).digest("hex");
};
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
export type FormattedSteamGame = {
id: string;
name: string;
formattedName: string;
};
export type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
export const formatName = (name: string) => {
return name
.normalize("NFD")
.replaceAll(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replaceAll(/[^a-z0-9]/g, "");
};
export const formatRepackName = (name: string) => {
return formatName(name.replace("[DL]", ""));
};
interface DownloadSource {
id: number;
url: string;
name: string;
etag: string | null;
status: number;
downloadCount: number;
objectIds: string[];
fingerprint?: string;
createdAt: Date;
updatedAt: Date;
}
const getDownloadSourcesMap = async (): Promise<
Map<string, DownloadSource>
> => {
const map = new Map();
for await (const [key, source] of downloadSourcesSublevel.iterator()) {
map.set(key, source);
}
return map;
};
export const checkUrlExists = async (url: string): Promise<boolean> => {
const sources = await getDownloadSourcesMap();
for (const source of sources.values()) {
if (source.url === url) {
return true;
}
}
return false;
};
let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null;
export const getSteamGames = async (): Promise<FormattedSteamGamesByLetter> => {
if (steamGamesFormattedCache) {
return steamGamesFormattedCache;
}
let steamGames: SteamGamesByLetter;
const cached = ResourceCache.getCachedData<SteamGamesByLetter>(
"steam-games-by-letter"
);
if (cached) {
steamGames = cached;
} else {
steamGames = await ResourceCache.fetchAndCache<SteamGamesByLetter>(
"steam-games-by-letter",
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
);
}
const formattedData: FormattedSteamGamesByLetter = {};
for (const [letter, games] of Object.entries(steamGames)) {
formattedData[letter] = games.map((game) => ({
...game,
formattedName: formatName(game.name),
}));
}
steamGamesFormattedCache = formattedData;
return formattedData;
};
export type SublevelIterator = AsyncIterable<[string, { id: number }]>;
export interface SublevelWithId {
iterator: () => SublevelIterator;
}
let maxRepackId: number | null = null;
let maxDownloadSourceId: number | null = null;
export const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
const isRepackSublevel = sublevel === repacksSublevel;
const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel;
if (isRepackSublevel && maxRepackId !== null) {
return ++maxRepackId;
}
if (isDownloadSourceSublevel && maxDownloadSourceId !== null) {
return ++maxDownloadSourceId;
}
let maxId = 0;
for await (const [, value] of sublevel.iterator()) {
if (value.id > maxId) {
maxId = value.id;
}
}
if (isRepackSublevel) {
maxRepackId = maxId;
} else if (isDownloadSourceSublevel) {
maxDownloadSourceId = maxId;
}
return maxId + 1;
};
export const invalidateIdCaches = () => {
maxRepackId = null;
maxDownloadSourceId = null;
};
export const addNewDownloads = async (
downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: FormattedSteamGamesByLetter
) => {
const now = new Date();
const objectIdsOnSource = new Set<string>();
let nextRepackId = await getNextId(repacksSublevel);
const batch = repacksSublevel.batch();
const titleHashMapping = await getTitleHashMapping();
let hashMatchCount = 0;
let fuzzyMatchCount = 0;
let noMatchCount = 0;
for (const download of downloads) {
let objectIds: string[] = [];
let usedHashMatch = false;
const titleHash = hashTitle(download.title);
const steamIdsFromHash = titleHashMapping[titleHash];
if (steamIdsFromHash && steamIdsFromHash.length > 0) {
hashMatchCount++;
usedHashMatch = true;
objectIds = steamIdsFromHash.map(String);
}
if (!usedHashMatch) {
let gamesInSteam: FormattedSteamGame[] = [];
const formattedTitle = formatRepackName(download.title);
if (formattedTitle && formattedTitle.length > 0) {
const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || [];
gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(game.formattedName)
);
if (gamesInSteam.length === 0) {
gamesInSteam = games.filter(
(game) =>
formattedTitle.includes(game.formattedName) ||
game.formattedName.includes(formattedTitle)
);
}
if (gamesInSteam.length === 0) {
for (const letter of Object.keys(steamGames)) {
const letterGames = steamGames[letter] || [];
const matches = letterGames.filter(
(game) =>
formattedTitle.includes(game.formattedName) ||
game.formattedName.includes(formattedTitle)
);
if (matches.length > 0) {
gamesInSteam = matches;
break;
}
}
}
if (gamesInSteam.length > 0) {
fuzzyMatchCount++;
objectIds = gamesInSteam.map((game) => String(game.id));
} else {
noMatchCount++;
}
} else {
noMatchCount++;
}
}
for (const id of objectIds) {
objectIdsOnSource.add(id);
}
const repack = {
id: nextRepackId++,
objectIds: objectIds,
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
};
batch.put(`${repack.id}`, repack);
}
await batch.write();
logger.info(
`Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
);
const existingSource = await downloadSourcesSublevel.get(
`${downloadSource.id}`
);
if (existingSource) {
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...existingSource,
objectIds: Array.from(objectIdsOnSource),
});
}
return Array.from(objectIdsOnSource);
};
export const importDownloadSourceToLocal = async (
url: string,
throwOnDuplicate = false
) => {
const urlExists = await checkUrlExists(url);
if (urlExists) {
if (throwOnDuplicate) {
throw new Error("Download source with this URL already exists");
}
return null;
}
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const steamGames = await getSteamGames();
const now = new Date();
const nextId = await getNextId(downloadSourcesSublevel);
const downloadSource = {
id: nextId,
url,
name: response.data.name,
etag: response.headers["etag"] || null,
status: DownloadSourceStatus.UpToDate,
downloadCount: response.data.downloads.length,
objectIds: [],
createdAt: now,
updatedAt: now,
};
await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource);
const objectIds = await addNewDownloads(
downloadSource,
response.data.downloads,
steamGames
);
// Invalidate ID caches after creating new repacks to prevent ID collisions
invalidateIdCaches();
return {
...downloadSource,
objectIds,
};
};
export const updateDownloadSourcePreservingTimestamp = async (
existingSource: DownloadSource,
url: string
) => {
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const updatedSource = {
...existingSource,
name: response.data.name,
etag: response.headers["etag"] || null,
status: DownloadSourceStatus.UpToDate,
downloadCount: response.data.downloads.length,
updatedAt: new Date(),
// Preserve the original createdAt timestamp
};
await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource);
return updatedSource;
};

View File

@@ -1,17 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const putDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
objectIds: string[]
) => {
return HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds,
},
{ needsAuth: false }
);
};
registerEvent("putDownloadSource", putDownloadSource);

View File

@@ -0,0 +1,19 @@
import { HydraApi, logger } from "@main/services";
import { importDownloadSourceToLocal, checkUrlExists } from "./helpers";
export const syncDownloadSourcesFromApi = async () => {
try {
const apiSources = await HydraApi.get<
{ url: string; createdAt: string; updatedAt: string }[]
>("/profile/download-sources");
for (const apiSource of apiSources) {
const exists = await checkUrlExists(apiSource.url);
if (!exists) {
await importDownloadSourceToLocal(apiSource.url, false);
}
}
} catch (error) {
logger.error("Failed to sync download sources from API:", error);
}
};

View File

@@ -0,0 +1,115 @@
import { registerEvent } from "../register-event";
import axios, { AxiosError } from "axios";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared";
import {
invalidateIdCaches,
downloadSourceSchema,
getSteamGames,
addNewDownloads,
} from "./helpers";
const syncDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
): Promise<number> => {
let newRepacksCount = 0;
try {
const downloadSources: Array<{
id: number;
url: string;
name: string;
etag: string | null;
status: number;
downloadCount: number;
objectIds: string[];
fingerprint?: string;
createdAt: Date;
updatedAt: Date;
}> = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
downloadSources.push(source);
}
const existingRepacks: Array<{
id: number;
title: string;
uris: string[];
repacker: string;
fileSize: string | null;
objectIds: string[];
uploadDate: Date | string | null;
downloadSourceId: number;
createdAt: Date;
updatedAt: Date;
}> = [];
for await (const [, repack] of repacksSublevel.iterator()) {
existingRepacks.push(repack);
}
// Handle sources with missing fingerprints individually, don't delete all sources
const sourcesWithFingerprints = downloadSources.filter(
(source) => source.fingerprint
);
const sourcesWithoutFingerprints = downloadSources.filter(
(source) => !source.fingerprint
);
// For sources without fingerprints, just continue with normal sync
// They will get fingerprints updated later by updateMissingFingerprints
const allSourcesToSync = [
...sourcesWithFingerprints,
...sourcesWithoutFingerprints,
];
for (const downloadSource of allSourcesToSync) {
const headers: Record<string, string> = {};
if (downloadSource.etag) {
headers["If-None-Match"] = downloadSource.etag;
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
const steamGames = await getSteamGames();
const repacks = source.downloads.filter(
(download) =>
!existingRepacks.some((repack) => repack.title === download.title)
);
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...downloadSource,
etag: response.headers["etag"] || null,
downloadCount: source.downloads.length,
status: DownloadSourceStatus.UpToDate,
});
await addNewDownloads(downloadSource, repacks, steamGames);
newRepacksCount += repacks.length;
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...downloadSource,
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
invalidateIdCaches();
return newRepacksCount;
} catch (err) {
return -1;
}
};
registerEvent("syncDownloadSources", syncDownloadSources);

View File

@@ -0,0 +1,67 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
import { HydraApi, logger } from "@main/services";
const updateMissingFingerprints = async (
_event: Electron.IpcMainInvokeEvent
): Promise<number> => {
const sourcesNeedingFingerprints: Array<{
id: number;
objectIds: string[];
}> = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
if (
!source.fingerprint &&
source.objectIds &&
source.objectIds.length > 0
) {
sourcesNeedingFingerprints.push({
id: source.id,
objectIds: source.objectIds,
});
}
}
if (sourcesNeedingFingerprints.length === 0) {
return 0;
}
logger.info(
`Updating fingerprints for ${sourcesNeedingFingerprints.length} sources`
);
await Promise.all(
sourcesNeedingFingerprints.map(async (source) => {
try {
const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds: source.objectIds,
},
{ needsAuth: false }
);
const existingSource = await downloadSourcesSublevel.get(
`${source.id}`
);
if (existingSource) {
await downloadSourcesSublevel.put(`${source.id}`, {
...existingSource,
fingerprint,
updatedAt: new Date(),
});
}
} catch (error) {
logger.error(
`Failed to update fingerprint for source ${source.id}:`,
error
);
}
})
);
return sourcesNeedingFingerprints.length;
};
registerEvent("updateMissingFingerprints", updateMissingFingerprints);

View File

@@ -0,0 +1,32 @@
import { registerEvent } from "../register-event";
import axios from "axios";
import { z } from "zod";
const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
const validateDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const { name } = downloadSourceSchema.parse(response.data);
return {
name,
etag: response.headers["etag"] || null,
downloadCount: response.data.downloads.length,
};
};
registerEvent("validateDownloadSource", validateDownloadSource);

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,20 +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/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 "./catalogue/create-game-review";
import "./catalogue/get-game-reviews";
import "./catalogue/vote-review";
import "./catalogue/delete-review";
import "./catalogue/check-game-review";
import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";
@@ -50,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";
@@ -70,39 +61,30 @@ 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 "./download-sources/add-download-source";
import "./download-sources/update-missing-fingerprints";
import "./download-sources/delete-download-source";
import "./download-sources/delete-all-download-sources";
import "./download-sources/validate-download-source";
import "./download-sources/sync-download-sources";
import "./download-sources/get-download-sources-list";
import "./download-sources/check-download-source-exists";
import "./repacks/get-all-repacks";
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";
@@ -116,7 +98,6 @@ import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme";
import "./themes/close-editor-window";
import "./themes/toggle-custom-theme";
import "./download-sources/create-download-sources";
import "./download-sources/remove-download-source";
import "./download-sources/get-download-sources";
import { isPortableVersion } from "@main/helpers";

View File

@@ -1,6 +1,6 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
import { randomUUID } from "crypto";
import { randomUUID } from "node:crypto";
import type { GameShop } from "@types";
const addCustomGameToLibrary = async (

View File

@@ -19,7 +19,6 @@ const getAllCustomGameAssets = async (): Promise<string[]> => {
};
const getUsedAssetPaths = async (): Promise<Set<string>> => {
// Get all custom games from the level database
const { gamesSublevel } = await import("@main/level");
const allGames = await gamesSublevel.iterator().all();
@@ -30,7 +29,6 @@ const getUsedAssetPaths = async (): Promise<Set<string>> => {
const usedPaths = new Set<string>();
customGames.forEach((game) => {
// Extract file paths from local URLs
if (game.iconUrl?.startsWith("local:")) {
usedPaths.add(game.iconUrl.replace("local:", ""));
}

View File

@@ -1,7 +1,7 @@
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "crypto";
import { randomUUID } from "node:crypto";
import { ASSETS_PATH } from "@main/constants";
const copyCustomGameAsset = async (
@@ -13,29 +13,23 @@ const copyCustomGameAsset = async (
throw new Error("Source file does not exist");
}
// Ensure assets directory exists
if (!fs.existsSync(ASSETS_PATH)) {
fs.mkdirSync(ASSETS_PATH, { recursive: true });
}
// Create custom games assets subdirectory
const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games");
if (!fs.existsSync(customGamesAssetsPath)) {
fs.mkdirSync(customGamesAssetsPath, { recursive: true });
}
// Get file extension
const fileExtension = path.extname(sourcePath);
// Generate unique filename
const uniqueId = randomUUID();
const fileName = `${assetType}-${uniqueId}${fileExtension}`;
const destinationPath = path.join(customGamesAssetsPath, fileName);
// Copy the file
await fs.promises.copyFile(sourcePath, destinationPath);
// Return the local URL format
return `local:${destinationPath}`;
};

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

@@ -0,0 +1,16 @@
import { registerEvent } from "../register-event";
import { repacksSublevel, GameRepack } from "@main/level";
const getAllRepacks = async (_event: Electron.IpcMainInvokeEvent) => {
const repacks: GameRepack[] = [];
for await (const [, repack] of repacksSublevel.iterator()) {
if (Array.isArray(repack.objectIds)) {
repacks.push(repack);
}
}
return repacks;
};
registerEvent("getAllRepacks", getAllRepacks);

View File

@@ -1,17 +0,0 @@
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

@@ -0,0 +1,22 @@
import { db } from "../level";
import { levelKeys } from "./keys";
export interface DownloadSource {
id: number;
name: string;
url: string;
status: number;
objectIds: string[];
downloadCount: number;
fingerprint?: string;
etag: string | null;
createdAt: Date;
updatedAt: Date;
}
export const downloadSourcesSublevel = db.sublevel<string, DownloadSource>(
levelKeys.downloadSources,
{
valueEncoding: "json",
}
);

View File

@@ -6,3 +6,5 @@ export * from "./game-stats-cache";
export * from "./game-achievements";
export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./repacks";

View File

@@ -17,4 +17,6 @@ export const levelKeys = {
language: "language",
screenState: "screenState",
rpcPassword: "rpcPassword",
downloadSources: "downloadSources",
repacks: "repacks",
};

View File

@@ -0,0 +1,22 @@
import { db } from "../level";
import { levelKeys } from "./keys";
export interface GameRepack {
id: number;
title: string;
uris: string[];
repacker: string;
fileSize: string | null;
objectIds: string[];
uploadDate: Date | string | null;
downloadSourceId: number;
createdAt: Date;
updatedAt: Date;
}
export const repacksSublevel = db.sublevel<string, GameRepack>(
levelKeys.repacks,
{
valueEncoding: "json",
}
);

View File

@@ -8,7 +8,6 @@ import {
CommonRedistManager,
TorBoxClient,
RealDebridClient,
AllDebridClient,
Aria2,
DownloadManager,
HydraApi,
@@ -16,11 +15,16 @@ import {
startMainLoop,
Ludusavi,
Lock,
DeckyPlugin,
ResourceCache,
} from "@main/services";
export const loadState = async () => {
await Lock.acquireLock();
ResourceCache.initialize();
await ResourceCache.updateResourcesOnStartup();
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
@@ -38,10 +42,6 @@ export const loadState = async () => {
RealDebridClient.authorize(userPreferences.realDebridApiToken);
}
if (userPreferences?.allDebridApiKey) {
AllDebridClient.authorize(userPreferences.allDebridApiKey);
}
if (userPreferences?.torBoxApiToken) {
TorBoxClient.authorize(userPreferences.torBoxApiToken);
}
@@ -49,6 +49,10 @@ export const loadState = async () => {
Ludusavi.copyConfigFileToUserData();
Ludusavi.copyBinaryToUserData();
if (process.platform === "linux") {
DeckyPlugin.checkAndUpdateIfOutdated();
}
await HydraApi.setupApi().then(() => {
uploadGamesBatch();
// WSClient.connect();

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

@@ -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

@@ -1,315 +0,0 @@
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,7 +17,6 @@ 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";
@@ -379,27 +378,6 @@ 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

@@ -1,4 +1,3 @@
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;
@@ -102,8 +102,14 @@ export class HydraApi {
WindowManager.mainWindow.webContents.send("on-signin");
await clearGamesRemoteIds();
uploadGamesBatch();
// WSClient.close();
// WSClient.connect();
const { syncDownloadSourcesFromApi } = await import(
"../events/download-sources/sync-download-sources-from-api"
);
syncDownloadSourcesFromApi();
}
}

View File

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

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

@@ -0,0 +1,157 @@
import { app } from "electron";
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
import { logger } from "./logger";
interface CachedResource<T = unknown> {
data: T;
etag: string | null;
}
export class ResourceCache {
private static cacheDir: string;
static initialize() {
this.cacheDir = path.join(app.getPath("userData"), "resource-cache");
if (!fs.existsSync(this.cacheDir)) {
fs.mkdirSync(this.cacheDir, { recursive: true });
}
}
private static getCacheFilePath(resourceName: string): string {
return path.join(this.cacheDir, `${resourceName}.json`);
}
private static getEtagFilePath(resourceName: string): string {
return path.join(this.cacheDir, `${resourceName}.etag`);
}
private static readCachedResource<T = unknown>(
resourceName: string
): CachedResource<T> | null {
const dataPath = this.getCacheFilePath(resourceName);
const etagPath = this.getEtagFilePath(resourceName);
if (!fs.existsSync(dataPath)) {
return null;
}
try {
const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")) as T;
const etag = fs.existsSync(etagPath)
? fs.readFileSync(etagPath, "utf-8")
: null;
return { data, etag };
} catch (error) {
logger.error(`Failed to read cached resource ${resourceName}:`, error);
return null;
}
}
private static writeCachedResource<T = unknown>(
resourceName: string,
data: T,
etag: string | null
): void {
const dataPath = this.getCacheFilePath(resourceName);
const etagPath = this.getEtagFilePath(resourceName);
try {
fs.writeFileSync(dataPath, JSON.stringify(data), "utf-8");
if (etag) {
fs.writeFileSync(etagPath, etag, "utf-8");
}
logger.info(
`Cached resource ${resourceName} with etag: ${etag || "none"}`
);
} catch (error) {
logger.error(`Failed to write cached resource ${resourceName}:`, error);
}
}
static async fetchAndCache<T = unknown>(
resourceName: string,
url: string,
timeout: number = 10000
): Promise<T> {
const cached = this.readCachedResource<T>(resourceName);
const headers: Record<string, string> = {};
if (cached?.etag) {
headers["If-None-Match"] = cached.etag;
}
try {
const response = await axios.get<T>(url, {
headers,
timeout,
});
const newEtag = response.headers["etag"] || null;
this.writeCachedResource(resourceName, response.data, newEtag);
return response.data;
} catch (error: unknown) {
const axiosError = error as {
response?: { status?: number };
message?: string;
};
if (axiosError.response?.status === 304 && cached) {
logger.info(`Resource ${resourceName} not modified, using cache`);
return cached.data;
}
if (cached) {
logger.warn(
`Failed to fetch ${resourceName}, using cached version:`,
axiosError.message || "Unknown error"
);
return cached.data;
}
logger.error(
`Failed to fetch ${resourceName} and no cache available:`,
error
);
throw error;
}
}
static getCachedData<T = unknown>(resourceName: string): T | null {
const cached = this.readCachedResource<T>(resourceName);
return cached?.data || null;
}
static async updateResourcesOnStartup(): Promise<void> {
logger.info("Starting background resource cache update...");
const resources = [
{
name: "steam-games-by-letter",
url: `${process.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`,
},
{
name: "sources-manifest",
url: "https://cdn.losbroxas.org/sources-manifest.json",
},
];
await Promise.allSettled(
resources.map(async (resource) => {
try {
await this.fetchAndCache(resource.name, resource.url);
} catch (error) {
logger.error(`Failed to update ${resource.name} on startup:`, error);
}
})
);
logger.info("Resource cache update complete");
}
}

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") {

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 (import.meta.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

@@ -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,7 +11,6 @@ import type {
GameRunning,
FriendRequestAction,
UpdateProfileRequest,
CatalogueSearchPayload,
SeedingStatus,
GameAchievement,
Theme,
@@ -20,7 +19,7 @@ import type {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import type { AuthPage, CatalogueCategory } from "@shared";
import type { AuthPage } from "@shared";
import type { AxiosProgressEvent } from "axios";
contextBridge.exposeInMainWorld("electron", {
@@ -62,44 +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),
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),
getGameAssets: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameAssets", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
createGameReview: (
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) =>
ipcRenderer.invoke("createGameReview", shop, objectId, reviewHtml, score),
getGameReviews: (
shop: GameShop,
objectId: string,
take?: number,
skip?: number,
sortBy?: string
) => ipcRenderer.invoke("getGameReviews", shop, objectId, take, skip, sortBy),
voteReview: (
shop: GameShop,
objectId: string,
reviewId: string,
voteType: "upvote" | "downvote"
) => ipcRenderer.invoke("voteReview", shop, objectId, reviewId, voteType),
deleteReview: (shop: GameShop, objectId: string, reviewId: string) =>
ipcRenderer.invoke("deleteReview", shop, objectId, reviewId),
checkGameReview: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("checkGameReview", shop, objectId),
onUpdateAchievements: (
objectId: string,
shop: GameShop,
@@ -125,19 +93,28 @@ 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),
/* Download sources */
putDownloadSource: (objectIds: string[]) =>
ipcRenderer.invoke("putDownloadSource", objectIds),
createDownloadSources: (urls: string[]) =>
ipcRenderer.invoke("createDownloadSources", urls),
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
updateMissingFingerprints: () =>
ipcRenderer.invoke("updateMissingFingerprints"),
removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
deleteDownloadSource: (id: number) =>
ipcRenderer.invoke("deleteDownloadSource", id),
deleteAllDownloadSources: () =>
ipcRenderer.invoke("deleteAllDownloadSources"),
validateDownloadSource: (url: string) =>
ipcRenderer.invoke("validateDownloadSource", url),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesList: () => ipcRenderer.invoke("getDownloadSourcesList"),
checkDownloadSourceExists: (url: string) =>
ipcRenderer.invoke("checkDownloadSourceExists", url),
getAllRepacks: () => ipcRenderer.invoke("getAllRepacks"),
/* Library */
toggleAutomaticCloudSync: (
@@ -309,10 +286,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,
@@ -323,8 +296,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,
@@ -381,10 +352,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 */
@@ -415,13 +475,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 = (
@@ -434,26 +491,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

@@ -20,11 +20,10 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
setIsImportingSources,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
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";
@@ -136,15 +135,6 @@ export function App() {
}, [fetchUserDetails, updateUserDetails, dispatch]);
const onSignIn = useCallback(() => {
window.electron.getDownloadSources().then((sources) => {
sources.forEach((source) => {
downloadSourcesWorker.postMessage([
"IMPORT_DOWNLOAD_SOURCE",
source.url,
]);
});
});
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
@@ -210,41 +200,34 @@ export function App() {
}, [dispatch, draggingDisabled]);
useEffect(() => {
updateRepacks();
(async () => {
dispatch(setIsImportingSources(true));
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
try {
// Initial repacks load
await updateRepacks();
channel.onmessage = async (event: MessageEvent<number>) => {
const newRepacksCount = event.data;
window.electron.publishNewRepacksNotification(newRepacksCount);
updateRepacks();
// Sync all local sources (check for updates)
const newRepacksCount = await window.electron.syncDownloadSources();
const downloadSources = await downloadSourcesTable.toArray();
if (newRepacksCount > 0) {
window.electron.publishNewRepacksNotification(newRepacksCount);
}
await Promise.all(
downloadSources
.filter((source) => !source.fingerprint)
.map(async (downloadSource) => {
const { fingerprint } = await window.electron.putDownloadSource(
downloadSource.objectIds
);
// Update fingerprints for sources that don't have them
await window.electron.updateMissingFingerprints();
return downloadSourcesTable.update(downloadSource.id, {
fingerprint,
});
})
);
channel.close();
};
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
return () => {
channel.close();
};
}, [updateRepacks]);
// Update repacks AFTER all syncing and fingerprint updates are complete
await updateRepacks();
} catch (error) {
console.error("Error syncing download sources:", error);
// Still update repacks even if sync fails
await updateRepacks();
} finally {
dispatch(setIsImportingSources(false));
}
})();
}, [updateRepacks, dispatch]);
const loadAndApplyTheme = useCallback(async () => {
const activeTheme = await window.electron.getActiveCustomTheme();

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View File

@@ -302,7 +302,8 @@ $margin-bottom: 28px;
}
&--rare &__trophy-overlay {
background: linear-gradient(
background:
linear-gradient(
118deg,
#e8ad15 18.96%,
#d5900f 26.41%,

View File

@@ -1,11 +0,0 @@
@use "../../scss/globals.scss";
.confirm-modal {
&__actions {
display: flex;
width: 100%;
justify-content: flex-end;
align-items: center;
gap: globals.$spacing-unit;
}
}

View File

@@ -1,57 +0,0 @@
import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components";
import "./confirm-modal.scss";
export interface ConfirmModalProps {
visible: boolean;
title: string;
description?: string;
onClose: () => void;
onConfirm: () => Promise<void> | void;
confirmLabel?: string;
cancelLabel?: string;
confirmTheme?: "primary" | "outline" | "danger";
confirmDisabled?: boolean;
}
export function ConfirmModal({
visible,
title,
description,
onClose,
onConfirm,
confirmLabel,
cancelLabel,
confirmTheme = "outline",
confirmDisabled = false,
}: ConfirmModalProps) {
const { t } = useTranslation();
const handleConfirm = async () => {
await onConfirm();
onClose();
};
return (
<Modal
visible={visible}
title={title}
description={description}
onClose={onClose}
>
<div className="confirm-modal__actions">
<Button
onClick={handleConfirm}
theme={confirmTheme}
disabled={confirmDisabled}
>
{confirmLabel || t("confirm")}
</Button>
<Button onClick={onClose} theme="primary">
{cancelLabel || t("cancel")}
</Button>
</div>
</Modal>
);
}

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

@@ -19,7 +19,6 @@
&__item-container {
position: relative;
padding-right: 8px;
}
&__item {
@@ -97,7 +96,7 @@
&__submenu {
position: absolute;
left: calc(100% - 2px);
left: 100%;
top: 0;
background-color: globals.$background-color;
border: 1px solid globals.$border-color;

View File

@@ -144,9 +144,9 @@ export function ContextMenu({
if (parentRect.right + submenuWidth > viewportWidth - 8) {
styles.left = "auto";
styles.right = "calc(100% - 2px)";
styles.right = "100%";
} else {
styles.left = "calc(100% - 2px)";
styles.left = "100%";
styles.right = undefined;
}

View File

@@ -109,12 +109,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
</span>
</div>
<div className="game-card__specifics-item">
<StarRating
rating={stats?.averageScore || null}
size={14}
showCalculating={!!(stats && stats.averageScore === null)}
calculatingText={t("calculating")}
/>
<StarRating rating={stats?.averageScore || null} size={14} />
</div>
</div>
</div>

View File

@@ -14,9 +14,13 @@ import {
} from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { LibraryGame } from "@types";
import { ContextMenu, ContextMenuItemData, ContextMenuProps } from "..";
import { ConfirmModal } from "@renderer/components/confirm-modal/confirm-modal";
import { useGameActions } from "..";
import {
ContextMenu,
ContextMenuItemData,
ContextMenuProps,
ConfirmationModal,
useGameActions,
} from "..";
interface GameContextMenuProps extends Omit<ContextMenuProps, "items"> {
game: LibraryGame;
@@ -36,9 +40,11 @@ export function GameContextMenu({
canPlay,
isDeleting,
isGameDownloading,
isGameRunning,
hasRepacks,
shouldShowCreateStartMenuShortcut,
handlePlayGame,
handleCloseGame,
handleToggleFavorite,
handleCreateShortcut,
handleCreateSteamShortcut,
@@ -53,10 +59,20 @@ export function GameContextMenu({
const items: ContextMenuItemData[] = [
{
id: "play",
label: canPlay ? t("play") : t("download"),
icon: canPlay ? <PlayIcon size={16} /> : <DownloadIcon size={16} />,
label: isGameRunning ? t("close") : canPlay ? t("play") : t("download"),
icon: isGameRunning ? (
<XIcon size={16} />
) : canPlay ? (
<PlayIcon size={16} />
) : (
<DownloadIcon size={16} />
),
onClick: () => {
void handlePlayGame();
if (isGameRunning) {
void handleCloseGame();
} else {
void handlePlayGame();
}
},
disabled: isDeleting,
},
@@ -195,36 +211,40 @@ export function GameContextMenu({
}
/>
<ConfirmModal
<ConfirmationModal
visible={showConfirmRemoveLibrary}
title={t("remove_from_library_title")}
description={t("remove_from_library_description", { game: game.title })}
descriptionText={t("remove_from_library_description", {
game: game.title,
})}
onClose={() => {
setShowConfirmRemoveLibrary(false);
onClose();
}}
onConfirm={async () => {
setShowConfirmRemoveLibrary(false);
onClose();
await handleRemoveFromLibrary();
}}
confirmLabel={t("remove")}
cancelLabel={t("cancel")}
confirmTheme="danger"
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
/>
<ConfirmModal
<ConfirmationModal
visible={showConfirmRemoveFiles}
title={t("remove_files")}
description={t("delete_modal_description", { ns: "downloads" })}
descriptionText={t("delete_modal_description", { ns: "downloads" })}
onClose={() => {
setShowConfirmRemoveFiles(false);
onClose();
}}
onConfirm={async () => {
setShowConfirmRemoveFiles(false);
onClose();
await handleRemoveFiles();
}}
confirmLabel={t("remove")}
cancelLabel={t("cancel")}
confirmTheme="danger"
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
/>
</>
);

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { LibraryGame, ShortcutLocation } from "@types";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
@@ -21,6 +21,7 @@ export function useGameActions(game: LibraryGame) {
} = useDownload();
const [creatingSteamShortcut, setCreatingSteamShortcut] = useState(false);
const [isGameRunning, setIsGameRunning] = useState(false);
const canPlay = Boolean(game.executablePath);
const isDeleting = isGameDeleting(game.id);
@@ -30,6 +31,20 @@ export function useGameActions(game: LibraryGame) {
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({
@@ -75,6 +90,15 @@ export function useGameActions(game: LibraryGame) {
}
};
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) {
@@ -239,10 +263,12 @@ export function useGameActions(game: LibraryGame) {
canPlay,
isDeleting,
isGameDownloading,
isGameRunning,
hasRepacks,
shouldShowCreateStartMenuShortcut,
creatingSteamShortcut,
handlePlayGame,
handleCloseGame,
handleToggleFavorite,
handleCreateShortcut,
handleCreateSteamShortcut,

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

@@ -5,10 +5,7 @@
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
width: 100%;
max-width: 500px;
margin: 0 auto;
text-align: center;
min-width: 500px;
}
&__form {

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