Compare commits

...

85 Commits

Author SHA1 Message Date
Moyasee
aadbda770b fix: linting issue, marked props as read-only 2025-10-31 00:19:49 +02:00
Moyasee
bd059cc7fa feat: update cursorrules 2025-10-30 23:45:29 +02:00
Moyasee
bbbf861594 fix: deleted comments 2025-10-30 23:36:41 +02:00
Moyasee
80e0adcd49 fix: removed ability to enter non-number symbols to pagination 2025-10-30 23:33:07 +02:00
Zamitto
1bd88e6c6e Merge pull request #1837 from Stormm232/main
Updating Hungarian Translation
2025-10-29 23:06:22 -03:00
Kiwo.2
4ff8dc4fa7 Fix with Prettier 2025-10-30 02:32:18 +01:00
Kiwo.2
dcc671f999 Mistake Correction 2025-10-30 02:15:35 +01:00
Kiwo.2
6e76111e23 Missing Comma Fix 2025-10-30 02:10:02 +01:00
Kiwo.2
3fce26f1f7 Update to 3.7.2 2025-10-30 01:55:15 +01:00
Kiwo.2
90c5ccb796 Update to 3.7.2 2025-10-30 01:40:36 +01:00
Kiwo.2
41092c2dd4 Update to 3.7.2 2025-10-30 01:35:54 +01:00
Kiwo.2
6383b728bc Fix to the translation 2025-10-30 01:26:51 +01:00
Kiwo.2
4dd28bbbf1 Hungarian Translation 3.7.2 2025-10-30 01:12:29 +01:00
Zamitto
21074322fa Merge pull request #1836 from Wkeynhk/patch-6
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
Update Russian Translation
2025-10-29 20:32:30 -03:00
Wkeynhk
0e7e53478a Update translation.json 2025-10-30 00:47:30 +03:00
Zamitto
65e49550ad chore: fix aur package 2025-10-29 18:10:27 -03:00
Zamitto
0990951183 chore: fix aur package 2025-10-29 18:06:46 -03:00
Zamitto
53c162f0e4 feat: add i18n 2025-10-29 17:55:55 -03:00
Zamitto
2fb44a6c0e chore: remove build renderer trigger on main 2025-10-29 15:49:43 -03:00
Zamitto
49c2bc34d1 Merge branch 'release/v3.7.2' 2025-10-29 15:48:44 -03:00
Zamitto
49df40650c chore: prettier 2025-10-29 15:27:36 -03:00
Chubby Granny Chaser
499a830e3e chore: sync with main 2025-10-29 18:23:06 +00:00
Chubby Granny Chaser
437b0a3b19 Merge branch 'main' of https://github.com/hydralauncher/hydra into release/v3.7.2 2025-10-29 18:21:39 +00:00
Chubby Granny Chaser
d59ff5c484 Merge pull request #1814 from whintersnow0/refactor/remove-unnecessary-usememo
refactor: remove unnecessary useMemo hooks
2025-10-29 18:20:21 +00:00
Chubby Granny Chaser
dcf13a5920 Merge branch 'main' into refactor/remove-unnecessary-usememo 2025-10-29 18:19:39 +00:00
Chubby Granny Chaser
51861752a1 Merge pull request #1816 from hydralauncher/feat/improving-sources
feat: moving sources to worker
2025-10-29 18:19:35 +00:00
Chubby Granny Chaser
fb29152a0b Merge branch 'main' into feat/improving-sources 2025-10-29 18:19:07 +00:00
Zamitto
19bbbbfb77 Merge pull request #1835 from hydralauncher/fix/custom-assets-sizing
fix: images with big height breaking layout
2025-10-29 15:13:21 -03:00
Chubby Granny Chaser
952d4d9ee2 Merge branch 'main' into feat/improving-sources 2025-10-29 18:13:05 +00:00
Moyasee
ad588b5600 fix: images with big height breaking layout 2025-10-29 19:51:09 +02:00
Zamitto
c24ad34bc7 fix: hltb and achievements being called for custom games
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-29 12:42:39 -03:00
Zamitto
4f2c3105ce Merge pull request #1834 from hydralauncher/fix/custom-games-unnecessary-requests
fix: disabling unnecessary api calls when game is custom
2025-10-29 12:07:40 -03:00
Moyasee
feedcb1dc7 feat: disabled assets request for custom games 2025-10-29 16:49:51 +02:00
Moyasee
4b8d64c72b feat: disabled favorite/unfavorite get request for custom games 2025-10-29 16:44:48 +02:00
Moyasee
dff68a3e26 fix: removed comments 2025-10-29 16:22:12 +02:00
Moyasee
58bdbdab71 fix: disabling unnecessary api calls if game is custom 2025-10-29 16:16:11 +02:00
Chubby Granny Chaser
e143fadf38 fix: fixing import
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-29 02:55:37 +00:00
Chubby Granny Chaser
f99f8d9554 feat: forcing dev tools 2025-10-29 02:32:45 +00:00
Chubby Granny Chaser
274080069f feat: forcing dev tools 2025-10-29 02:12:17 +00:00
Chubby Granny Chaser
b1069426e4 chore: sync with main 2025-10-29 01:47:35 +00:00
Chubby Granny Chaser
dc6d578462 chore: sync with main 2025-10-28 23:49:09 +00:00
Chubby Granny Chaser
8a12c6e088 chore: sync with main 2025-10-28 23:26:28 +00:00
Chubby Granny Chaser
b795cea599 Merge branch 'main' of https://github.com/hydralauncher/hydra into release/v3.7.2 2025-10-28 23:20:44 +00:00
Chubby Granny Chaser
574a012d8c Merge pull request #1833 from hydralauncher/feat/settings-trailer-and-launch
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
Feat: New behavior settings: Trailers Auto-play and Hide on game launch
2025-10-28 23:11:57 +00:00
Chubby Granny Chaser
8e3bf29a21 Merge branch 'main' into feat/settings-trailer-and-launch 2025-10-28 23:11:19 +00:00
Chubby Granny Chaser
f6d21baff5 Merge pull request #1831 from hydralauncher/feat/catalogue-manual-pagination
Feat: Manual selection of the page in catalogue.
2025-10-28 23:09:53 +00:00
Chubby Granny Chaser
b2b7b36f70 Merge branch 'main' into feat/catalogue-manual-pagination 2025-10-28 23:08:46 +00:00
Chubby Granny Chaser
dc8a19e845 ci: adding ci vars 2025-10-28 23:02:40 +00:00
Chubby Granny Chaser
ce0619bbe3 ci: adding releases 2025-10-28 22:40:06 +00:00
Chubby Granny Chaser
4acb7f7001 Merge branch 'feat/improving-sources' of https://github.com/hydralauncher/hydra into feat/improving-sources 2025-10-28 21:38:24 +00:00
Chubby Granny Chaser
a11b3e8877 ci: fixing release pipeline 2025-10-28 21:38:07 +00:00
Chubby Granny Chaser
6b96c99bb1 ci: fixing release pipeline 2025-10-28 21:37:28 +00:00
Moyasee
dbf5d7afc7 fix: multiple imports 2025-10-28 17:43:19 +02:00
Moyasee
bfc4bb1a83 ci: formatting 2025-10-28 17:36:11 +02:00
Moyasee
120aad6c1c feat: Hide to tray on game startup and ability to disable trailers auto-play 2025-10-28 17:34:20 +02:00
Zamitto
1123aaa65e chore: remove zod dep 2025-10-28 06:48:42 -03:00
Zamitto
2f8d241ded Merge pull request #1829 from hydralauncher/chore/bump-electron-version
chore: bump electron version
2025-10-27 18:16:35 -03:00
Zamitto
61072aa02a fix: add theme editor dev tools back
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-27 15:25:18 -03:00
Zamitto
ddd6af0d4c fix: add theme editor dev tools back 2025-10-27 15:22:47 -03:00
Chubby Granny Chaser
fc6068d603 fix: fixing dlls 2025-10-27 12:33:29 +00:00
Chubby Granny Chaser
3ab1e29578 ci: testing windows 2025-10-27 10:47:19 +00:00
Chubby Granny Chaser
1effa80311 ci: testing windows 2025-10-27 10:35:20 +00:00
Chubby Granny Chaser
549e1270ee ci: testing windows 2025-10-27 09:56:45 +00:00
Chubby Granny Chaser
eb006c5e90 ci: testing windows 2019 2025-10-27 09:51:11 +00:00
Chubby Granny Chaser
54632bd06d ci: testing windows 2019 2025-10-27 09:16:55 +00:00
Chubby Granny Chaser
6921bfa3ff ci: testing windows 2019 2025-10-27 07:41:58 +00:00
Chubby Granny Chaser
2835207d79 fix: fixing download sources import status 2025-10-27 04:12:19 +00:00
Chubby Granny Chaser
2a90faeb42 fix: persisting is remote 2025-10-27 01:42:02 +00:00
Chubby Granny Chaser
87a57f7a37 feat: adding sources migration 2025-10-26 23:22:20 +00:00
Chubby Granny Chaser
7c272aeed8 Merge branch 'main' into feat/improving-sources 2025-10-26 21:42:58 +00:00
Moyasee
6565ce5316 fix: moved component out of parent component 2025-10-26 19:59:43 +02:00
Moyasee
fee3a4522a fix: duplications 2025-10-26 19:49:15 +02:00
Moyasee
cb3e52de34 fix: go to page button did not appear correctly for the last pages 2025-10-26 19:37:57 +02:00
Moyasee
7f2343413e feat: added manual page selection and changed functionality of pagination 2025-10-26 17:26:25 +02:00
Zamitto
ee35bc24b2 chore: undo remove hydra api logs
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-24 21:11:56 -03:00
Chubby Granny Chaser
73e378e26a Merge branch 'main' into refactor/remove-unnecessary-usememo 2025-10-21 21:11:37 +01:00
Chubby Granny Chaser
3782d74ad2 Merge branch 'feat/improving-sources' of github.com:hydralauncher/hydra into feat/improving-sources 2025-10-21 04:22:17 +01:00
Chubby Granny Chaser
8a40c678f7 feat: using api download sources 2025-10-21 04:21:56 +01:00
Chubby Granny Chaser
e1ce5bc6cb feat: using api download sources 2025-10-21 04:20:11 +01:00
Chubby Granny Chaser
48ce9a2476 feat: using api download sources 2025-10-21 04:18:11 +01:00
Chubby Granny Chaser
2909be312a Merge branch 'main' into feat/improving-sources 2025-10-21 03:59:49 +01:00
Chubby Granny Chaser
d2089ec7d4 Merge branch 'main' into feat/improving-sources 2025-10-18 14:08:31 +01:00
Chubby Granny Chaser
c2273dbf71 feat: moving sources to worker 2025-10-18 14:07:44 +01:00
whintersnow0
3dc71a8d1f refactor: remove unnecessary useMemo hooks 2025-10-15 19:19:08 +02:00
Kiwo.2
8203399eda Matching to Latest Update 2025-10-12 23:27:20 +02:00
87 changed files with 942 additions and 1499 deletions

37
.cursorrules Normal file
View File

@@ -0,0 +1,37 @@
# Hydra Project Rules
## Logging
- **Always use `logger` instead of `console` for logging** in both main and renderer processes
- In main process: `import { logger } from "@main/services";`
- In renderer process: `import { logger } from "@renderer/logger";`
- Replace all instances of:
- `console.log()` → `logger.log()`
- `console.error()` → `logger.error()`
- `console.warn()` → `logger.warn()`
- `console.info()` → `logger.info()`
- `console.debug()` → `logger.debug()`
- Do not use `console` for any logging purposes
## Internationalization (i18n)
- All user-facing strings must be translated using i18next
- Use the `useTranslation` hook in React components: `const { t } = useTranslation("namespace");`
- Add new translation keys to `src/locales/en/translation.json`
- Never hardcode English strings in the UI code
- Placeholder text in form fields must also be translated
## Code Style
- Use ESLint and Prettier for code formatting
- Follow TypeScript strict mode conventions
- Use async/await instead of promises when possible
- Prefer named exports over default exports for utilities and services
## Comments
- Keep comments concise and purposeful; avoid verbose explanations.
- Focus on the "why" or non-obvious context, not restating the code.
- Prefer self-explanatory naming and structure over excessive comments.
- Do not comment every line or obvious behavior; remove stale comments.
- Use docblocks only where they add value (public APIs, complex logic).

View File

@@ -3,3 +3,4 @@ MAIN_VITE_AUTH_URL=
MAIN_VITE_WS_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE=
MAIN_VITE_LAUNCHER_SUBDOMAIN=

View File

@@ -6,23 +6,37 @@ concurrency:
on:
push:
branches: [main]
branches:
- release/**
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
BRANCH_NAME: ${{ github.ref_name }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Node.js
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22.21.0
cache: "yarn"
- name: Enable Corepack (Yarn)
run: corepack enable
- name: Install dependencies
run: yarn --frozen-lockfile --ignore-scripts
run: yarn install --frozen-lockfile --ignore-scripts
- name: Build Renderer
run: yarn build
@@ -36,5 +50,5 @@ jobs:
run: |
npx --yes wrangler@3 pages deploy out/renderer \
--project-name="hydra" \
--commit-dirty=true \
--branch="main"
--branch "$BRANCH_NAME" \
--commit-dirty

View File

@@ -1,11 +1,12 @@
name: Build
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: pull_request
jobs:
build:
strategy:
@@ -38,6 +39,12 @@ jobs:
- name: Build with cx_Freeze
run: python python_rpc/setup.py build
- name: Copy OpenSSL DLLs
if: matrix.os == 'windows-2022'
run: |
cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |

View File

@@ -6,7 +6,8 @@ concurrency:
on:
push:
branches: [main]
branches:
- release/**
jobs:
build:
@@ -39,6 +40,12 @@ jobs:
- name: Build with cx_Freeze
run: python python_rpc/setup.py build
- name: Copy OpenSSL DLLs
if: matrix.os == 'windows-2022'
run: |
cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
@@ -55,7 +62,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 }}
MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }}
- name: Build Windows
if: matrix.os == 'windows-2022'
@@ -72,7 +79,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 }}
MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }}
- name: Create artifact
uses: actions/upload-artifact@v4

View File

@@ -98,6 +98,7 @@ jobs:
# Update pkgver in PKGBUILD
cd hydra-launcher-bin
NEW_VERSION="${{ steps.get-version.outputs.version }}"
NEW_VERSION="${NEW_VERSION#v}"
echo "Updating PKGBUILD pkgver to $NEW_VERSION"
@@ -137,6 +138,9 @@ jobs:
COMMIT_MSG="v${{ steps.get-version.outputs.version }}"
git commit -m "$COMMIT_MSG"
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -F ~/.ssh/config -o UserKnownHostsFile=$SSH_PATH/known_hosts"
git push origin master
echo "Successfully updated AUR package to version ${{ steps.get-version.outputs.version }}"
fi

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.1",
"version": "3.7.2",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -90,8 +90,7 @@
"winreg": "^1.2.5",
"ws": "^8.18.1",
"yaml": "^2.6.1",
"yup": "^1.5.0",
"zod": "^3.24.1"
"yup": "^1.5.0"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.705.0",

View File

@@ -428,6 +428,9 @@
"validate_download_source": "Validate",
"remove_download_source": "Remove",
"add_download_source": "Add source",
"adding": "Adding…",
"failed_add_download_source": "Failed to add download source. Please try again.",
"download_source_already_exists": "This download source URL already exists.",
"download_count_zero": "No download options",
"download_count_one": "{{countFormatted}} download option",
"download_count_other": "{{countFormatted}} download options",
@@ -435,9 +438,16 @@
"add_download_source_description": "Insert the URL of the .json file",
"download_source_up_to_date": "Up-to-date",
"download_source_errored": "Errored",
"download_source_pending_matching": "Updating soon",
"download_source_matched": "Up to date",
"download_source_matching": "Updating",
"download_source_failed": "Error",
"download_source_no_information": "No information available",
"sync_download_sources": "Sync sources",
"removed_download_source": "Download source removed",
"removed_download_sources": "Download sources removed",
"removed_all_download_sources": "All download sources removed",
"download_sources_synced_successfully": "All download sources are synced",
"cancel_button_confirmation_delete_all_sources": "No",
"confirm_button_confirmation_delete_all_sources": "Yes, delete everything",
"title_confirmation_delete_all_sources": "Delete all download sources",
@@ -468,6 +478,7 @@
"seed_after_download_complete": "Seed after download complete",
"show_hidden_achievement_description": "Show hidden achievements description before unlocking them",
"account": "Account",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "You have no blocked users",
"subscription_active_until": "Your Hydra Cloud is active until {{date}}",
"manage_subscription": "Manage subscription",
@@ -541,7 +552,9 @@
"hidden": "Hidden",
"test_notification": "Test notification",
"notification_preview": "Achievement Notification Preview",
"enable_friend_start_game_notifications": "When a friend starts playing a game"
"enable_friend_start_game_notifications": "When a friend starts playing a game",
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup"
},
"notifications": {
"download_complete": "Download complete",

View File

@@ -541,7 +541,9 @@
"notification_preview": "Probar notificación de logro",
"debrid": "Debrid",
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego"
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
},
"notifications": {
"download_complete": "Descarga completada",

View File

@@ -8,7 +8,7 @@
"no_results": "Nincs találat",
"start_typing": "Kereséshez gépelj...",
"hot": "Most felkapott",
"weekly": "📅 A hét felkapott játékai",
"weekly": "📅 A hét felkapottjai",
"achievements": "🏆 Achievement támogatott"
},
"sidebar": {
@@ -26,7 +26,7 @@
"sign_in": "Bejelentkezés",
"friends": "Barátok",
"need_help": "Elakadtál?",
"favorites": "Kedvenc játékok",
"favorites": "Kedvenc Játékaim",
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
"add_custom_game_tooltip": "Saját játék hozzáadása",
"show_playable_only_tooltip": "Csak játszható játék mutatása",
@@ -224,7 +224,7 @@
"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...",
"write_review_placeholder": "Oszd meg gondolatod 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!",
@@ -252,7 +252,7 @@
"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",
"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",
@@ -356,13 +356,18 @@
"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"
"delete_review_modal_cancel_button": "Mégse",
"vote_failed": "A szavazatod nem regisztrálódott. Kérlek próbáld újra.",
"show_original": "Eredeti megjelenítése",
"show_translation": "Fordítás megjelenítése",
"show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})",
"hide_original": "Eredeti elrejtése"
},
"activation": {
"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 hol kérdezz efelől, akkor nem kéne ilyened legyen.",
"message": "Ha nem tudod merre kérdezz efelől, akkor nem kéne ilyened legyen.",
"activate": "Aktiválás",
"loading": "Töltés…"
},
@@ -386,7 +391,7 @@
"download_in_progress": "Folyamatban lévő",
"queued_downloads": "Várakozósoron lévő letöltések",
"downloads_completed": "Befejezett",
"queued": "Várakozási sorban",
"queued": "Várakozásban",
"no_downloads_title": "Oly üres..",
"no_downloads_description": "Még nem töltöttél le semmit a Hydra segítségével, de soha nem késő elkezdeni.",
"checking_files": "Fájlok ellenőrzése…",
@@ -419,20 +424,30 @@
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése",
"changes_saved": "Változtatások sikeresen mentve",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"validate_download_source": "Érvényesítés",
"remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása",
"adding": "Hozzáadás…",
"failed_add_download_source": "Letöltési forrás hozzáadása sikertelen. Kérlek próbáld újra.",
"download_source_already_exists": "Ez a letöltési forrás URL már létezik.",
"download_count_zero": "Nincs letöltési opció",
"download_count_one": "{{countFormatted}} letöltési opció",
"download_count_other": "{{countFormatted}} letöltési opció",
"download_source_url": "URL forrás:",
"download_source_url": "URL Forrás:",
"add_download_source_description": "Helyezd be a .json fájl URL-jét",
"download_source_up_to_date": "Naprakész",
"download_source_errored": "Hiba történt",
"download_source_pending_matching": "Frissítés hamarosan",
"download_source_matched": "Naprakész",
"download_source_matching": "Frissítés..",
"download_source_failed": "Hiba",
"download_source_no_information": "Nincs elérhető információ",
"sync_download_sources": "Források szinkronizálása",
"removed_download_source": "Letöltési forrás eltávolítva",
"removed_download_sources": "Letöltési források eltávolítva",
"removed_all_download_sources": "Összes letöltési forrás eltávolítva",
"download_sources_synced_successfully": "Az összes letöltési forrás szinkronizálva",
"cancel_button_confirmation_delete_all_sources": "Nem",
"confirm_button_confirmation_delete_all_sources": "Igen, törölj mindent",
"title_confirmation_delete_all_sources": "Az összes letöltési forrás törlése",
@@ -445,6 +460,7 @@
"found_download_option_one": "{{countFormatted}} Letöltési opció találva",
"found_download_option_other": "{{countFormatted}} Letöltési opciók találva",
"import": "Importálás",
"importing": "Importálás...",
"public": "Publikus",
"private": "Privát",
"friends_only": "Csak barátok",
@@ -462,6 +478,7 @@
"seed_after_download_complete": "Letöltés utáni seedelés",
"show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt",
"account": "Fiók",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "Nincsenek letiltott felhasználóid",
"subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}",
"manage_subscription": "Előfizetés kezelése",
@@ -498,14 +515,14 @@
"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.",
"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, és csak az internet sebességed szab határt.",
"enable_torbox": "TorBox bekapcsolása",
"torbox_description": "A TorBox egy olyan premium seedbox szolgáltatás, amely még a piacon elérhető legjobb szerverekkel is felveszi a versenyt.",
"torbox_account_linked": "TorBox fiók összekapcsolva",
"create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod",
"create_torbox_account": "Kattints ide ha még nincs TorBox fiókod",
"real_debrid_account_linked": "Real-Debrid fiók összekapcsolva",
"name_min_length": "A téma neve legalább 3 karakter hosszú legyen",
"name_min_length": "A téma neve legalább 3 karakter hosszú kell legyen",
"import_theme": "Téma importálása",
"import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}",
"error_importing_theme": "Hiba lépett fel a téma importálása közben",
@@ -535,7 +552,9 @@
"hidden": "Rejtett",
"test_notification": "Értesítés tesztelése",
"notification_preview": "Achievement Értesítés Előnézete",
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot"
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot",
"autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán",
"hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára"
},
"notifications": {
"download_complete": "Letöltés befejezve",
@@ -563,10 +582,10 @@
"available_one": "Elérhető",
"available_other": "Elérhető",
"no_downloads": "Nincs elérhető letöltés",
"calculating": "Feldolgozás"
"calculating": "Számítás alatt.."
},
"binary_not_found_modal": {
"title": "A programok nincsenek telepítve",
"title": "Hiányzó programok",
"description": "Wine vagy Lutris futtatható fájlok nem találhatók a rendszereden",
"instructions": "Ellenőrízd hogy melyiket kell helyesen telepíteni a Linux disztribúciódra, hogy a játék megfelelően fusson"
},
@@ -585,6 +604,7 @@
"activity": "Legutóbbi tevékenység",
"library": "Könyvtár",
"pinned": "Kitűzve",
"sort_by": "Rendezés:",
"achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva",
"playtime": "Játszottidő",
@@ -654,7 +674,7 @@
"uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve",
"stats": "Statisztikák",
"achievements": "achievementek",
"achievements": "achievement",
"games": "Játékok",
"top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "A rangsor hetente frissül.",
@@ -669,7 +689,7 @@
"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"
"karma_description": "Pozitív értékelésekkel szerzett pontok"
},
"achievement": {
"achievement_unlocked": "Achievement feloldva",
@@ -678,7 +698,7 @@
"unlocked_at": "Feloldva: {{date}}",
"subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges",
"new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievement",
"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",

View File

@@ -416,6 +416,9 @@
"validate_download_source": "Validar",
"remove_download_source": "Remover",
"add_download_source": "Adicionar fonte",
"adding": "Adicionando…",
"failed_add_download_source": "Falha ao adicionar fonte de download. Tente novamente.",
"download_source_already_exists": "Esta URL de fonte de download já existe.",
"download_count_zero": "Sem downloads na lista",
"download_count_one": "{{countFormatted}} download na lista",
"download_count_other": "{{countFormatted}} downloads na lista",
@@ -423,7 +426,13 @@
"add_download_source_description": "Insira a URL contendo o arquivo .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou",
"download_source_pending_matching": "Importando em breve",
"download_source_matched": "Sincronizada",
"download_source_matching": "Sincronizando",
"download_source_failed": "Erro",
"download_source_no_information": "Sem informações",
"sync_download_sources": "Sincronizar",
"download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida",
"removed_download_sources": "Fontes removidas",
"cancel_button_confirmation_delete_all_sources": "Não",
@@ -529,7 +538,9 @@
"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"
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo"
},
"notifications": {
"download_complete": "Download concluído",

View File

@@ -252,7 +252,13 @@
"add_download_source_description": "Insere o URL que contém o ficheiro .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou",
"download_source_pending_matching": "A atualizar em breve",
"download_source_matched": "Atualizado",
"download_source_matching": "A atualizar",
"download_source_failed": "Erro",
"download_source_no_information": "Sem informações",
"sync_download_sources": "Sincronizar",
"download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida",
"cancel_button_confirmation_delete_all_sources": "Não",
"confirm_button_confirmation_delete_all_sources": "Sim, apague tudo",

View File

@@ -212,6 +212,7 @@
"stats": "Статистика",
"download_count": "Загрузки",
"player_count": "Активные игроки",
"rating_count": "Оценка",
"download_error": "Этот вариант загрузки недоступен",
"download": "Скачать",
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
@@ -252,17 +253,6 @@
"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": "Отмена",
"show_original": "Показать оригинал",
"show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал",
"cloud_save": "Облачное сохранение",
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
"backups": "Резервные копии",
@@ -360,7 +350,18 @@
"caption": "Субтитры",
"audio": "Аудио",
"filter_by_source": "Фильтр по источнику",
"no_repacks_found": "Источники для этой игры не найдены"
"no_repacks_found": "Источники для этой игры не найдены",
"delete_review": "Удалить отзыв",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"vote_failed": "Не удалось зарегистрировать ваш голос. Пожалуйста, попробуйте снова.",
"show_original": "Показать оригинал",
"show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал"
},
"activation": {
"title": "Активировать Hydra",
@@ -427,6 +428,9 @@
"validate_download_source": "Проверить",
"remove_download_source": "Удалить",
"add_download_source": "Добавить источник",
"adding": "Добавление…",
"failed_add_download_source": "Не удалось добавить источник. Пожалуйста, попробуйте снова.",
"download_source_already_exists": "Этот URL источника уже существует.",
"download_count_zero": "В списке нет загрузок",
"download_count_one": "{{countFormatted}} загрузка в списке",
"download_count_other": "{{countFormatted}} загрузок в списке",
@@ -434,9 +438,16 @@
"add_download_source_description": "Вставьте ссылку на .json-файл",
"download_source_up_to_date": "Обновлён",
"download_source_errored": "Ошибка",
"download_source_pending_matching": "Скоро обновится",
"download_source_matched": "Обновлен",
"download_source_matching": "Обновление",
"download_source_failed": "Ошибка",
"download_source_no_information": "Информация отсутствует",
"sync_download_sources": "Обновить источники",
"removed_download_source": "Источник удален",
"removed_download_sources": "Источники удалены",
"removed_all_download_sources": "Все источники удалены",
"download_sources_synced_successfully": "Все источники синхронизированы",
"cancel_button_confirmation_delete_all_sources": "Нет",
"confirm_button_confirmation_delete_all_sources": "Да, удалить все",
"title_confirmation_delete_all_sources": "Удалить все источники",
@@ -467,6 +478,7 @@
"seed_after_download_complete": "Раздавать после завершения загрузки",
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением",
"account": "Аккаунт",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "У вас нет заблокированных пользователей",
"subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}",
"manage_subscription": "Управлять подпиской",
@@ -540,7 +552,9 @@
"hidden": "Скрытый",
"test_notification": "Тестовое уведомление",
"notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
"hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры"
},
"notifications": {
"download_complete": "Загрузка завершена",
@@ -590,6 +604,7 @@
"activity": "Недавняя активность",
"library": "Библиотека",
"pinned": "Закрепленные",
"sort_by": "Сортировать по:",
"achievements_earned": "Заработанные достижения",
"played_recently": "Недавно сыгранные",
"playtime": "Время игры",

View File

@@ -6,6 +6,10 @@ import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours
export const getGameAssets = async (objectId: string, shop: GameShop) => {
if (shop === "custom") {
return null;
}
const cachedAssets = await gamesShopAssetsSublevel.get(
levelKeys.game(shop, objectId)
);

View File

@@ -26,6 +26,8 @@ const getGameShopDetails = async (
shop: GameShop,
language: string
): Promise<ShopDetailsWithAssets | null> => {
if (shop === "custom") return null;
if (shop === "steam") {
const [cachedData, cachedAssets] = await Promise.all([
gamesShopCacheSublevel.get(

View File

@@ -10,6 +10,10 @@ const getGameStats = async (
objectId: string,
shop: GameShop
) => {
if (shop === "custom") {
return null;
}
const cachedStats = await gamesStatsCacheSublevel.get(
levelKeys.game(shop, objectId)
);

View File

@@ -1,76 +1,50 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { HydraApi, logger } from "@main/services";
import { importDownloadSourceToLocal } from "./helpers";
import { HydraApi } from "@main/services/hydra-api";
import { downloadSourcesSublevel } from "@main/level";
import type { DownloadSource } from "@types";
import { logger } from "@main/services";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const result = await importDownloadSourceToLocal(url, true);
if (!result) {
throw new Error("Failed to import download source");
}
try {
const existingSources = await downloadSourcesSublevel.values().all();
const urlExists = existingSources.some((source) => source.url === url);
// 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++;
if (urlExists) {
throw new Error("Download source with this URL already exists");
}
}
await HydraApi.post("/profile/download-sources", {
urls: [url],
});
const downloadSource = await HydraApi.post<DownloadSource>(
"/download-sources",
{
url,
},
{ needsAuth: false }
);
const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds: result.objectIds,
},
{ needsAuth: false }
);
if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) {
try {
await HydraApi.post("/profile/download-sources", {
urls: [url],
});
} catch (error) {
logger.error("Failed to add download source to profile:", error);
}
}
// Update the source with fingerprint
const updatedSource = await downloadSourcesSublevel.get(`${result.id}`);
if (updatedSource) {
await downloadSourcesSublevel.put(`${result.id}`, {
...updatedSource,
fingerprint,
updatedAt: new Date(),
await downloadSourcesSublevel.put(downloadSource.id, {
...downloadSource,
isRemote: true,
createdAt: new Date().toISOString(),
});
}
// 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");
return downloadSource;
} catch (error) {
logger.error("Failed to add download source:", error);
throw error;
}
// 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

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

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

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

@@ -1,8 +1,10 @@
import { HydraApi } from "@main/services";
import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
import { orderBy } from "lodash-es";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get("/profile/download-sources");
const allSources = await downloadSourcesSublevel.values().all();
return orderBy(allSources, "createdAt", "desc");
};
registerEvent("getDownloadSources", getDownloadSources);

View File

@@ -1,367 +0,0 @@
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,18 +1,27 @@
import { HydraApi } from "@main/services";
import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url?: string,
removeAll = false
removeAll = false,
downloadSourceId?: string
) => {
const params = new URLSearchParams({
all: removeAll.toString(),
});
if (url) params.set("url", url);
if (downloadSourceId) params.set("downloadSourceId", downloadSourceId);
return HydraApi.delete(`/profile/download-sources?${params.toString()}`);
if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) {
void HydraApi.delete(`/profile/download-sources?${params.toString()}`);
}
if (removeAll) {
await downloadSourcesSublevel.clear();
} else if (downloadSourceId) {
await downloadSourcesSublevel.del(downloadSourceId);
}
};
registerEvent("removeDownloadSource", removeDownloadSource);

View File

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

@@ -1,114 +1,28 @@
import { HydraApi } from "@main/services";
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";
import { downloadSourcesSublevel } from "@main/level";
import type { DownloadSource } from "@types";
const syncDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
): Promise<number> => {
let newRepacksCount = 0;
const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
const downloadSources = await downloadSourcesSublevel.values().all();
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 response = await HydraApi.post<DownloadSource[]>(
"/download-sources/sync",
{
ids: downloadSources.map((downloadSource) => downloadSource.id),
},
{ needsAuth: false }
);
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 (const downloadSource of response) {
const existingDownloadSource = downloadSources.find(
(source) => source.id === downloadSource.id
);
// 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;
await downloadSourcesSublevel.put(downloadSource.id, {
...existingDownloadSource,
...downloadSource,
});
}
};

View File

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

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

@@ -63,14 +63,7 @@ import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-torbox";
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";

View File

@@ -37,6 +37,7 @@ const addCustomGameToLibrary = async (
logoImageUrl: logoImageUrl || "",
logoPosition: null,
coverImageUrl: iconUrl || "",
downloadSources: [],
};
await gamesShopAssetsSublevel.put(gameKey, assets);

View File

@@ -13,7 +13,9 @@ const addGameToFavorites = async (
const game = await gamesSublevel.get(gameKey);
if (!game) return;
HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {});
if (shop !== "custom") {
HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {});
}
try {
await gamesSublevel.put(gameKey, {

View File

@@ -13,7 +13,11 @@ const removeGameFromFavorites = async (
const game = await gamesSublevel.get(gameKey);
if (!game) return;
HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch(() => {});
if (shop !== "custom") {
HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch(
() => {}
);
}
try {
await gamesSublevel.put(gameKey, {

View File

@@ -84,7 +84,7 @@ const removeGameFromLibrary = async (
await resetShopAssets(gameKey);
}
if (game?.remoteId) {
if (game.remoteId) {
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
}

View File

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

@@ -0,0 +1,27 @@
import { downloadSourcesSublevel } from "@main/level";
import { HydraApi } from "@main/services/hydra-api";
import { DownloadSource } from "@types";
export const migrateDownloadSources = async () => {
const downloadSources = downloadSourcesSublevel.iterator();
for await (const [key, value] of downloadSources) {
if (!value.isRemote) {
const downloadSource = await HydraApi.post<DownloadSource>(
"/download-sources",
{
url: value.url,
},
{ needsAuth: false }
);
await downloadSourcesSublevel.put(downloadSource.id, {
...downloadSource,
isRemote: true,
createdAt: new Date().toISOString(),
});
await downloadSourcesSublevel.del(key);
}
}
};

View File

@@ -1,18 +1,6 @@
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;
}
import type { DownloadSource } from "@types";
export const downloadSourcesSublevel = db.sublevel<string, DownloadSource>(
levelKeys.downloadSources,

View File

@@ -7,4 +7,3 @@ export * from "./game-achievements";
export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./repacks";

View File

@@ -18,5 +18,4 @@ export const levelKeys = {
screenState: "screenState",
rpcPassword: "rpcPassword",
downloadSources: "downloadSources",
repacks: "repacks",
};

View File

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

@@ -16,15 +16,12 @@ import {
Ludusavi,
Lock,
DeckyPlugin,
ResourceCache,
} from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
export const loadState = async () => {
await Lock.acquireLock();
ResourceCache.initialize();
await ResourceCache.updateResourcesOnStartup();
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
@@ -53,8 +50,12 @@ export const loadState = async () => {
DeckyPlugin.checkAndUpdateIfOutdated();
}
await HydraApi.setupApi().then(() => {
await HydraApi.setupApi().then(async () => {
uploadGamesBatch();
void migrateDownloadSources();
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
// WSClient.connect();
});

View File

@@ -27,6 +27,10 @@ export const getGameAchievementData = async (
shop: GameShop,
useCachedData: boolean
) => {
if (shop === "custom") {
return [];
}
const gameKey = levelKeys.game(shop, objectId);
const cachedAchievements = await gameAchievementsSublevel.get(gameKey);

View File

@@ -29,7 +29,7 @@ export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = false;
private static readonly ADD_LOG_INTERCEPTOR = true;
private static secondsToMilliseconds(seconds: number) {
return seconds * 1000;
@@ -46,7 +46,7 @@ export class HydraApi {
return this.userAuth.authToken !== "";
}
private static hasActiveSubscription() {
public static hasActiveSubscription() {
const expiresAt = new Date(this.userAuth.subscription?.expiresAt ?? 0);
return expiresAt > new Date();
}
@@ -106,9 +106,7 @@ export class HydraApi {
// WSClient.close();
// WSClient.connect();
const { syncDownloadSourcesFromApi } = await import(
"../events/download-sources/sync-download-sources-from-api"
);
const { syncDownloadSourcesFromApi } = await import("./user");
syncDownloadSourcesFromApi();
}
}

View File

@@ -18,4 +18,4 @@ export * from "./library-sync";
export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";
export * from "./resource-cache";
export * from "./user";

View File

@@ -72,6 +72,7 @@ export const mergeWithRemoteGames = async () => {
logoImageUrl: game.logoImageUrl,
iconUrl: game.iconUrl,
logoPosition: game.logoPosition,
downloadSources: game.downloadSources,
});
}
})

View File

@@ -1,10 +1,10 @@
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
import type { Game, GameRunning } from "@types";
import type { Game, GameRunning, UserPreferences } from "@types";
import { PythonRPC } from "./python-rpc";
import axios from "axios";
import { ProcessPayload } from "./download/types";
import { gamesSublevel, levelKeys } from "@main/level";
import { db, gamesSublevel, levelKeys } from "@main/level";
import { CloudSync } from "./cloud-sync";
import { logger } from "./logger";
import path from "path";
@@ -209,6 +209,17 @@ function onOpenGame(game: Game) {
lastSyncTick: now,
});
// Hide Hydra to tray on game startup if enabled
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",
})
.then((userPreferences) => {
if (userPreferences?.hideToTrayOnGameStart) {
WindowManager.mainWindow?.hide();
}
})
.catch(() => {});
if (game.remoteId) {
updateGamePlaytime(
game,

View File

@@ -1,157 +0,0 @@
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: `${import.meta.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

@@ -0,0 +1,2 @@
export * from "./get-user-data";
export * from "./sync-download-sources";

View File

@@ -0,0 +1,41 @@
import { HydraApi, logger } from "../";
import { downloadSourcesSublevel } from "@main/level";
import type { DownloadSource } from "@types";
export const syncDownloadSourcesFromApi = async () => {
if (!HydraApi.isLoggedIn() || !HydraApi.hasActiveSubscription()) {
return;
}
try {
const profileSources = await HydraApi.get<DownloadSource[]>(
"/profile/download-sources"
);
const existingSources = await downloadSourcesSublevel.values().all();
const existingUrls = new Set(existingSources.map((source) => source.url));
for (const downloadSource of profileSources) {
if (!existingUrls.has(downloadSource.url)) {
try {
await downloadSourcesSublevel.put(downloadSource.id, {
...downloadSource,
isRemote: true,
createdAt: new Date().toISOString(),
});
logger.log(
`Synced download source from profile: ${downloadSource.url}`
);
} catch (error) {
logger.error(
`Failed to sync download source ${downloadSource.url}:`,
error
);
}
}
}
} catch (error) {
logger.error("Failed to sync download sources from API:", error);
}
};

View File

@@ -25,6 +25,7 @@ import type {
} from "@types";
import { AuthPage, generateAchievementCustomNotificationTest } from "@shared";
import { isStaging } from "@main/constants";
import { logger } from "./logger";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
@@ -54,21 +55,25 @@ export class WindowManager {
show: false,
};
private static formatVersionNumber(version: string) {
return version.replaceAll(".", "-");
}
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"]) {
window.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`);
} else if (import.meta.env.MAIN_VITE_RENDERER_URL) {
} else if (import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN) {
// Try to load from remote URL in production
try {
await window.loadURL(
`${import.meta.env.MAIN_VITE_RENDERER_URL}#/${hash}`
`https://release-v${this.formatVersionNumber(app.getVersion())}.${import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN}#/${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:",
logger.error(
"Failed to load from MAIN_VITE_LAUNCHER_SUBDOMAIN, falling back to local file:",
error
);
window.loadFile(path.join(__dirname, "../renderer/index.html"), {
@@ -462,6 +467,7 @@ export class WindowManager {
editorWindow.once("ready-to-show", () => {
editorWindow.show();
this.mainWindow?.webContents.openDevTools();
if (!app.isPackaged || isStaging) {
editorWindow.webContents.openDevTools();
}
@@ -469,11 +475,12 @@ export class WindowManager {
editorWindow.webContents.on("before-input-event", (_event, input) => {
if (input.key === "F12") {
editorWindow.webContents.toggleDevTools();
this.mainWindow?.webContents.toggleDevTools();
}
});
editorWindow.on("close", () => {
this.mainWindow?.webContents.closeDevTools();
this.editorWindows.delete(themeId);
});
}

View File

@@ -7,7 +7,7 @@ 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 MAIN_VITE_LAUNCHER_SUBDOMAIN: string;
readonly ELECTRON_RENDERER_URL: string;
}

View File

@@ -99,22 +99,10 @@ contextBridge.exposeInMainWorld("electron", {
/* Download sources */
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: (

View File

@@ -7,7 +7,6 @@ import {
useAppSelector,
useDownload,
useLibrary,
useRepacks,
useToast,
useUserDetails,
} from "@renderer/hooks";
@@ -20,7 +19,6 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
setIsImportingSources,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
@@ -40,8 +38,6 @@ export function App() {
const { t } = useTranslation("app");
const { updateRepacks } = useRepacks();
const { clearDownload, setLastPacket } = useDownload();
const {
@@ -199,36 +195,6 @@ export function App() {
});
}, [dispatch, draggingDisabled]);
useEffect(() => {
(async () => {
dispatch(setIsImportingSources(true));
try {
// Initial repacks load
await updateRepacks();
// Sync all local sources (check for updates)
const newRepacksCount = await window.electron.syncDownloadSources();
if (newRepacksCount > 0) {
window.electron.publishNewRepacksNotification(newRepacksCount);
}
// Update fingerprints for sources that don't have them
await window.electron.updateMissingFingerprints();
// 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();
if (activeTheme?.code) {

View File

@@ -1,5 +1,5 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import type { GameStats } from "@types";
import type { GameStats, ShopAssets } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@@ -8,15 +8,15 @@ import "./game-card.scss";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { StarRating } from "../star-rating/star-rating";
import { useCallback, useState, useMemo } from "react";
import { useFormat, useRepacks } from "@renderer/hooks";
import { useCallback, useState } from "react";
import { useFormat } from "@renderer/hooks";
export interface GameCardProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
game: any;
game: ShopAssets;
}
const shopIcon = {
@@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const [stats, setStats] = useState<GameStats | null>(null);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const uniqueRepackers = Array.from(
new Set(repacks.map((repack) => repack.repacker))
);
const handleHover = useCallback(() => {
if (!stats) {
window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
@@ -45,15 +38,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { numberFormatter } = useFormat();
const firstThreeRepackers = useMemo(
() => uniqueRepackers.slice(0, 3),
[uniqueRepackers]
);
const remainingCount = useMemo(
() => uniqueRepackers.length - 3,
[uniqueRepackers]
);
return (
<button
{...props}
@@ -75,18 +59,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
<p className="game-card__title">{game.title}</p>
</div>
{uniqueRepackers.length > 0 ? (
{game.downloadSources.length > 0 ? (
<ul className="game-card__download-options">
{firstThreeRepackers.map((repacker) => (
<li key={repacker}>
<Badge>{repacker}</Badge>
{game.downloadSources.slice(0, 3).map((sourceName) => (
<li key={sourceName}>
<Badge>{sourceName}</Badge>
</li>
))}
{remainingCount > 0 && (
{game.downloadSources.length > 3 && (
<li>
<Badge>
+{remainingCount}{" "}
{t("game_card:available", { count: remainingCount })}
+{game.downloadSources.length - 3}{" "}
{t("game_card:available", {
count: game.downloadSources.length - 3,
})}
</Badge>
</li>
)}

View File

@@ -1,9 +1,7 @@
import React, { useId, useMemo, useState } from "react";
import React, { useId, useState } from "react";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import cn from "classnames";
import "./text-field.scss";
export interface TextFieldProps
@@ -42,44 +40,30 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
) => {
const id = useId();
const [isFocused, setIsFocused] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const { t } = useTranslation("forms");
const showPasswordToggleButton = props.type === "password";
const inputType = useMemo(() => {
if (props.type === "password" && isPasswordVisible) return "text";
return props.type ?? "text";
}, [props.type, isPasswordVisible]);
const hintContent = useMemo(() => {
if (error)
return (
<small className="text-field-container__error-label">{error}</small>
);
if (hint) return <small>{hint}</small>;
return null;
}, [hint, error]);
const inputType =
props.type === "password" && isPasswordVisible
? "text"
: (props.type ?? "text");
const hintContent = error ? (
<small className="text-field-container__error-label">{error}</small>
) : hint ? (
<small>{hint}</small>
) : null;
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(true);
if (props.onFocus) props.onFocus(event);
props.onFocus?.(event);
};
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(false);
if (props.onBlur) props.onBlur(event);
props.onBlur?.(event);
};
const hasError = !!error;
return (
<div className="text-field-container" {...containerProps}>
{label && <label htmlFor={id}>{label}</label>}
<div className="text-field-container__text-field-wrapper">
<div
className={cn(
@@ -104,7 +88,6 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
onBlur={handleBlur}
type={inputType}
/>
{showPasswordToggleButton && (
<button
type="button"
@@ -120,14 +103,11 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
</button>
)}
</div>
{rightContent}
</div>
{hintContent}
</div>
);
}
);
TextField.displayName = "TextField";

View File

@@ -98,6 +98,11 @@ export function CloudSyncContextProvider({
);
const getGameArtifacts = useCallback(async () => {
if (shop === "custom") {
setArtifacts([]);
return;
}
const params = new URLSearchParams({
objectId,
shop,

View File

@@ -1,11 +1,4 @@
import {
createContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createContext, useCallback, useEffect, useRef, useState } from "react";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers";
@@ -13,11 +6,11 @@ import {
useAppDispatch,
useAppSelector,
useDownload,
useRepacks,
useUserDetails,
} from "@renderer/hooks";
import type {
GameRepack,
GameShop,
GameStats,
LibraryGame,
@@ -84,12 +77,7 @@ export function GameDetailsContextProvider({
const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const { getRepacksForObjectId } = useRepacks();
const repacks = useMemo(() => {
return getRepacksForObjectId(objectId);
}, [getRepacksForObjectId, objectId]);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const { i18n } = useTranslation("game_details");
const location = useLocation();
@@ -142,10 +130,12 @@ export function GameDetailsContextProvider({
}
});
window.electron.getGameStats(objectId, shop).then((result) => {
if (abortController.signal.aborted) return;
setStats(result);
});
if (shop !== "custom") {
window.electron.getGameStats(objectId, shop).then((result) => {
if (abortController.signal.aborted) return;
setStats(result);
});
}
const assetsPromise = window.electron.getGameAssets(objectId, shop);
@@ -167,7 +157,7 @@ export function GameDetailsContextProvider({
setIsLoading(false);
});
if (userDetails) {
if (userDetails && shop !== "custom") {
window.electron
.getUnlockedAchievements(objectId, shop)
.then((achievements) => {
@@ -287,19 +277,6 @@ export function GameDetailsContextProvider({
}
}, [location]);
const lastDownloadedOption = useMemo(() => {
if (game?.download) {
const repack = repacks.find((repack) =>
repack.uris.some((uri) => uri.includes(game.download!.uri))
);
if (!repack) return null;
return repack;
}
return null;
}, [game?.download, repacks]);
useEffect(() => {
const unsubscribe = window.electron.onUpdateAchievements(
objectId,
@@ -315,6 +292,34 @@ export function GameDetailsContextProvider({
};
}, [objectId, shop, userDetails]);
useEffect(() => {
const fetchDownloadSources = async () => {
try {
const sources = await window.electron.getDownloadSources();
const params = {
take: 100,
skip: 0,
downloadSourceIds: sources.map((source) => source.id),
};
const downloads = await window.electron.hydraApi.get<GameRepack[]>(
`/games/${shop}/${objectId}/download-sources`,
{
params,
needsAuth: false,
}
);
setRepacks(downloads);
} catch (error) {
console.error("Failed to fetch download sources:", error);
}
};
fetchDownloadSources();
}, [shop, objectId]);
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
@@ -359,7 +364,7 @@ export function GameDetailsContextProvider({
stats,
achievements,
hasNSFWContentBlocked,
lastDownloadedOption,
lastDownloadedOption: null,
setHasNSFWContentBlocked,
selectGameExecutable,
updateGame,

View File

@@ -31,8 +31,6 @@ import type {
Game,
DiskUsage,
DownloadSource,
DownloadSourceValidationResult,
GameRepack,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -210,20 +208,12 @@ declare global {
/* Download sources */
addDownloadSource: (url: string) => Promise<DownloadSource>;
updateMissingFingerprints: () => Promise<number>;
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
getDownloadSources: () => Promise<
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[]
>;
deleteDownloadSource: (id: number) => Promise<void>;
deleteAllDownloadSources: () => Promise<void>;
validateDownloadSource: (
url: string
) => Promise<DownloadSourceValidationResult>;
syncDownloadSources: () => Promise<number>;
getDownloadSourcesList: () => Promise<DownloadSource[]>;
checkDownloadSourceExists: (url: string) => Promise<boolean>;
getAllRepacks: () => Promise<GameRepack[]>;
removeDownloadSource: (
removeAll = false,
downloadSourceId?: string
) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;

View File

@@ -1,21 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
export interface DownloadSourcesState {
isImporting: boolean;
}
const initialState: DownloadSourcesState = {
isImporting: false,
};
export const downloadSourcesSlice = createSlice({
name: "downloadSources",
initialState,
reducers: {
setIsImportingSources: (state, action) => {
state.isImporting = action.payload;
},
},
});
export const { setIsImportingSources } = downloadSourcesSlice.actions;

View File

@@ -6,6 +6,4 @@ export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./game-running.slice";
export * from "./subscription-slice";
export * from "./repacks-slice";
export * from "./download-sources-slice";
export * from "./catalogue-search";

View File

@@ -5,5 +5,4 @@ export * from "./use-toast";
export * from "./redux";
export * from "./use-user-details";
export * from "./use-format";
export * from "./use-repacks";
export * from "./use-feature";

View File

@@ -2,6 +2,7 @@ import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { useAppDispatch } from "./redux";
import { setGenres, setTags } from "@renderer/features";
import type { DownloadSource } from "@types";
export const externalResourcesInstance = axios.create({
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
@@ -12,6 +13,7 @@ export function useCatalogue() {
const [steamPublishers, setSteamPublishers] = useState<string[]>([]);
const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const getSteamUserTags = useCallback(() => {
externalResourcesInstance.get("/steam-user-tags.json").then((response) => {
@@ -37,17 +39,25 @@ export function useCatalogue() {
});
}, []);
const getDownloadSources = useCallback(() => {
window.electron.getDownloadSources().then((results) => {
setDownloadSources(results.filter((source) => !!source.fingerprint));
});
}, []);
useEffect(() => {
getSteamUserTags();
getSteamGenres();
getSteamPublishers();
getSteamDevelopers();
getDownloadSources();
}, [
getSteamUserTags,
getSteamGenres,
getSteamPublishers,
getSteamDevelopers,
getDownloadSources,
]);
return { steamPublishers, steamDevelopers };
return { steamPublishers, downloadSources, steamDevelopers };
}

View File

@@ -1,26 +0,0 @@
import { setRepacks } from "@renderer/features";
import { useCallback } from "react";
import { RootState } from "@renderer/store";
import { useSelector } from "react-redux";
import { useAppDispatch } from "./redux";
export function useRepacks() {
const dispatch = useAppDispatch();
const repacks = useSelector((state: RootState) => state.repacks.value);
const getRepacksForObjectId = useCallback(
(objectId: string) => {
return repacks.filter((repack) => repack.objectIds.includes(objectId));
},
[repacks]
);
const updateRepacks = useCallback(async () => {
const repacks = await window.electron.getAllRepacks();
dispatch(
setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
);
}, [dispatch]);
return { getRepacksForObjectId, updateRepacks };
}

View File

@@ -1,4 +1,8 @@
import type { CatalogueSearchResult, DownloadSource } from "@types";
import type {
CatalogueSearchResult,
CatalogueSearchPayload,
DownloadSource,
} from "@types";
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -29,13 +33,12 @@ export default function Catalogue() {
const abortControllerRef = useRef<AbortController | null>(null);
const cataloguePageRef = useRef<HTMLDivElement>(null);
const { steamDevelopers, steamPublishers } = useCatalogue();
const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
const { steamGenres, steamUserTags } = useAppSelector(
(state) => state.catalogueSearch
);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState<CatalogueSearchResult[]>([]);
@@ -51,24 +54,41 @@ export default function Catalogue() {
const { t, i18n } = useTranslation("catalogue");
const debouncedSearch = useRef(
debounce(async (filters, pageSize, offset) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
debounce(
async (
filters: CatalogueSearchPayload,
downloadSources: DownloadSource[],
pageSize: number,
offset: number
) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
const response = await window.electron.hydraApi.post<{
edges: CatalogueSearchResult[];
count: number;
}>("/catalogue/search", {
data: { ...filters, take: pageSize, skip: offset },
needsAuth: false,
});
const requestData = {
...filters,
take: pageSize,
skip: offset,
downloadSourceIds: downloadSources.map(
(downloadSource) => downloadSource.id
),
};
if (abortController.signal.aborted) return;
const response = await window.electron.hydraApi.post<{
edges: CatalogueSearchResult[];
count: number;
}>("/catalogue/search", {
data: requestData,
needsAuth: false,
});
setResults(response.edges);
setItemsCount(response.count);
setIsLoading(false);
}, 500)
if (abortController.signal.aborted) return;
setResults(response.edges);
setItemsCount(response.count);
setIsLoading(false);
},
500
)
).current;
const decodeHTML = (s: string) =>
@@ -79,18 +99,17 @@ export default function Catalogue() {
setIsLoading(true);
abortControllerRef.current?.abort();
debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE);
debouncedSearch(
filters,
downloadSources,
PAGE_SIZE,
(page - 1) * PAGE_SIZE
);
return () => {
debouncedSearch.cancel();
};
}, [filters, page, debouncedSearch]);
useEffect(() => {
window.electron.getDownloadSourcesList().then((sources) => {
setDownloadSources(sources.filter((source) => !!source.fingerprint));
});
}, []);
}, [filters, downloadSources, page, debouncedSearch]);
const language = i18n.language.split("-")[0];
@@ -168,7 +187,7 @@ export default function Catalogue() {
value: publisher,
})),
];
}, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
}, [filters, steamUserTags, downloadSources, steamGenresMapping, language]);
const filterSections = useMemo(() => {
return [

View File

@@ -1,6 +1,6 @@
import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import { useAppSelector, useRepacks, useLibrary } from "@renderer/hooks";
import { useAppSelector, useLibrary } from "@renderer/hooks";
import { useMemo, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
@@ -23,10 +23,6 @@ export function GameItem({ game }: GameItemProps) {
const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const [isAddingToLibrary, setIsAddingToLibrary] = useState(false);
const [added, setAdded] = useState(false);
@@ -63,10 +59,6 @@ export function GameItem({ game }: GameItemProps) {
}
};
const uniqueRepackers = useMemo(() => {
return Array.from(new Set(repacks.map((repack) => repack.repacker)));
}, [repacks]);
const genres = useMemo(() => {
return game.genres?.map((genre) => {
const index = steamGenres["en"]?.findIndex(
@@ -117,8 +109,8 @@ export function GameItem({ game }: GameItemProps) {
<span className="game-item__genres">{genres.join(", ")}</span>
<div className="game-item__repackers">
{uniqueRepackers.map((repacker) => (
<Badge key={repacker}>{repacker}</Badge>
{game.downloadSources.map((sourceName) => (
<Badge key={sourceName}>{sourceName}</Badge>
))}
</div>
</div>

View File

@@ -1,3 +1,5 @@
@use "../../scss/globals.scss";
.pagination {
display: flex;
gap: 4px;
@@ -18,4 +20,31 @@
font-size: 16px;
}
}
&__page-input {
box-sizing: border-box;
width: 40px;
min-width: 40px;
max-width: 40px;
min-height: 40px;
border-radius: 8px;
border: solid 1px globals.$border-color;
background-color: transparent;
color: globals.$muted-color;
text-align: center;
font-size: 12px;
padding: 0 6px;
outline: none;
}
&__double-chevron {
display: flex;
align-items: center;
justify-content: center;
font-size: 0; // remove whitespace node width between SVGs
}
&__double-chevron > svg + svg {
margin-left: -8px; // pull the second chevron closer
}
}

View File

@@ -1,8 +1,53 @@
import { Button } from "@renderer/components/button/button";
import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useFormat } from "@renderer/hooks/use-format";
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, KeyboardEvent, RefObject } from "react";
import "./pagination.scss";
interface JumpControlProps {
isOpen: boolean;
value: string;
totalPages: number;
inputRef: RefObject<HTMLInputElement>;
onOpen: () => void;
onClose: () => void;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
}
function JumpControl({
isOpen,
value,
totalPages,
inputRef,
onOpen,
onClose,
onChange,
onKeyDown,
}: JumpControlProps) {
return isOpen ? (
<input
ref={inputRef}
type="text"
min={1}
max={totalPages}
inputMode="numeric"
pattern="[0-9]*"
className="pagination__page-input"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={onClose}
aria-label="Go to page"
/>
) : (
<Button theme="outline" className="pagination__button" onClick={onOpen}>
...
</Button>
);
}
interface PaginationProps {
page: number;
totalPages: number;
@@ -13,23 +58,104 @@ export function Pagination({
page,
totalPages,
onPageChange,
}: PaginationProps) {
}: Readonly<PaginationProps>) {
const { formatNumber } = useFormat();
const [isJumpOpen, setIsJumpOpen] = useState(false);
const [jumpValue, setJumpValue] = useState<string>("");
const jumpInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (isJumpOpen) {
setJumpValue("");
setTimeout(() => jumpInputRef.current?.focus(), 0);
}
}, [isJumpOpen, page]);
if (totalPages <= 1) return null;
const visiblePages = 3;
const isLastThree = totalPages > 3 && page >= totalPages - 2;
let startPage = Math.max(1, page - 1);
let endPage = startPage + visiblePages - 1;
if (endPage > totalPages) {
if (isLastThree) {
startPage = Math.max(1, totalPages - 2);
endPage = totalPages;
} else if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - visiblePages + 1);
}
const onJumpChange = (e: ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
const digitsOnly = raw.replaceAll(/\D+/g, "");
if (digitsOnly === "") {
setJumpValue("");
return;
}
const num = Number.parseInt(digitsOnly, 10);
if (Number.isNaN(num)) {
setJumpValue("");
return;
}
if (num < 1) {
setJumpValue("1");
return;
}
if (num > totalPages) {
setJumpValue(String(totalPages));
return;
}
setJumpValue(String(num));
};
const onJumpKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const controlKeys = [
"Backspace",
"Delete",
"Tab",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
];
if (controlKeys.includes(e.key) || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === "Enter") {
const sanitized = jumpValue.replaceAll(/\D+/g, "");
if (sanitized.trim() === "") return;
const parsed = Number.parseInt(sanitized, 10);
if (Number.isNaN(parsed)) return;
const target = Math.max(1, Math.min(totalPages, parsed));
onPageChange(target);
setIsJumpOpen(false);
} else if (e.key === "Escape") {
setIsJumpOpen(false);
} else if (!/^\d$/.test(e.key)) {
e.preventDefault();
}
};
return (
<div className="pagination">
{startPage > 1 && (
<Button
theme="outline"
onClick={() => onPageChange(1)}
className="pagination__button"
>
<span className="pagination__double-chevron">
<ChevronLeftIcon />
<ChevronLeftIcon />
</span>
</Button>
)}
<Button
theme="outline"
onClick={() => onPageChange(page - 1)}
@@ -39,20 +165,25 @@ export function Pagination({
<ChevronLeftIcon />
</Button>
{page > 2 && (
{isLastThree && startPage > 1 && (
<>
<Button
theme="outline"
onClick={() => onPageChange(1)}
className="pagination__button"
disabled={page === 1}
onClick={() => onPageChange(1)}
>
{1}
{formatNumber(1)}
</Button>
<div className="pagination__ellipsis">
<span className="pagination__ellipsis-text">...</span>
</div>
<JumpControl
isOpen={isJumpOpen}
value={jumpValue}
totalPages={totalPages}
inputRef={jumpInputRef}
onOpen={() => setIsJumpOpen(true)}
onClose={() => setIsJumpOpen(false)}
onChange={onJumpChange}
onKeyDown={onJumpKeyDown}
/>
</>
)}
@@ -70,11 +201,18 @@ export function Pagination({
</Button>
))}
{page < totalPages - 1 && (
{!isLastThree && page < totalPages - 1 && (
<>
<div className="pagination__ellipsis">
<span className="pagination__ellipsis-text">...</span>
</div>
<JumpControl
isOpen={isJumpOpen}
value={jumpValue}
totalPages={totalPages}
inputRef={jumpInputRef}
onOpen={() => setIsJumpOpen(true)}
onClose={() => setIsJumpOpen(false)}
onChange={onJumpChange}
onKeyDown={onJumpKeyDown}
/>
<Button
theme="outline"
@@ -95,6 +233,19 @@ export function Pagination({
>
<ChevronRightIcon />
</Button>
{endPage < totalPages && (
<Button
theme="outline"
onClick={() => onPageChange(totalPages)}
className="pagination__button"
>
<span className="pagination__double-chevron">
<ChevronRightIcon />
<ChevronRightIcon />
</span>
</Button>
)}
</div>
);
}

View File

@@ -7,11 +7,16 @@ import {
} from "@primer/octicons-react";
import useEmblaCarousel from "embla-carousel-react";
import { gameDetailsContext } from "@renderer/context";
import { useAppSelector } from "@renderer/hooks";
import "./gallery-slider.scss";
export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const autoplayEnabled = userPreferences?.autoplayGameTrailers !== false;
const hasScreenshots = shopDetails && shopDetails.screenshots?.length;
@@ -164,7 +169,7 @@ export function GallerySlider() {
poster={item.poster}
loop
muted
autoPlay
autoPlay={autoplayEnabled}
tabIndex={-1}
>
<source src={item.videoSrc} />

View File

@@ -228,7 +228,7 @@ export function GameDetailsContent() {
</button>
)}
{game?.shop !== "custom" && shop && objectId && (
{shop !== "custom" && shop && objectId && (
<GameReviews
shop={shop}
objectId={objectId}
@@ -241,7 +241,7 @@ export function GameDetailsContent() {
)}
</div>
{game?.shop !== "custom" && <Sidebar />}
{shop !== "custom" && <Sidebar />}
</div>
</section>

View File

@@ -103,7 +103,6 @@ export default function GameDetails() {
automaticallyExtract: boolean
) => {
const response = await startDownload({
repackId: repack.id,
objectId: objectId!,
title: gameTitle,
downloader,

View File

@@ -117,7 +117,7 @@ export function GameReviews({
});
const checkUserReview = useCallback(async () => {
if (!objectId || !userDetailsId) return;
if (!objectId || !userDetailsId || shop === "custom") return;
try {
const response = await window.electron.hydraApi.get<{
@@ -147,7 +147,7 @@ export function GameReviews({
const loadReviews = useCallback(
async (reset = false) => {
if (!objectId) return;
if (!objectId || shop === "custom") return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();

View File

@@ -146,6 +146,8 @@ $hero-height: 350px;
&__game-logo {
width: 200px;
align-self: flex-end;
object-fit: contain;
object-position: left bottom;
@media (min-width: 768px) {
width: 250px;
@@ -153,6 +155,7 @@ $hero-height: 350px;
@media (min-width: 1024px) {
width: 300px;
max-height: 150px;
}
}

View File

@@ -54,7 +54,7 @@ export function RepacksModal({
{}
);
const { repacks, game } = useContext(gameDetailsContext);
const { game, repacks } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@@ -88,6 +88,15 @@ export function RepacksModal({
});
}, [repacks, isFeatureEnabled, Feature]);
useEffect(() => {
const fetchDownloadSources = async () => {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
};
fetchDownloadSources();
}, []);
const sortedRepacks = useMemo(() => {
return orderBy(
repacks,
@@ -103,23 +112,13 @@ export function RepacksModal({
);
}, [repacks, hashesInDebrid]);
useEffect(() => {
window.electron.getDownloadSourcesList().then((sources) => {
const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
const filteredSources = sources.filter(
(s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
);
setDownloadSources(filteredSources);
});
}, [sortedRepacks]);
useEffect(() => {
const term = filterTerm.trim().toLowerCase();
const byTerm = sortedRepacks.filter((repack) => {
if (!term) return true;
const lowerTitle = repack.title.toLowerCase();
const lowerRepacker = repack.repacker.toLowerCase();
const lowerRepacker = repack.downloadSourceName.toLowerCase();
return lowerTitle.includes(term) || lowerRepacker.includes(term);
});
@@ -130,7 +129,7 @@ export function RepacksModal({
(src) =>
src.fingerprint &&
selectedFingerprints.includes(src.fingerprint) &&
src.name === repack.repacker
src.name === repack.downloadSourceName
);
});
@@ -281,7 +280,7 @@ export function RepacksModal({
)}
<p className="repacks-modal__repack-info">
{repack.fileSize} - {repack.repacker} -{" "}
{repack.fileSize} - {repack.downloadSourceName} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
</p>

View File

@@ -40,14 +40,20 @@ export default function Home() {
setCurrentCatalogueCategory(category);
setIsLoading(true);
const params = new URLSearchParams({
take: "12",
skip: "0",
});
const downloadSources = await window.electron.getDownloadSources();
const params = {
take: 12,
skip: 0,
downloadSourceIds: downloadSources.map((source) => source.id),
};
const catalogue = await window.electron.hydraApi.get<ShopAssets[]>(
`/catalogue/${category}?${params.toString()}`,
{ needsAuth: false }
`/catalogue/${category}`,
{
params,
needsAuth: false,
}
);
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));

View File

@@ -38,4 +38,11 @@
animation: spin 1s linear infinite;
margin-right: calc(globals.$spacing-unit / 2);
}
&__actions {
display: flex;
justify-content: flex-end;
gap: globals.$spacing-unit;
margin-top: calc(globals.$spacing-unit * 2);
}
}

View File

@@ -1,15 +1,13 @@
import { useCallback, useContext, useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import { settingsContext } from "@renderer/context";
import { useForm } from "react-hook-form";
import { useAppDispatch } from "@renderer/hooks";
import { logger } from "@renderer/logger";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import type { DownloadSourceValidationResult } from "@types";
import { setIsImportingSources } from "@renderer/features";
import { SyncIcon } from "@primer/octicons-react";
import "./add-download-source-modal.scss";
@@ -28,7 +26,6 @@ export function AddDownloadSourceModal({
onClose,
onAddDownloadSource,
}: Readonly<AddDownloadSourceModalProps>) {
const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation("settings");
@@ -48,77 +45,43 @@ export function AddDownloadSourceModal({
resolver: yupResolver(schema),
});
const [validationResult, setValidationResult] =
useState<DownloadSourceValidationResult | null>(null);
const { sourceUrl } = useContext(settingsContext);
const dispatch = useAppDispatch();
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
const onSubmit = useCallback(
async (values: FormValues) => {
const exists = await window.electron.checkDownloadSourceExists(
values.url
);
try {
await window.electron.addDownloadSource(values.url);
if (exists) {
setError("url", {
type: "server",
message: t("source_already_exists"),
});
onClose();
onAddDownloadSource();
} catch (error) {
logger.error("Failed to add download source:", error);
const errorMessage =
error instanceof Error && error.message.includes("already exists")
? t("download_source_already_exists")
: t("failed_add_download_source");
return;
}
const validationResult = await window.electron.validateDownloadSource(
values.url
);
setValidationResult(validationResult);
setUrl(values.url);
},
[setError, t]
);
setError("url", {
type: "server",
message: errorMessage,
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
setValue("url", "");
clearErrors();
setIsLoading(false);
setValidationResult(null);
if (sourceUrl) {
setValue("url", sourceUrl);
handleSubmit(onSubmit)();
}
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const handleAddDownloadSource = async () => {
if (validationResult) {
setIsLoading(true);
dispatch(setIsImportingSources(true));
try {
// Single call that handles: import → API sync → fingerprint
await window.electron.addDownloadSource(url);
// Close modal and update UI
onClose();
onAddDownloadSource();
} catch (error) {
console.error("Failed to add download source:", error);
setError("url", {
type: "server",
message: "Failed to import source. Please try again.",
});
} finally {
setIsLoading(false);
dispatch(setIsImportingSources(false));
}
}
};
}, [visible, clearErrors, setValue, sourceUrl]);
const handleClose = () => {
// Prevent closing while importing
if (isLoading) return;
onClose();
};
@@ -132,49 +95,32 @@ export function AddDownloadSourceModal({
clickOutsideToClose={!isLoading}
>
<div className="add-download-source-modal__container">
<TextField
{...register("url")}
label={t("download_source_url")}
placeholder={t("insert_valid_json_url")}
error={errors.url?.message}
rightContent={
<form onSubmit={handleSubmit(onSubmit)}>
<TextField
{...register("url")}
label={t("download_source_url")}
placeholder={t("insert_valid_json_url")}
error={errors.url?.message}
/>
<div className="add-download-source-modal__actions">
<Button
type="button"
theme="outline"
className="add-download-source-modal__validate-button"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || isLoading}
>
{t("validate_download_source")}
</Button>
}
/>
{validationResult && (
<div className="add-download-source-modal__validation-result">
<div className="add-download-source-modal__validation-info">
<h4>{validationResult?.name}</h4>
<small>
{t("found_download_option", {
count: validationResult?.downloadCount,
countFormatted:
validationResult?.downloadCount.toLocaleString(),
})}
</small>
</div>
<Button
type="button"
onClick={handleAddDownloadSource}
onClick={handleClose}
disabled={isLoading}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting || isLoading}>
{isLoading && (
<SyncIcon className="add-download-source-modal__spinner" />
)}
{isLoading ? t("importing") : t("import")}
{isLoading ? t("adding") : t("add_download_source")}
</Button>
</div>
)}
</form>
</div>
</Modal>
);

View File

@@ -201,7 +201,7 @@ export function SettingsAccount() {
</section>
<section className="settings-account__section">
<h3>Hydra Cloud</h3>
<h3>{t("hydra_cloud")}</h3>
<div className="settings-account__subscription-info">
{getHydraCloudSectionContent().description}
</div>

View File

@@ -27,6 +27,8 @@ export function SettingsBehavior() {
showDownloadSpeedInMegabytes: false,
extractFilesByDefault: true,
enableSteamAchievements: false,
autoplayGameTrailers: true,
hideToTrayOnGameStart: false,
});
const { t } = useTranslation("settings");
@@ -49,6 +51,8 @@ export function SettingsBehavior() {
extractFilesByDefault: userPreferences.extractFilesByDefault ?? true,
enableSteamAchievements:
userPreferences.enableSteamAchievements ?? false,
autoplayGameTrailers: userPreferences.autoplayGameTrailers ?? true,
hideToTrayOnGameStart: userPreferences.hideToTrayOnGameStart ?? false,
});
}
}, [userPreferences]);
@@ -76,6 +80,16 @@ export function SettingsBehavior() {
}
/>
<CheckboxField
label={t("hide_to_tray_on_game_start")}
checked={form.hideToTrayOnGameStart}
onChange={() =>
handleChange({
hideToTrayOnGameStart: !form.hideToTrayOnGameStart,
})
}
/>
{showRunAtStartup && (
<CheckboxField
label={t("launch_with_system")}
@@ -120,6 +134,14 @@ export function SettingsBehavior() {
/>
)}
<CheckboxField
label={t("autoplay_trailers_on_game_page")}
checked={form.autoplayGameTrailers}
onChange={() =>
handleChange({ autoplayGameTrailers: !form.autoplayGameTrailers })
}
/>
<CheckboxField
label={t("disable_nsfw_alert")}
checked={form.disableNsfwAlert}

View File

@@ -1,5 +1,14 @@
@use "../../scss/globals.scss";
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.settings-download-sources {
&__list {
padding: 0;
@@ -22,6 +31,17 @@
&--syncing {
opacity: globals.$disabled-opacity;
}
&--pending {
opacity: 0.6;
}
}
&__spinner {
animation: spin 1s linear infinite;
margin-right: calc(globals.$spacing-unit / 2);
width: 12px;
height: 12px;
}
&__item-header {

View File

@@ -16,12 +16,13 @@ import {
TrashIcon,
} from "@primer/octicons-react";
import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
import { useAppDispatch, useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { settingsContext } from "@renderer/context";
import { useNavigate } from "react-router-dom";
import { setFilters, clearFilters } from "@renderer/features";
import "./settings-download-sources.scss";
import { logger } from "@renderer/logger";
export function SettingsDownloadSources() {
const [
@@ -35,7 +36,6 @@ export function SettingsDownloadSources() {
useState(false);
const [isRemovingDownloadSource, setIsRemovingDownloadSource] =
useState(false);
const [isFetchingSources, setIsFetchingSources] = useState(true);
const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
@@ -46,37 +46,53 @@ export function SettingsDownloadSources() {
const navigate = useNavigate();
const { updateRepacks } = useRepacks();
const getDownloadSources = async () => {
await window.electron
.getDownloadSourcesList()
.then((sources) => {
setDownloadSources(sources);
})
.finally(() => {
setIsFetchingSources(false);
});
};
useEffect(() => {
getDownloadSources();
}, []);
useEffect(() => {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
useEffect(() => {
const fetchDownloadSources = async () => {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
};
fetchDownloadSources();
}, []);
useEffect(() => {
const hasPendingOrMatchingSource = downloadSources.some(
(source) =>
source.status === DownloadSourceStatus.PendingMatching ||
source.status === DownloadSourceStatus.Matching
);
if (!hasPendingOrMatchingSource || !downloadSources.length) {
return;
}
const intervalId = setInterval(async () => {
try {
await window.electron.syncDownloadSources();
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
} catch (error) {
logger.error("Failed to fetch download sources:", error);
}
}, 5000);
return () => clearInterval(intervalId);
}, [downloadSources]);
const handleRemoveSource = async (downloadSource: DownloadSource) => {
setIsRemovingDownloadSource(true);
try {
await window.electron.deleteDownloadSource(downloadSource.id);
await window.electron.removeDownloadSource(downloadSource.url);
await window.electron.removeDownloadSource(false, downloadSource.id);
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources as DownloadSource[]);
showSuccessToast(t("removed_download_source"));
await getDownloadSources();
updateRepacks();
} catch (error) {
logger.error("Failed to remove download source:", error);
} finally {
setIsRemovingDownloadSource(false);
}
@@ -86,53 +102,47 @@ export function SettingsDownloadSources() {
setIsRemovingDownloadSource(true);
try {
await window.electron.deleteAllDownloadSources();
await window.electron.removeDownloadSource("", true);
showSuccessToast(t("removed_download_sources"));
await getDownloadSources();
setShowConfirmationDeleteAllSourcesModal(false);
updateRepacks();
await window.electron.removeDownloadSource(true);
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources as DownloadSource[]);
showSuccessToast(t("removed_all_download_sources"));
} catch (error) {
logger.error("Failed to remove all download sources:", error);
} finally {
setIsRemovingDownloadSource(false);
setShowConfirmationDeleteAllSourcesModal(false);
}
};
const handleAddDownloadSource = async () => {
// Refresh sources list and repacks after import completes
await getDownloadSources();
// Force repacks update to ensure UI reflects new data
await updateRepacks();
showSuccessToast(t("added_download_source"));
try {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources as DownloadSource[]);
} catch (error) {
logger.error("Failed to refresh download sources:", error);
}
};
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
try {
// Sync local sources (check for updates)
await window.electron.syncDownloadSources();
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources as DownloadSource[]);
// Refresh sources and repacks AFTER sync completes
await getDownloadSources();
await updateRepacks();
showSuccessToast(t("download_sources_synced"));
} catch (error) {
console.error("Error syncing download sources:", error);
// Still refresh the UI even if sync fails
await getDownloadSources();
await updateRepacks();
showSuccessToast(t("download_sources_synced_successfully"));
} finally {
setIsSyncingDownloadSources(false);
}
};
const statusTitle = {
[DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"),
[DownloadSourceStatus.Errored]: t("download_source_errored"),
[DownloadSourceStatus.PendingMatching]: t(
"download_source_pending_matching"
),
[DownloadSourceStatus.Matched]: t("download_source_matched"),
[DownloadSourceStatus.Matching]: t("download_source_matching"),
[DownloadSourceStatus.Failed]: t("download_source_failed"),
};
const handleModalClose = () => {
@@ -142,7 +152,7 @@ export function SettingsDownloadSources() {
const navigateToCatalogue = (fingerprint?: string) => {
if (!fingerprint) {
console.error("Cannot navigate: fingerprint is undefined");
logger.error("Cannot navigate: fingerprint is undefined");
return;
}
@@ -180,8 +190,7 @@ export function SettingsDownloadSources() {
disabled={
!downloadSources.length ||
isSyncingDownloadSources ||
isRemovingDownloadSource ||
isFetchingSources
isRemovingDownloadSource
}
onClick={syncDownloadSources}
>
@@ -197,8 +206,7 @@ export function SettingsDownloadSources() {
disabled={
isRemovingDownloadSource ||
isSyncingDownloadSources ||
!downloadSources.length ||
isFetchingSources
!downloadSources.length
}
>
<TrashIcon />
@@ -209,11 +217,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => setShowAddDownloadSourceModal(true)}
disabled={
isSyncingDownloadSources ||
isFetchingSources ||
isRemovingDownloadSource
}
disabled={isSyncingDownloadSources || isRemovingDownloadSource}
>
<PlusCircleIcon />
{t("add_download_source")}
@@ -223,16 +227,25 @@ export function SettingsDownloadSources() {
<ul className="settings-download-sources__list">
{downloadSources.map((downloadSource) => {
const isPendingOrMatching =
downloadSource.status === DownloadSourceStatus.PendingMatching ||
downloadSource.status === DownloadSourceStatus.Matching;
return (
<li
key={downloadSource.id}
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""}`}
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""} ${isPendingOrMatching ? "settings-download-sources__item--pending" : ""}`}
>
<div className="settings-download-sources__item-header">
<h2>{downloadSource.name}</h2>
<div style={{ display: "flex" }}>
<Badge>{statusTitle[downloadSource.status]}</Badge>
<Badge>
{isPendingOrMatching && (
<SyncIcon className="settings-download-sources__spinner" />
)}
{statusTitle[downloadSource.status]}
</Badge>
</div>
<button
@@ -244,11 +257,13 @@ export function SettingsDownloadSources() {
}
>
<small>
{t("download_count", {
count: downloadSource.downloadCount,
countFormatted:
downloadSource.downloadCount.toLocaleString(),
})}
{isPendingOrMatching
? t("download_source_no_information")
: t("download_count", {
count: downloadSource.downloadCount,
countFormatted:
downloadSource.downloadCount.toLocaleString(),
})}
</small>
</button>
</div>

View File

@@ -133,7 +133,7 @@ export function SettingsRealDebrid() {
{t("save_changes")}
</Button>
}
placeholder="API Token"
placeholder={t("api_token")}
hint={
<Trans i18nKey="debrid_api_token_hint" ns="settings">
<Link to={REAL_DEBRID_API_TOKEN_URL} />

View File

@@ -116,7 +116,7 @@ export function SettingsTorBox() {
onChange={(event) =>
setForm({ ...form, torBoxApiToken: event.target.value })
}
placeholder="API Token"
placeholder={t("api_token")}
rightContent={
<Button type="submit" disabled={isButtonDisabled}>
{t("save_changes")}

View File

@@ -8,8 +8,6 @@ import {
userDetailsSlice,
gameRunningSlice,
subscriptionSlice,
repacksSlice,
downloadSourcesSlice,
catalogueSearchSlice,
} from "@renderer/features";
@@ -23,8 +21,6 @@ export const store = configureStore({
userDetails: userDetailsSlice.reducer,
gameRunning: gameRunningSlice.reducer,
subscription: subscriptionSlice.reducer,
repacks: repacksSlice.reducer,
downloadSources: downloadSourcesSlice.reducer,
catalogueSearch: catalogueSearchSlice.reducer,
},
});

View File

@@ -11,8 +11,10 @@ export enum Downloader {
}
export enum DownloadSourceStatus {
UpToDate,
Errored,
PendingMatching = "PENDING_MATCHING",
Matched = "MATCHED",
Matching = "MATCHING",
Failed = "FAILED",
}
export enum CatalogueCategory {

View File

@@ -1,4 +1,4 @@
export type GameShop = "steam" | "epic" | "custom";
export type GameShop = "steam" | "custom";
export type ShortcutLocation = "desktop" | "start_menu";

View File

@@ -16,29 +16,24 @@ export interface DiskUsage {
}
export interface GameRepack {
id: number;
id: string;
title: string;
uris: string[];
repacker: string;
fileSize: string | null;
objectIds: string[];
uploadDate: Date | string | null;
createdAt: Date;
updatedAt: Date;
uris: string[];
uploadDate: string | null;
downloadSourceId: string;
downloadSourceName: string;
}
export interface DownloadSource {
id: number;
id: string;
name: string;
url: string;
repackCount: number;
status: DownloadSourceStatus;
objectIds: string[];
downloadCount: number;
fingerprint?: string;
etag: string | null;
createdAt: Date;
updatedAt: Date;
isRemote?: true;
createdAt: string;
}
export interface ShopAssets {
@@ -51,6 +46,7 @@ export interface ShopAssets {
logoImageUrl: string;
logoPosition: string | null;
coverImageUrl: string | null;
downloadSources: string[];
}
export type ShopDetails = SteamAppDetails & {
@@ -113,7 +109,6 @@ export type AppUpdaterEvent =
/* Events */
export interface StartGameDownloadPayload {
repackId: number;
objectId: string;
title: string;
shop: GameShop;
@@ -231,12 +226,6 @@ export interface DownloadSourceDownload {
fileSize: string;
}
export interface DownloadSourceValidationResult {
name: string;
etag: string;
downloadCount: number;
}
export interface GameStats {
downloadCount: number;
playerCount: number;
@@ -366,7 +355,7 @@ export type CatalogueSearchResult = {
title: string;
shop: GameShop;
genres: string[];
} & Pick<ShopAssets, "libraryImageUrl">;
} & Pick<ShopAssets, "libraryImageUrl" | "downloadSources">;
export type LibraryGame = Game &
Partial<ShopAssets> & {

View File

@@ -118,6 +118,8 @@ export interface UserPreferences {
showDownloadSpeedInMegabytes?: boolean;
extractFilesByDefault?: boolean;
enableSteamAchievements?: boolean;
autoplayGameTrailers?: boolean;
hideToTrayOnGameStart?: boolean;
}
export interface ScreenState {

View File

@@ -9190,8 +9190,3 @@ yup@^1.5.0:
tiny-case "^1.0.3"
toposort "^2.0.2"
type-fest "^2.19.0"
zod@^3.24.1:
version "3.25.76"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==