mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-12 14:26:16 +00:00
Compare commits
206 Commits
v2.0
...
github/for
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0dd89e471 | ||
|
|
4e8f5a0881 | ||
|
|
ae45547c17 | ||
|
|
4540dcc033 | ||
|
|
11c29355e3 | ||
|
|
91862cd2fe | ||
|
|
6c5d3793ae | ||
|
|
43c5fdbab9 | ||
|
|
3952f106fc | ||
|
|
2e386528a4 | ||
|
|
b6727be3cf | ||
|
|
05f9703c25 | ||
|
|
929be48495 | ||
|
|
8c67dda84e | ||
|
|
6d277cd1d8 | ||
|
|
d4902a5ab1 | ||
|
|
004ccd0db5 | ||
|
|
e55dc20c7d | ||
|
|
7f3d7a56c3 | ||
|
|
d0406282ce | ||
|
|
c6e99f8599 | ||
|
|
5aec973882 | ||
|
|
49fd34c3c0 | ||
|
|
198a283752 | ||
|
|
46b12f2bc2 | ||
|
|
cb93fbcb72 | ||
|
|
22b66149b3 | ||
|
|
6f70b529a2 | ||
|
|
a81b016500 | ||
|
|
ef0699dbea | ||
|
|
b3f87d5662 | ||
|
|
6ff48605da | ||
|
|
0f0a1e98a3 | ||
|
|
007da03837 | ||
|
|
6cc8e8f5fe | ||
|
|
6ccbff0160 | ||
|
|
202f5b60de | ||
|
|
b9558907ec | ||
|
|
8a01352eab | ||
|
|
1aa438b0fa | ||
|
|
e008478e53 | ||
|
|
6a195eb566 | ||
|
|
e2b089e0f8 | ||
|
|
a9b92f3fc1 | ||
|
|
6822ed8447 | ||
|
|
2a6e0f31df | ||
|
|
5683a0ba49 | ||
|
|
18488490c1 | ||
|
|
b7cabfdbde | ||
|
|
2ee3fdc223 | ||
|
|
6fce60f9f7 | ||
|
|
c8aa9fd681 | ||
|
|
0f12dfae88 | ||
|
|
be48306ca2 | ||
|
|
ab81e21341 | ||
|
|
b7f94102da | ||
|
|
9e7b27afe6 | ||
|
|
c24523e8e6 | ||
|
|
b58330ed35 | ||
|
|
dde40f39e9 | ||
|
|
d2b3017de9 | ||
|
|
64f4dad7cc | ||
|
|
154d211b21 | ||
|
|
7905ef6c10 | ||
|
|
b09f2c055f | ||
|
|
2c5b3b4ffa | ||
|
|
fdefc0c165 | ||
|
|
47ca2535e3 | ||
|
|
f706836a43 | ||
|
|
d8158bb80e | ||
|
|
4e422bdf91 | ||
|
|
4be3db8007 | ||
|
|
29b64237ed | ||
|
|
d481164bf3 | ||
|
|
138f33e0c3 | ||
|
|
be3c78f584 | ||
|
|
be1d9825d3 | ||
|
|
981116f221 | ||
|
|
26aad178ee | ||
|
|
56c8349899 | ||
|
|
0b2c407770 | ||
|
|
d2e3d48ef8 | ||
|
|
153291f89f | ||
|
|
ae3daa4c79 | ||
|
|
1397e3932d | ||
|
|
0f5db4f34e | ||
|
|
75c8f69e81 | ||
|
|
aa253466a3 | ||
|
|
b8bd786c45 | ||
|
|
c9c585f820 | ||
|
|
9e11d6c098 | ||
|
|
2f83c2c9da | ||
|
|
dc94a886e6 | ||
|
|
7deabc4889 | ||
|
|
e57200d024 | ||
|
|
7a13739d49 | ||
|
|
f8cbbc64f0 | ||
|
|
9096eb5e0e | ||
|
|
eebb5fec61 | ||
|
|
88cfd0d095 | ||
|
|
a43768ce67 | ||
|
|
16a8c28935 | ||
|
|
1cc5a5b209 | ||
|
|
a39082d326 | ||
|
|
0c1a75eedd | ||
|
|
dd23358a95 | ||
|
|
8f00254dc2 | ||
|
|
449b34d3dd | ||
|
|
9870213fff | ||
|
|
de237b7c39 | ||
|
|
8a5d4e38b6 | ||
|
|
77152a32ab | ||
|
|
c57c8dc477 | ||
|
|
455d80da3e | ||
|
|
d61c535c6f | ||
|
|
23308a7780 | ||
|
|
05ec01178b | ||
|
|
84e279cc14 | ||
|
|
8eca067aed | ||
|
|
05e4934f9f | ||
|
|
ec0439e41b | ||
|
|
b61fd1e61a | ||
|
|
6d4f47df38 | ||
|
|
0eaf629d37 | ||
|
|
c12f16f59e | ||
|
|
ac27438a35 | ||
|
|
d3787b4525 | ||
|
|
ec8a0f75ac | ||
|
|
7e85ac5b43 | ||
|
|
a4644e7501 | ||
|
|
ed978af3ae | ||
|
|
4bd2174bf3 | ||
|
|
c27182c618 | ||
|
|
1ceabb00be | ||
|
|
2a44313d84 | ||
|
|
e0dca85825 | ||
|
|
ec8ccf7728 | ||
|
|
e88088cca4 | ||
|
|
75b69f38fc | ||
|
|
50a1ba1dea | ||
|
|
2229151795 | ||
|
|
041fce027e | ||
|
|
1d5004ecb4 | ||
|
|
363bcf16a4 | ||
|
|
b1532a52c8 | ||
|
|
a3f7d3c59e | ||
|
|
f1fecb684b | ||
|
|
9c99e56b70 | ||
|
|
7be626b3dd | ||
|
|
96e96cd8aa | ||
|
|
13644c60e8 | ||
|
|
a1e41ea464 | ||
|
|
41dc504660 | ||
|
|
a0cc15b5d8 | ||
|
|
7cd121cb80 | ||
|
|
ccaea88a88 | ||
|
|
d90888c7ba | ||
|
|
9f9ea6ee88 | ||
|
|
c26315219e | ||
|
|
c1c06c2d20 | ||
|
|
328b7cb137 | ||
|
|
82f72071f9 | ||
|
|
d9ed2403ed | ||
|
|
d447942f84 | ||
|
|
05cfdefc84 | ||
|
|
e4020d5b6a | ||
|
|
1a047547fc | ||
|
|
47ab35421c | ||
|
|
e08aa9c299 | ||
|
|
e44049ff63 | ||
|
|
7aa02f9d64 | ||
|
|
3fe6ab469b | ||
|
|
ccd1d18981 | ||
|
|
906e801036 | ||
|
|
63c13e17cb | ||
|
|
c1297530f6 | ||
|
|
ac10e755b8 | ||
|
|
1f17dda2f8 | ||
|
|
94284a427f | ||
|
|
7fe8a6425b | ||
|
|
2e1eb9e9b7 | ||
|
|
fe33045b9e | ||
|
|
2020663ee5 | ||
|
|
2b51b82d03 | ||
|
|
13b691aaad | ||
|
|
e10f9f829c | ||
|
|
936881e570 | ||
|
|
0c826cb6f7 | ||
|
|
2a27c37a25 | ||
|
|
3fd9776987 | ||
|
|
e93b0a786e | ||
|
|
7a6d8ece63 | ||
|
|
51c56f7536 | ||
|
|
87f5e7eb26 | ||
|
|
2a3fda90b3 | ||
|
|
9d11cac680 | ||
|
|
42209b51a6 | ||
|
|
170826ad5d | ||
|
|
11dffd1b7a | ||
|
|
37eddbaeeb | ||
|
|
4744d1ed52 | ||
|
|
3b40413257 | ||
|
|
42eff5e906 | ||
|
|
d29f266ca1 | ||
|
|
42864a4bea | ||
|
|
d70b46d475 |
@@ -1,3 +1,3 @@
|
||||
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
|
||||
MAIN_VITE_API_URL=API_URL
|
||||
|
||||
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN
|
||||
|
||||
15
.github/workflows/build.yml
vendored
15
.github/workflows/build.yml
vendored
@@ -22,6 +22,17 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Build with cx_Freeze
|
||||
run: python torrent-client/setup.py build
|
||||
|
||||
- name: Build Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: yarn build:linux
|
||||
@@ -29,6 +40,8 @@ jobs:
|
||||
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
|
||||
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Windows
|
||||
@@ -38,6 +51,8 @@ jobs:
|
||||
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
|
||||
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create artifact
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Lint
|
||||
|
||||
on: [pull_request, push]
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
||||
17
.github/workflows/release.yml
vendored
17
.github/workflows/release.yml
vendored
@@ -24,12 +24,26 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Build with cx_Freeze
|
||||
run: python torrent-client/setup.py build
|
||||
|
||||
- name: Build Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: yarn build:linux
|
||||
env:
|
||||
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
|
||||
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Windows
|
||||
@@ -38,6 +52,9 @@ jobs:
|
||||
env:
|
||||
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
|
||||
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Release
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.vscode
|
||||
node_modules
|
||||
hydra-download-manager/
|
||||
aria2/
|
||||
fastlist.exe
|
||||
__pycache__
|
||||
@@ -9,3 +10,4 @@ out
|
||||
*.log*
|
||||
.env
|
||||
.vite
|
||||
sentry.properties
|
||||
|
||||
@@ -83,7 +83,7 @@ Puedes unirte a nuestra conversación y discusiones en nuestro canal de [Telegra
|
||||
|
||||
### Haz un fork y clona tu repositorio
|
||||
|
||||
1. Rea;iza un fork del repositorio [(Haz click acá para hacer un fork ahora)](https://github.com/hydralauncher/hydra/fork)
|
||||
1. Realiza un fork del repositorio [(Haz click acá para hacer un fork ahora)](https://github.com/hydralauncher/hydra/fork)
|
||||
2. Clona el código forkeado `git clone https://github.com/tu_nombredeusuario/hydra`
|
||||
3. Crea una nueva rama
|
||||
4. Sube tus commits
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.</strong>
|
||||
<strong>Hydra is a game launcher with its own embedded bittorrent client.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -50,17 +50,15 @@
|
||||
|
||||
## About
|
||||
|
||||
**Hydra** is a **Game Launcher** with its own embedded **BitTorrent Client** and a **self-managed repack scraper**.
|
||||
**Hydra** is a **Game Launcher** with its own embedded **BitTorrent Client**.
|
||||
<br>
|
||||
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using libtorrent.
|
||||
|
||||
## Features
|
||||
|
||||
- Self-Managed repack scraper among all the most reliable websites on the [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
|
||||
- Own embedded bittorrent client
|
||||
- How Long To Beat (HLTB) integration on game page
|
||||
- Downloads path customization
|
||||
- Repack list update notifications
|
||||
- Windows and Linux support
|
||||
- Constantly updated
|
||||
- And more ...
|
||||
@@ -134,9 +132,8 @@ pip install -r requirements.txt
|
||||
## Environment variables
|
||||
|
||||
You'll need an SteamGridDB API Key in order to fetch the game icons on installation.
|
||||
If you want to have onlinefix as a repacker you'll need to add your credentials to the .env
|
||||
|
||||
Once you have it, you can copy or rename the `.env.example` file to `.env` and put it on`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
|
||||
Once you have it, you can copy or rename the `.env.example` file to `.env` and put it on`STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- aria2
|
||||
- hydra-download-manager
|
||||
- seeds
|
||||
- from: node_modules/ps-list/vendor/fastlist-0.3.0-x64.exe
|
||||
to: fastlist.exe
|
||||
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
||||
files:
|
||||
- "!**/.vscode/*"
|
||||
@@ -19,7 +18,6 @@ asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
executableName: Hydra
|
||||
requestedExecutionLevel: requireAdministrator
|
||||
target:
|
||||
- nsis
|
||||
- portable
|
||||
@@ -32,7 +30,6 @@ nsis:
|
||||
allowToChangeInstallationDirectory: true
|
||||
portable:
|
||||
artifactName: ${name}-${version}-portable.${ext}
|
||||
requestExecutionLevel: admin
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
|
||||
@@ -6,9 +6,16 @@ import {
|
||||
externalizeDepsPlugin,
|
||||
} from "electron-vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
||||
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
|
||||
const sentryPlugin = sentryVitePlugin({
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
org: "hydra-launcher",
|
||||
project: "hydra-launcher",
|
||||
});
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
loadEnv(mode);
|
||||
|
||||
@@ -28,7 +35,7 @@ export default defineConfig(({ mode }) => {
|
||||
"@shared": resolve("src/shared"),
|
||||
},
|
||||
},
|
||||
plugins: [externalizeDepsPlugin(), swcPlugin()],
|
||||
plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin],
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
@@ -44,7 +51,7 @@ export default defineConfig(({ mode }) => {
|
||||
"@shared": resolve("src/shared"),
|
||||
},
|
||||
},
|
||||
plugins: [svgr(), react(), vanillaExtractPlugin()],
|
||||
plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
@@ -38,7 +38,9 @@
|
||||
"@fontsource/fira-sans": "^5.0.20",
|
||||
"@primer/octicons-react": "^19.9.0",
|
||||
"@reduxjs/toolkit": "^2.2.3",
|
||||
"@sentry/electron": "^5.1.0",
|
||||
"@vanilla-extract/css": "^1.14.2",
|
||||
"@vanilla-extract/dynamic": "^2.1.1",
|
||||
"@vanilla-extract/recipes": "^0.5.2",
|
||||
"aria2": "^4.1.2",
|
||||
"auto-launch": "^5.0.6",
|
||||
@@ -65,11 +67,11 @@
|
||||
"lottie-react": "^2.4.0",
|
||||
"parse-torrent": "^11.0.16",
|
||||
"piscina": "^4.5.1",
|
||||
"ps-list": "^8.1.1",
|
||||
"react-i18next": "^14.1.0",
|
||||
"react-loading-skeleton": "^3.4.0",
|
||||
"react-redux": "^9.1.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"typeorm": "^0.3.20",
|
||||
"user-agents": "^1.1.193",
|
||||
"yaml": "^2.4.1",
|
||||
@@ -81,6 +83,7 @@
|
||||
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^1.0.1",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@sentry/vite-plugin": "^2.20.1",
|
||||
"@swc/core": "^1.4.16",
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/color": "^3.0.6",
|
||||
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
libtorrent
|
||||
cx_Freeze
|
||||
cx_Logging; sys_platform == 'win32'
|
||||
lief; sys_platform == 'win32'
|
||||
pywin32; sys_platform == 'win32'
|
||||
psutil
|
||||
148
src/locales/ca/translation.json
Normal file
148
src/locales/ca/translation.json
Normal file
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"home": {
|
||||
"featured": "Destacats",
|
||||
"trending": "Populars",
|
||||
"surprise_me": "Sorprèn-me",
|
||||
"no_results": "No s'ha trobat res"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Catàleg",
|
||||
"downloads": "Baixades",
|
||||
"settings": "Configuració",
|
||||
"my_library": "Biblioteca",
|
||||
"downloading_metadata": "{{title}} (S'estan baixant les metadades…)",
|
||||
"paused": "{{title}} (Pausat)",
|
||||
"downloading": "{{title}} ({{percentage}} - S'està baixant…)",
|
||||
"filter": "Filtra la biblioteca",
|
||||
"home": "Inici"
|
||||
},
|
||||
"header": {
|
||||
"search": "Cerca jocs",
|
||||
"home": "Inici",
|
||||
"catalogue": "Catàleg",
|
||||
"downloads": "Baixades",
|
||||
"search_results": "Resultats de la cerca",
|
||||
"settings": "Configuració",
|
||||
"version_available_install": "Hi ha disponible la versió {{version}}. Feu clic aquí per a reiniciar i instal·lar-la.",
|
||||
"version_available_download": "Hi ha disponible la versió {{version}}. Feu clic aquí per a baixar-la."
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "Cap baixada en curs",
|
||||
"downloading_metadata": "S'estan baixant les metadades de: {{title}}…",
|
||||
"downloading": "S'està baixant: {{title}}… ({{percentage}} complet) - Finalització: {{eta}} - {{speed}}"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Pàgina següent",
|
||||
"previous_page": "Pàgina anterior"
|
||||
},
|
||||
"game_details": {
|
||||
"open_download_options": "Obre les opcions de baixada",
|
||||
"download_options_zero": "No hi ha opcions de baixada",
|
||||
"download_options_one": "{{count}} opció de baixada",
|
||||
"download_options_other": "{{count}} opcions de baixada",
|
||||
"updated_at": "Actualitzat: {{updated_at}}",
|
||||
"install": "Instal·la",
|
||||
"resume": "Reprèn",
|
||||
"pause": "Pausa",
|
||||
"cancel": "Cancel·la",
|
||||
"remove": "Elimina",
|
||||
"space_left_on_disk": "{{space}} lliures al disc",
|
||||
"eta": "Finalització: {{eta}}",
|
||||
"downloading_metadata": "S'estan baixant les metadades…",
|
||||
"filter": "Filtra els reempaquetats",
|
||||
"requirements": "Requisits del sistema",
|
||||
"minimum": "Mínims",
|
||||
"recommended": "Recomanats",
|
||||
"release_date": "Publicat el {{date}}",
|
||||
"publisher": "Publicat per {{publisher}}",
|
||||
"hours": "hores",
|
||||
"minutes": "minuts",
|
||||
"amount_hours": "{{amount}} hores",
|
||||
"amount_minutes": "{{amount}} minuts",
|
||||
"accuracy": "{{accuracy}}% de precisió",
|
||||
"add_to_library": "Afegeix a la biblioteca",
|
||||
"remove_from_library": "Elimina de la biblioteca",
|
||||
"no_downloads": "No hi ha baixades disponibles",
|
||||
"play_time": "Jugat durant {{amount}}",
|
||||
"last_time_played": "Última partida: {{period}}",
|
||||
"not_played_yet": "Encara no has jugat al {{title}}",
|
||||
"next_suggestion": "Suggeriment següent",
|
||||
"play": "Inicia",
|
||||
"deleting": "S'està eliminant l'instal·lador…",
|
||||
"close": "Tanca",
|
||||
"playing_now": "S'està jugant",
|
||||
"change": "Canvia",
|
||||
"repacks_modal_description": "Tria quin reempaquetat vols baixar",
|
||||
"select_folder_hint": "Per a canviar la carpeta predefinida, vés a la <0>Configuració</0>",
|
||||
"download_now": "Baixa ara",
|
||||
"no_shop_details": "No s'han pogut recuperar els detalls de la tenda.",
|
||||
"download_options": "Opcions de baixada",
|
||||
"download_path": "Ruta de baixada",
|
||||
"previous_screenshot": "Captura anterior",
|
||||
"next_screenshot": "Captura següent",
|
||||
"screenshot": "Captura {{number}}",
|
||||
"open_screenshot": "Obre la captura {{number}}"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activa l'Hydra",
|
||||
"installation_id": "ID d'instal·lació:",
|
||||
"enter_activation_code": "Introdueix el codi d'activació",
|
||||
"message": "Si no saps on demanar-ho, no ho hauries de tenir.",
|
||||
"activate": "Activa",
|
||||
"loading": "S'està carregant…"
|
||||
},
|
||||
"downloads": {
|
||||
"resume": "Reprèn",
|
||||
"pause": "Pausa",
|
||||
"eta": "Finalització {{eta}}",
|
||||
"paused": "Pausada",
|
||||
"verifying": "S'està verificant…",
|
||||
"completed": "Completada",
|
||||
"cancel": "Cancel·la",
|
||||
"filter": "Filtra els jocs baixats",
|
||||
"remove": "Elimina",
|
||||
"downloading_metadata": "S'estan baixant les metadades…",
|
||||
"deleting": "S'està eliminant l'instal·lador…",
|
||||
"delete": "Elimina l'instal·lador",
|
||||
"delete_modal_title": "N'estàs segur?",
|
||||
"delete_modal_description": "S'eliminaran de l'ordinador tots els fitxers d'instal·lació",
|
||||
"install": "Instal·la"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Ruta de baixades",
|
||||
"change": "Actualitza",
|
||||
"notifications": "Notificacions",
|
||||
"enable_download_notifications": "Quan finalitzi una baixada",
|
||||
"enable_repack_list_notifications": "Quan s'afegeixi un nou reempaquetat",
|
||||
"real_debrid_api_token_label": "Testimoni de l'API de Real Debrid",
|
||||
"quit_app_instead_hiding": "Tanca l'Hydra en compte de minimitzar-la a la safata",
|
||||
"launch_with_system": "Inicia l'Hydra quan s'iniciï el sistema",
|
||||
"general": "General",
|
||||
"behavior": "Comportament",
|
||||
"enable_real_debrid": "Activa el Real Debrid",
|
||||
"real_debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí</0>.",
|
||||
"save_changes": "Desa els canvis"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "La baixada ha finalitzat",
|
||||
"game_ready_to_install": "{{title}} ja es pot instal·lar",
|
||||
"repack_list_updated": "S'ha actualitzat la llista de reempaquetats",
|
||||
"repack_count_one": "S'ha afegit {{count}} reempaquetat",
|
||||
"repack_count_other": "S'han afegit {{count}} reempaquetats"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Obre l'Hydra",
|
||||
"quit": "Tanca"
|
||||
},
|
||||
"game_card": {
|
||||
"no_downloads": "No hi ha baixades disponibles"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Programes no instal·lats",
|
||||
"description": "No s'ha trobat els executables del Wine o el Lutris al sistema.",
|
||||
"instructions": "Comprova quina és la manera correcta d'instal·lar qualsevol d'ells en la teva distribució de Linux perquè el joc pugui executar-se amb normalitat."
|
||||
},
|
||||
"modal": {
|
||||
"close": "Botó de tancar"
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,8 @@
|
||||
"no_downloads_in_progress": "No downloads in progress",
|
||||
"downloading_metadata": "Downloading {{title}} metadata…",
|
||||
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Conclusion {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…"
|
||||
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
|
||||
"checking_files": "Checking {{title}} files… ({{percentage}} complete)"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Next page",
|
||||
@@ -144,7 +145,8 @@
|
||||
"downloads_completed": "Completed",
|
||||
"queued": "Queued",
|
||||
"no_downloads_title": "Such empty",
|
||||
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start."
|
||||
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start.",
|
||||
"checking_files": "Checking files…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Downloads path",
|
||||
@@ -192,12 +194,27 @@
|
||||
"found_download_option_other": "Found {{countFormatted}} download options",
|
||||
"import": "Import"
|
||||
},
|
||||
"collections": {
|
||||
"collections": "Collections",
|
||||
"add_the_game_to_the_collection": "Add the game to the collection",
|
||||
"select_a_collection": "Select a collection",
|
||||
"enter_the_name_of_the_collection": "Enter the name of the collection",
|
||||
"add": "Add",
|
||||
"remove": "Remove",
|
||||
"you_cant_give_collections_existing_or_empty_names": "You can`t give collections existing or empty names",
|
||||
"the_collection_has_been_added_successfully": "The collection has been added successfully",
|
||||
"the_collection_has_been_removed_successfully": "The collection has been removed successfully",
|
||||
"the_game_has_been_added_to_the_collection": "The game has been added to the collection",
|
||||
"the_game_has_been_removed_from_the_collection": "The game has been removed from the collection"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Download complete",
|
||||
"game_ready_to_install": "{{title}} is ready to install",
|
||||
"repack_list_updated": "Repack list updated",
|
||||
"repack_count_one": "{{count}} repack added",
|
||||
"repack_count_other": "{{count}} repacks added"
|
||||
"repack_count_other": "{{count}} repacks added",
|
||||
"new_update_available": "Version {{version}} available",
|
||||
"restart_to_install_update": "Restart Hydra to install the update"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Open Hydra",
|
||||
@@ -237,6 +254,15 @@
|
||||
"successfully_signed_out": "Successfully signed out",
|
||||
"sign_out": "Sign out",
|
||||
"playing_for": "Playing for {{amount}}",
|
||||
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?"
|
||||
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?",
|
||||
"add_friends": "Add Friends",
|
||||
"add": "Add",
|
||||
"friend_code": "Friend code",
|
||||
"see_profile": "See profile",
|
||||
"sending": "Sending",
|
||||
"friend_request_sent": "Friend request sent",
|
||||
"friends": "Friends",
|
||||
"friends_list": "Friends list",
|
||||
"user_not_found": "User not found"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": {
|
||||
"successfully_signed_in": "Successfully signed in (TRANSLATE ME)"
|
||||
"successfully_signed_in": "Sesión iniciada correctamente"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Destacado",
|
||||
@@ -20,7 +20,7 @@
|
||||
"home": "Inicio",
|
||||
"queued": "{{title}} (En Cola)",
|
||||
"game_has_no_executable": "El juego no tiene un ejecutable",
|
||||
"sign_in": "Sign in (TRANSLATE ME)"
|
||||
"sign_in": "Iniciar sesión"
|
||||
},
|
||||
"header": {
|
||||
"search": "Buscar juegos",
|
||||
@@ -36,7 +36,8 @@
|
||||
"no_downloads_in_progress": "Sin descargas en progreso",
|
||||
"downloading_metadata": "Descargando metadatos de {{title}}…",
|
||||
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…"
|
||||
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…",
|
||||
"checking_files": "Verificando archivos de {{title}}… ({{percentage}} completado)"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Siguiente página",
|
||||
@@ -47,7 +48,7 @@
|
||||
"download_options_zero": "No hay opciones de descargas disponibles",
|
||||
"download_options_one": "{{count}} opción de descarga",
|
||||
"download_options_other": "{{count}} opciones de descargas",
|
||||
"updated_at": "Actualizado el {{updated_at}}",
|
||||
"updated_at": "Actualizado el: {{updated_at}}",
|
||||
"install": "Instalar",
|
||||
"resume": "Continuar",
|
||||
"pause": "Pausa",
|
||||
@@ -73,7 +74,7 @@
|
||||
"remove_from_library": "Eliminar de la biblioteca",
|
||||
"no_downloads": "No hay descargas disponibles",
|
||||
"play_time": "Jugado por {{amount}}",
|
||||
"last_time_played": "Jugado por última vez {{period}}",
|
||||
"last_time_played": "Jugado por última vez: {{period}}",
|
||||
"not_played_yet": "Aún no has jugado a {{title}}",
|
||||
"next_suggestion": "Siguiente sugerencia",
|
||||
"play": "Jugar",
|
||||
@@ -92,7 +93,7 @@
|
||||
"screenshot": "Captura {{number}}",
|
||||
"open_screenshot": "Abrir captura {{number}}",
|
||||
"download_settings": "Ajustes de descarga",
|
||||
"downloader": "Descargador",
|
||||
"downloader": "Método de descarga",
|
||||
"select_executable": "Seleccionar",
|
||||
"no_executable_selected": "No se seleccionó un ejecutable",
|
||||
"open_folder": "Abrir carpeta",
|
||||
@@ -106,11 +107,13 @@
|
||||
"executable_section_description": "Ruta del archivo que se ejecutará cuando se presione \"Jugar\"",
|
||||
"downloads_secion_title": "Descargas",
|
||||
"downloads_section_description": "Buscar actualizaciones u otras versiones de este juego",
|
||||
"danger_zone_section_title": "Zona de Peligro",
|
||||
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra",
|
||||
"danger_zone_section_title": "Opciones Avanzadas",
|
||||
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra (Esto solo eliminará los archivos de instalación y no el juego instalado)",
|
||||
"download_in_progress": "Descarga en progreso",
|
||||
"download_paused": "Descarga pausada",
|
||||
"last_downloaded_option": "Última opción descargada"
|
||||
"last_downloaded_option": "Última opción descargada",
|
||||
"create_shortcut_success": "Atajo creado con éxito",
|
||||
"create_shortcut_error": "Error al crear un atajo"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activar Hydra",
|
||||
@@ -135,14 +138,15 @@
|
||||
"deleting": "Eliminando instalador…",
|
||||
"delete": "Eliminar instalador",
|
||||
"delete_modal_title": "¿Estás seguro?",
|
||||
"delete_modal_description": "Esto eliminará todos los archivos de instalación de tu computadora.",
|
||||
"delete_modal_description": "Esto eliminará todos los archivos de la instalación del repack del juego de tu computadora. (Si ya instalaste el juego, puedes eliminar esto, no afectará al juego)",
|
||||
"install": "Instalar",
|
||||
"download_in_progress": "En progreso",
|
||||
"queued_downloads": "Descargas en cola",
|
||||
"downloads_completed": "Completado",
|
||||
"queued": "En cola",
|
||||
"no_downloads_title": "Esto está tan... vacío",
|
||||
"no_downloads_description": "No has descargado nada con Hydra... aún, ¡pero nunca es tarde para comenzar!."
|
||||
"no_downloads_description": "No has descargado nada con Hydra... aún, ¡pero nunca es tarde para comenzar!.",
|
||||
"checking_files": "Verificando archivos…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Ruta de descarga",
|
||||
@@ -159,7 +163,7 @@
|
||||
"language": "Idioma",
|
||||
"real_debrid_api_token": "Token API",
|
||||
"enable_real_debrid": "Activar Real-Debrid",
|
||||
"real_debrid_description": "Real-Debrid es un descargador sin restricciones que te permite descargar archivos instantáneamente con la máxima velocidad de tu internet.",
|
||||
"real_debrid_description": "Real-Debrid es una forma de descargar sin restricciones archivos instantáneamente con la máxima velocidad de tu internet.",
|
||||
"real_debrid_invalid_token": "Token de API inválido",
|
||||
"real_debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>",
|
||||
"real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratuita. Por favor, suscríbete a Real-Debrid",
|
||||
@@ -183,19 +187,21 @@
|
||||
"sync_download_sources": "Sincronizar fuentes",
|
||||
"removed_download_source": "Fuente de descarga eliminada",
|
||||
"added_download_source": "Fuente de descarga añadida",
|
||||
"download_sources_synced": "Todas las fuentes de descarga estánn actualizadas (TRANSLATE ME)",
|
||||
"insert_valid_json_url": "Insert a valid JSON url (TRANSLATE ME)",
|
||||
"found_download_option_zero": "No download option found (TRANSLATE ME)",
|
||||
"found_download_option_one": "Found {{countFormatted}} download option (TRANSLATE ME)",
|
||||
"found_download_option_other": "Found {{countFormatted}} download options (TRANSLATE ME)",
|
||||
"import": "Import (TRANSLATE ME)"
|
||||
"download_sources_synced": "Todas las fuentes de descargas están actualizadas.",
|
||||
"insert_valid_json_url": "Introduce una URL JSON válida",
|
||||
"found_download_option_zero": "No se encontró una opción de descarga",
|
||||
"found_download_option_one": "Se encontró {{countFormatted}} opción de descarga",
|
||||
"found_download_option_other": "Se encontraron {{countFormatted}} opciones de descarga",
|
||||
"import": "Importar"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Descarga completada",
|
||||
"game_ready_to_install": "{{title}} está listo para instalarse",
|
||||
"repack_list_updated": "Lista de repacks actualizadas",
|
||||
"repack_count_one": "{{count}} repack ha sido añadido",
|
||||
"repack_count_other": "{{count}} repacks añadidos"
|
||||
"repack_count_other": "{{count}} repacks añadidos",
|
||||
"new_update_available": "Version {{version}} disponible",
|
||||
"restart_to_install_update": "Reinicia Hydra para instalar la actualización"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
@@ -216,25 +222,34 @@
|
||||
"toggle_password_visibility": "Cambiar visibilidad de contraseña"
|
||||
},
|
||||
"user_profile": {
|
||||
"amount_hours": "{{amount}} hours (TRANSLATE ME)",
|
||||
"amount_minutes": "{{amount}} minutes (TRANSLATE ME)",
|
||||
"last_time_played": "Last played {{period}} (TRANSLATE ME)",
|
||||
"activity": "Recent activity (TRANSLATE ME)",
|
||||
"library": "Library (TRANSLATE ME)",
|
||||
"total_play_time": "Total playtime: {{amount}} (TRANSLATE ME)",
|
||||
"no_recent_activity_title": "Hmmm… nothing here (TRANSLATE ME)",
|
||||
"no_recent_activity_description": "You haven't played any games recently. It's time to change that! (TRANSLATE ME)",
|
||||
"display_name": "Display name (TRANSLATE ME)",
|
||||
"saving": "Saving (TRANSLATE ME)",
|
||||
"save": "Save (TRANSLATE ME)",
|
||||
"edit_profile": "Edit Profile (TRANSLATE ME)",
|
||||
"saved_successfully": "Saved successfully (TRANSLATE ME)",
|
||||
"try_again": "Please, try again (TRANSLATE ME)",
|
||||
"sign_out_modal_title": "Are you sure? (TRANSLATE ME)",
|
||||
"cancel": "Cancel (TRANSLATE ME)",
|
||||
"successfully_signed_out": "Successfully signed out (TRANSLATE ME)",
|
||||
"sign_out": "Sign out (TRANSLATE ME)",
|
||||
"playing_for": "Playing for {{amount}} (TRANSLATE ME)",
|
||||
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out? (TRANSLATE ME)"
|
||||
"amount_hours": "{{amount}} horas",
|
||||
"amount_minutes": "{{amount}} minutos",
|
||||
"last_time_played": "Última vez jugado: {{period}}",
|
||||
"activity": "Actividad reciente",
|
||||
"library": "Biblioteca",
|
||||
"total_play_time": "Total de tiempo jugado: {{amount}}",
|
||||
"no_recent_activity_title": "Que raro, no hay nada por acá...",
|
||||
"no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!",
|
||||
"display_name": "Nombre en pantalla",
|
||||
"saving": "Guardando",
|
||||
"save": "Guardar",
|
||||
"edit_profile": "Editar perfil",
|
||||
"saved_successfully": "Guardado exitosamente",
|
||||
"try_again": "Por favor, intenta de nuevo",
|
||||
"sign_out_modal_title": "¿Estás seguro?",
|
||||
"cancel": "Cancelar",
|
||||
"successfully_signed_out": "Sesión cerrada exitosamente",
|
||||
"sign_out": "Cerrar sesión",
|
||||
"playing_for": "Jugando por {{amount}}",
|
||||
"sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?",
|
||||
"add_friends": "Añadir amigos",
|
||||
"add": "Añadir",
|
||||
"friend_code": "Código de amigo",
|
||||
"see_profile": "Ver perfil",
|
||||
"sending": "Enviando",
|
||||
"friend_request_sent": "Solicitud de amistad enviada",
|
||||
"friends": "Amigos",
|
||||
"friends_list": "Lista de amigos",
|
||||
"user_not_found": "Usuario no encontrado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,3 +16,6 @@ export { default as ko } from "./ko/translation.json";
|
||||
export { default as da } from "./da/translation.json";
|
||||
export { default as ar } from "./ar/translation.json";
|
||||
export { default as fa } from "./fa/translation.json";
|
||||
export { default as ro } from "./ro/translation.json";
|
||||
export { default as ca } from "./ca/translation.json";
|
||||
export { default as kk } from "./kk/translation.json";
|
||||
|
||||
242
src/locales/kk/translation.json
Normal file
242
src/locales/kk/translation.json
Normal file
@@ -0,0 +1,242 @@
|
||||
{
|
||||
"app": {
|
||||
"successfully_signed_in": "Сәтті кіру"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Ұсынылған",
|
||||
"trending": "Трендте",
|
||||
"surprise_me": "Таңқалдыр",
|
||||
"no_results": "Ештеңе табылмады"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Каталог",
|
||||
"downloads": "Жүктеулер",
|
||||
"settings": "Параметрлер",
|
||||
"my_library": "Кітапхана",
|
||||
"downloading_metadata": "{{title}} (Метадеректерді жүктеу…)",
|
||||
"paused": "{{title}} (Тоқтатылды)",
|
||||
"downloading": "{{title}} ({{percentage}} - Жүктеу…)",
|
||||
"filter": "Кітапхана фильтрі",
|
||||
"home": "Басты бет",
|
||||
"queued": "{{title}} (Кезекте)",
|
||||
"game_has_no_executable": "Ойынды іске қосу файлы таңдалмаған",
|
||||
"sign_in": "Кіру"
|
||||
},
|
||||
"header": {
|
||||
"search": "Іздеу",
|
||||
"home": "Басты бет",
|
||||
"catalogue": "Каталог",
|
||||
"downloads": "Жүктеулер",
|
||||
"search_results": "Іздеу нәтижелері",
|
||||
"settings": "Параметрлер",
|
||||
"version_available_install": "Қол жетімді нұсқа {{version}}. Қайта іске қосу және орнату үшін мұнда басыңыз.",
|
||||
"version_available_download": "Қол жетімді нұсқа {{version}}. Жүктеу үшін мұнда басыңыз."
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "Белсенді жүктеулер жоқ",
|
||||
"downloading_metadata": "Метадеректерді жүктеу {{title}}…",
|
||||
"downloading": "Жүктеу {{title}}… ({{percentage}} аяқталды) - Аяқтау {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Жүктеу {{title}}… ({{percentage}} аяқталды) - Қалған уақытты есептеу…"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Келесі бет",
|
||||
"previous_page": "Алдыңғы бет"
|
||||
},
|
||||
"game_details": {
|
||||
"open_download_options": "Жүктеу нұсқаларын ашу",
|
||||
"download_options_zero": "Жүктеу нұсқалары жоқ",
|
||||
"download_options_one": "{{count}} жүктеу нұсқасы",
|
||||
"download_options_other": "{{count}} жүктеу нұсқалары",
|
||||
"updated_at": "Жаңартылды {{updated_at}}",
|
||||
"install": "Орнату",
|
||||
"resume": "Жандандыру",
|
||||
"pause": "Тоқтату",
|
||||
"cancel": "Болдырмау",
|
||||
"remove": "Жою",
|
||||
"space_left_on_disk": "{{space}} бос орын",
|
||||
"eta": "Аяқтау {{eta}}",
|
||||
"calculating_eta": "Қалған уақытты есептеу…",
|
||||
"downloading_metadata": "Метадеректерді жүктеу…",
|
||||
"filter": "Репактар фильтрі",
|
||||
"requirements": "Жүйелік талаптар",
|
||||
"minimum": "Минималды",
|
||||
"recommended": "Ұсынылған",
|
||||
"paused": "Тоқтатылды",
|
||||
"release_date": "Шыққан күні {{date}}",
|
||||
"publisher": "Баспагер {{publisher}}",
|
||||
"hours": "сағат",
|
||||
"minutes": "минут",
|
||||
"amount_hours": "{{amount}} сағат",
|
||||
"amount_minutes": "{{amount}} минут",
|
||||
"accuracy": "дәлдік {{accuracy}}%",
|
||||
"add_to_library": "Кітапханаға қосу",
|
||||
"remove_from_library": "Кітапханадан жою",
|
||||
"no_downloads": "Жүктеулер жоқ",
|
||||
"play_time": "Ойнау уақыты {{amount}}",
|
||||
"last_time_played": "Соңғы ойнаған уақыт {{period}}",
|
||||
"not_played_yet": "Сіз {{title}} ойнамағансыз",
|
||||
"next_suggestion": "Келесі ұсыныс",
|
||||
"play": "Ойнау",
|
||||
"deleting": "Орнатушыны жою…",
|
||||
"close": "Жабу",
|
||||
"playing_now": "Қазір ойнап жатыр",
|
||||
"change": "Өзгерту",
|
||||
"repacks_modal_description": "Жүктеу үшін репакты таңдаңыз",
|
||||
"select_folder_hint": "Әдепкі жүктеу қалтасын өзгерту үшін <0>Параметрлер</0> ашыңыз",
|
||||
"download_now": "Қазір жүктеу",
|
||||
"no_shop_details": "Сипаттаманы алу мүмкін болмады",
|
||||
"download_options": "Жүктеу нұсқалары",
|
||||
"download_path": "Жүктеу жолы",
|
||||
"previous_screenshot": "Алдыңғы скриншот",
|
||||
"next_screenshot": "Келесі скриншот",
|
||||
"screenshot": "Скриншот {{number}}",
|
||||
"open_screenshot": "Скриншотты ашу {{number}}",
|
||||
"download_settings": "Жүктеу параметрлері",
|
||||
"downloader": "Жүктегіш",
|
||||
"select_executable": "Таңдау",
|
||||
"no_executable_selected": "Файл таңдалмаған",
|
||||
"open_folder": "Қалтаны ашу",
|
||||
"open_download_location": "Жүктеу қалтасын қарау",
|
||||
"create_shortcut": "Жұмыс үстелінде жарлық жасау",
|
||||
"remove_files": "Файлдарды жою",
|
||||
"remove_from_library_title": "Сіз сенімдісіз бе?",
|
||||
"remove_from_library_description": "{{game}} сіздің кітапханаңыздан жойылады.",
|
||||
"options": "Параметрлер",
|
||||
"executable_section_title": "Файл",
|
||||
"executable_section_description": "\"Ойнау\" батырмасын басқанда іске қосылатын файл жолы",
|
||||
"downloads_secion_title": "Жүктеулер",
|
||||
"downloads_section_description": "Ойынның жаңартулары немесе басқа нұсқалары бар-жоғын тексеру",
|
||||
"danger_zone_section_title": "Қауіпті аймақ",
|
||||
"danger_zone_section_description": "Осы ойынды кітапханаңыздан жою немесе Hydra жүктеген файлдарды жою",
|
||||
"download_in_progress": "Жүктеу жүріп жатыр",
|
||||
"download_paused": "Жүктеу тоқтатылды",
|
||||
"last_downloaded_option": "Соңғы жүктеу нұсқасы",
|
||||
"create_shortcut_success": "Жарлық жасалды",
|
||||
"create_shortcut_error": "Жарлық жасау мүмкін болмады"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Hydra-ны белсендіру",
|
||||
"installation_id": "Орнату ID:",
|
||||
"enter_activation_code": "Активтендіру кодын енгізіңіз",
|
||||
"message": "Егер оның қайдан алуға болатынын білмесеңіз, сізде оның болмауы керек.",
|
||||
"activate": "Белсендіру",
|
||||
"loading": "Жүктеу…"
|
||||
},
|
||||
"downloads": {
|
||||
"resume": "Жандандыру",
|
||||
"pause": "Тоқтату",
|
||||
"eta": "Аяқтау {{eta}}",
|
||||
"paused": "Тоқтатылды",
|
||||
"verifying": "Тексеру…",
|
||||
"completed": "Аяқталды",
|
||||
"removed": "Жүктелмеген",
|
||||
"cancel": "Болдырмау",
|
||||
"filter": "Жүктелген ойындар фильтрі",
|
||||
"remove": "Жою",
|
||||
"downloading_metadata": "Метадеректерді жүктеу…",
|
||||
"deleting": "Орнатушыны жою…",
|
||||
"delete": "Орнатушыны жою",
|
||||
"delete_modal_title": "Сіз сенімдісіз бе?",
|
||||
"delete_modal_description": "Бұл барлық орнатушыларды компьютеріңізден жояды",
|
||||
"install": "Орнату",
|
||||
"download_in_progress": "Жүктеу жүріп жатыр",
|
||||
"queued_downloads": "Кезектегі жүктеулер",
|
||||
"downloads_completed": "Аяқталды",
|
||||
"queued": "Кезекте",
|
||||
"no_downloads_title": "Мұнда бос...",
|
||||
"no_downloads_description": "Сіз Hydra арқылы әлі ештеңе жүктемегенсіз, бірақ бастау ешқашан кеш емес."
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Жүктеу жолы",
|
||||
"change": "Өзгерту",
|
||||
"notifications": "Хабарламалар",
|
||||
"enable_download_notifications": "Жүктеу аяқталғанда",
|
||||
"enable_repack_list_notifications": "Жаңа репак қосылғанда",
|
||||
"real_debrid_api_token_label": "Real-Debrid API-токен",
|
||||
"quit_app_instead_hiding": "Hydra-ны трейге жасырудың орнына жабу",
|
||||
"launch_with_system": "Жүйемен бірге Hydra-ны іске қосу",
|
||||
"general": "Жалпы",
|
||||
"behavior": "Мінез-құлық",
|
||||
"download_sources": "Жүктеу көздері",
|
||||
"language": "Тіл",
|
||||
"real_debrid_api_token": "API Кілті",
|
||||
"enable_real_debrid": "Real-Debrid-ті қосу",
|
||||
"real_debrid_description": "Real-Debrid - бұл шектеусіз жүктеуші, ол интернетте орналастырылған файлдарды тез жүктеуге немесе жеке желі арқылы кез келген блоктарды айналып өтіп, оларды бірден плеерге беруге мүмкіндік береді.",
|
||||
"real_debrid_invalid_token": "Қате API кілті",
|
||||
"real_debrid_api_token_hint": "API кілтін <0>осы жерден</0> алуға болады",
|
||||
"real_debrid_free_account_error": "\"{{username}}\" аккаунты жазылымға ие емес. Real-Debrid жазылымын алыңыз",
|
||||
"real_debrid_linked_message": "\"{{username}}\" аккаунты байланған",
|
||||
"save_changes": "Өзгерістерді сақтау",
|
||||
"changes_saved": "Өзгерістер сәтті сақталды",
|
||||
"download_sources_description": "Hydra осы көздерден жүктеу сілтемелерін алады. URL-да жүктеу сілтемелері бар .json файлына тікелей сілтеме болуы керек.",
|
||||
"validate_download_source": "Тексеру",
|
||||
"remove_download_source": "Жою",
|
||||
"add_download_source": "Жүктеу көзін қосу",
|
||||
"download_count_zero": "Жүктеулер тізімінде жоқ",
|
||||
"download_count_one": "{{countFormatted}} жүктеу тізімде",
|
||||
"download_count_other": "{{countFormatted}} жүктеу тізімде",
|
||||
"download_options_zero": "Қолжетімді жүктеулер жоқ",
|
||||
"download_options_one": "{{countFormatted}} жүктеу нұсқасы қол жетімді",
|
||||
"download_options_other": "{{countFormatted}} жүктеу нұсқалары қол жетімді",
|
||||
"download_source_url": "Көздің сілтемесі",
|
||||
"add_download_source_description": ".json файлға сілтемені қойыңыз",
|
||||
"download_source_up_to_date": "Жаңартылған",
|
||||
"download_source_errored": "Қате",
|
||||
"sync_download_sources": "Көздерді синхрондау",
|
||||
"removed_download_source": "Жүктеу көзі жойылды",
|
||||
"added_download_source": "Жүктеу көзі қосылды",
|
||||
"download_sources_synced": "Барлық жүктеу көздері синхрондалды",
|
||||
"insert_valid_json_url": "Жарамды JSON URL енгізіңіз",
|
||||
"found_download_option_zero": "Жүктеу нұсқалары табылмады",
|
||||
"found_download_option_one": "{{countFormatted}} жүктеу нұсқасы табылды",
|
||||
"found_download_option_other": "{{countFormatted}} жүктеу нұсқалары табылды",
|
||||
"import": "Импорттау"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Жүктеу аяқталды",
|
||||
"game_ready_to_install": "{{title}} орнатуға дайын",
|
||||
"repack_list_updated": "Репактар тізімі жаңартылды",
|
||||
"repack_count_one": "{{count}} репак қосылды",
|
||||
"repack_count_other": "{{count}} репактар қосылды"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Hydra-ны ашу",
|
||||
"quit": "Шығу"
|
||||
},
|
||||
"game_card": {
|
||||
"no_downloads": "Жүктеулер жоқ"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Бағдарламалар орнатылмаған",
|
||||
"description": "Wine немесе Lutris табылмады",
|
||||
"instructions": "Linux дистрибутивіңізге олардың кез келгенін дұрыс орнатудың жолын біліңіз осылайша ойын дұрыс жұмыс істей алады"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Жабу"
|
||||
},
|
||||
"forms": {
|
||||
"toggle_password_visibility": "Құпиясөзді көрсету"
|
||||
},
|
||||
"user_profile": {
|
||||
"amount_hours": "{{amount}} сағат",
|
||||
"amount_minutes": "{{amount}} минут",
|
||||
"last_time_played": "Соңғы ойын {{period}}",
|
||||
"activity": "Соңғы әрекет",
|
||||
"library": "Кітапхана",
|
||||
"total_play_time": "Барлығы ойнаған: {{amount}}",
|
||||
"no_recent_activity_title": "Хммм... Мұнда ештеңе жоқ",
|
||||
"no_recent_activity_description": "Сіз ұзақ уақыт бойы ештеңе ойнаған жоқсыз. Мұны өзгерту керек!",
|
||||
"display_name": "Көрсету аты",
|
||||
"saving": "Сақтау",
|
||||
"save": "Сақталды",
|
||||
"edit_profile": "Профильді өзгерту",
|
||||
"saved_successfully": "Сәтті сақталды",
|
||||
"try_again": "Қайта көріңіз",
|
||||
"sign_out_modal_title": "Сіз сенімдісіз бе?",
|
||||
"cancel": "Болдырмау",
|
||||
"successfully_signed_out": "Аккаунттан сәтті шығу",
|
||||
"sign_out": "Шығу",
|
||||
"playing_for": "Ойнаған {{amount}}",
|
||||
"sign_out_modal_text": "Сіздің кітапханаңыз ағымдағы аккаунтпен байланысты. Жүйеден шыққанда сіздің кітапханаңыз қол жетімсіз болады және прогресс сақталмайды. Шығу?"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"app": {
|
||||
"successfully_signed_in": "Logado com sucesso"
|
||||
"successfully_signed_in": "Autenticado com sucesso"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Destaque",
|
||||
"featured": "Destaques",
|
||||
"trending": "Populares",
|
||||
"surprise_me": "Surpreenda-me",
|
||||
"no_results": "Nenhum resultado encontrado"
|
||||
@@ -12,11 +12,11 @@
|
||||
"catalogue": "Catálogo",
|
||||
"downloads": "Downloads",
|
||||
"settings": "Ajustes",
|
||||
"my_library": "Minha biblioteca",
|
||||
"my_library": "Biblioteca",
|
||||
"downloading_metadata": "{{title}} (Baixando metadados…)",
|
||||
"paused": "{{title}} (Pausado)",
|
||||
"downloading": "{{title}} ({{percentage}} - Baixando…)",
|
||||
"filter": "Filtrar biblioteca",
|
||||
"filter": "Buscar",
|
||||
"home": "Início",
|
||||
"queued": "{{title}} (Na fila)",
|
||||
"game_has_no_executable": "Jogo não possui executável selecionado",
|
||||
@@ -36,7 +36,8 @@
|
||||
"no_downloads_in_progress": "Sem downloads em andamento",
|
||||
"downloading_metadata": "Baixando metadados de {{title}}…",
|
||||
"downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…"
|
||||
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…",
|
||||
"checking_files": "Verificando arquivos de {{title}}…"
|
||||
},
|
||||
"game_details": {
|
||||
"open_download_options": "Ver opções de download",
|
||||
@@ -44,7 +45,7 @@
|
||||
"download_options_one": "{{count}} opção de download",
|
||||
"download_options_other": "{{count}} opções de download",
|
||||
"updated_at": "Atualizado {{updated_at}}",
|
||||
"resume": "Resumir",
|
||||
"resume": "Retomar",
|
||||
"pause": "Pausar",
|
||||
"cancel": "Cancelar",
|
||||
"remove": "Remover",
|
||||
@@ -53,7 +54,7 @@
|
||||
"calculating_eta": "Calculando tempo restante…",
|
||||
"downloading_metadata": "Baixando metadados…",
|
||||
"filter": "Filtrar repacks",
|
||||
"requirements": "Requisitos do sistema",
|
||||
"requirements": "Requisitos de sistema",
|
||||
"minimum": "Mínimos",
|
||||
"recommended": "Recomendados",
|
||||
"paused": "Pausado",
|
||||
@@ -67,16 +68,16 @@
|
||||
"add_to_library": "Adicionar à biblioteca",
|
||||
"remove_from_library": "Remover da biblioteca",
|
||||
"no_downloads": "Nenhum download disponível",
|
||||
"play_time": "Jogado por {{amount}}",
|
||||
"play_time": "Jogou por {{amount}}",
|
||||
"next_suggestion": "Próxima sugestão",
|
||||
"install": "Instalar",
|
||||
"last_time_played": "Jogou por último {{period}}",
|
||||
"last_time_played": "Última sessão {{period}}",
|
||||
"play": "Jogar",
|
||||
"not_played_yet": "Você ainda não jogou {{title}}",
|
||||
"close": "Fechar",
|
||||
"deleting": "Excluindo instalador…",
|
||||
"playing_now": "Jogando agora",
|
||||
"change": "Mudar",
|
||||
"change": "Explorar",
|
||||
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
|
||||
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
|
||||
"download_now": "Iniciar download",
|
||||
@@ -89,13 +90,13 @@
|
||||
"open_screenshot": "Ver captura de tela {{number}}",
|
||||
"download_settings": "Ajustes do download",
|
||||
"downloader": "Downloader",
|
||||
"select_executable": "Selecionar",
|
||||
"select_executable": "Explorar",
|
||||
"no_executable_selected": "Nenhum executável selecionado",
|
||||
"open_folder": "Abrir pasta",
|
||||
"open_download_location": "Ver arquivos baixados",
|
||||
"create_shortcut": "Criar atalho na área de trabalho",
|
||||
"remove_files": "Remover arquivos",
|
||||
"options": "Opções",
|
||||
"options": "Gerenciar",
|
||||
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
|
||||
"remove_from_library_title": "Tem certeza?",
|
||||
"executable_section_title": "Executável",
|
||||
@@ -119,7 +120,7 @@
|
||||
"loading": "Carregando…"
|
||||
},
|
||||
"downloads": {
|
||||
"resume": "Resumir",
|
||||
"resume": "Retomar",
|
||||
"pause": "Pausar",
|
||||
"eta": "Conclusão {{eta}}",
|
||||
"paused": "Pausado",
|
||||
@@ -140,16 +141,17 @@
|
||||
"downloads_completed": "Completo",
|
||||
"queued": "Na fila",
|
||||
"no_downloads_title": "Nada por aqui…",
|
||||
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar."
|
||||
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar.",
|
||||
"checking_files": "Verificando arquivos…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Diretório dos downloads",
|
||||
"change": "Mudar",
|
||||
"change": "Explorar...",
|
||||
"notifications": "Notificações",
|
||||
"enable_download_notifications": "Quando um download for concluído",
|
||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra ao invés de minimizá-lo ao fechar",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
|
||||
"launch_with_system": "Iniciar o Hydra junto com o sistema",
|
||||
"general": "Geral",
|
||||
"behavior": "Comportamento",
|
||||
@@ -193,7 +195,9 @@
|
||||
"game_ready_to_install": "{{title}} está pronto para ser instalado",
|
||||
"repack_list_updated": "Lista de repacks atualizada",
|
||||
"repack_count_one": "{{count}} novo repack",
|
||||
"repack_count_other": "{{count}} novos repacks"
|
||||
"repack_count_other": "{{count}} novos repacks",
|
||||
"new_update_available": "Versão {{version}} disponível",
|
||||
"restart_to_install_update": "Reinicie o Hydra para instalar a nova versão"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
@@ -204,7 +208,7 @@
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Programas não instalados",
|
||||
"description": "Não foram encontrados no seu sistema os executáveis do Wine ou Lutris",
|
||||
"description": "Os executáveis do Wine ou Lutris não foram encontrados em seu sistema.",
|
||||
"instructions": "Verifique a forma correta de instalar algum deles no seu distro Linux, garantindo assim a execução normal do jogo"
|
||||
},
|
||||
"catalogue": {
|
||||
@@ -220,8 +224,8 @@
|
||||
"user_profile": {
|
||||
"amount_hours": "{{amount}} horas",
|
||||
"amount_minutes": "{{amount}} minutos",
|
||||
"last_time_played": "Jogou {{period}}",
|
||||
"activity": "Atividade recente",
|
||||
"last_time_played": "Última sessão {{period}}",
|
||||
"activity": "Atividades recentes",
|
||||
"library": "Biblioteca",
|
||||
"total_play_time": "Tempo total de jogo: {{amount}}",
|
||||
"no_recent_activity_title": "Hmmm… nada por aqui",
|
||||
@@ -229,7 +233,7 @@
|
||||
"display_name": "Nome de exibição",
|
||||
"saving": "Salvando…",
|
||||
"save": "Salvar",
|
||||
"edit_profile": "Editar Perfil",
|
||||
"edit_profile": "Editar perfil",
|
||||
"saved_successfully": "Salvo com sucesso",
|
||||
"try_again": "Por favor, tente novamente",
|
||||
"cancel": "Cancelar",
|
||||
@@ -237,6 +241,15 @@
|
||||
"sign_out": "Sair da conta",
|
||||
"sign_out_modal_title": "Tem certeza?",
|
||||
"playing_for": "Jogando por {{amount}}",
|
||||
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?"
|
||||
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?",
|
||||
"add_friends": "Adicionar Amigos",
|
||||
"friend_code": "Código de amigo",
|
||||
"see_profile": "Ver perfil",
|
||||
"friend_request_sent": "Pedido de amizade enviado",
|
||||
"friends": "Amigos",
|
||||
"add": "Adicionar",
|
||||
"sending": "Enviando",
|
||||
"friends_list": "Lista de amigos",
|
||||
"user_not_found": "Usuário não encontrado"
|
||||
}
|
||||
}
|
||||
|
||||
159
src/locales/ro/translation.json
Normal file
159
src/locales/ro/translation.json
Normal file
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"home": {
|
||||
"featured": "Recomandate",
|
||||
"trending": "Populare",
|
||||
"surprise_me": "Surprinde-mă",
|
||||
"no_results": "Niciun rezultat găsit"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Catalog",
|
||||
"downloads": "Descărcări",
|
||||
"settings": "Setări",
|
||||
"my_library": "Biblioteca mea",
|
||||
"downloading_metadata": "{{title}} (Se descarcă metadata...)",
|
||||
"paused": "{{title}} (Pauzat)",
|
||||
"downloading": "{{title}} ({{percentage}} - Se descarcă...)",
|
||||
"filter": "Filtrează biblioteca",
|
||||
"home": "Acasă"
|
||||
},
|
||||
"header": {
|
||||
"search": "Caută jocuri",
|
||||
"home": "Acasă",
|
||||
"catalogue": "Catalog",
|
||||
"downloads": "Descărcări",
|
||||
"search_results": "Rezultatele căutării",
|
||||
"settings": "Setări"
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "Nicio descărcare în curs",
|
||||
"downloading_metadata": "Se descarcă metadata pentru {{title}}...",
|
||||
"downloading": "Se descarcă {{title}}... ({{percentage}} complet) - Concluzie {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Se descarcă {{title}}... ({{percentage}} complet) - Calculare timp rămas..."
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Pagina următoare",
|
||||
"previous_page": "Pagina anterioară"
|
||||
},
|
||||
"game_details": {
|
||||
"open_download_options": "Deschide opțiunile de descărcare",
|
||||
"download_options_zero": "Nicio opțiune de descărcare",
|
||||
"download_options_one": "{{count}} opțiune de descărcare",
|
||||
"download_options_other": "{{count}} opțiuni de descărcare",
|
||||
"updated_at": "Actualizat la {{updated_at}}",
|
||||
"install": "Instalează",
|
||||
"resume": "Reia",
|
||||
"pause": "Pauză",
|
||||
"cancel": "Anulează",
|
||||
"remove": "Elimină",
|
||||
"space_left_on_disk": "{{space}} liber pe disc",
|
||||
"eta": "Concluzie {{eta}}",
|
||||
"calculating_eta": "Calculare timp rămas...",
|
||||
"downloading_metadata": "Se descarcă metadata...",
|
||||
"filter": "Filtrează repack-urile",
|
||||
"requirements": "Cerințe de sistem",
|
||||
"minimum": "Minim",
|
||||
"recommended": "Recomandat",
|
||||
"paused": "Pauzat",
|
||||
"release_date": "Lansat pe {{date}}",
|
||||
"publisher": "Publicat de {{publisher}}",
|
||||
"hours": "ore",
|
||||
"minutes": "minute",
|
||||
"amount_hours": "{{amount}} ore",
|
||||
"amount_minutes": "{{amount}} minute",
|
||||
"accuracy": "{{accuracy}}% acuratețe",
|
||||
"add_to_library": "Adaugă în bibliotecă",
|
||||
"remove_from_library": "Elimină din bibliotecă",
|
||||
"no_downloads": "Nicio descărcare disponibilă",
|
||||
"play_time": "Jucat timp de {{amount}}",
|
||||
"last_time_played": "Ultima dată jucat {{period}}",
|
||||
"not_played_yet": "Nu ai jucat încă {{title}}",
|
||||
"next_suggestion": "Sugestia următoare",
|
||||
"play": "Joacă",
|
||||
"deleting": "Se șterge programul de instalare...",
|
||||
"close": "Închide",
|
||||
"playing_now": "Se joacă acum",
|
||||
"change": "Schimbă",
|
||||
"repacks_modal_description": "Alege repack-ul pe care vrei să-l descarci",
|
||||
"select_folder_hint": "Pentru a schimba folderul predefinit, mergi la <0>Setări</0>",
|
||||
"download_now": "Descarcă acum",
|
||||
"no_shop_details": "Nu s-au putut obține detalii din magazin.",
|
||||
"download_options": "Opțiuni de descărcare",
|
||||
"download_path": "Locația de descărcare",
|
||||
"previous_screenshot": "Captura de ecran anterioară",
|
||||
"next_screenshot": "Captura de ecran următoare",
|
||||
"screenshot": "Captură de ecran {{number}}",
|
||||
"open_screenshot": "Deschide captura de ecran {{number}}",
|
||||
"download_settings": "Setări de descărcare",
|
||||
"downloader": "Program de descărcare"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activează Hydra",
|
||||
"installation_id": "ID-ul de instalare:",
|
||||
"enter_activation_code": "Introdu codul de activare",
|
||||
"message": "Dacă nu știi de unde să ceri acest lucru, atunci nu ar trebui să-l ai.",
|
||||
"activate": "Activează",
|
||||
"loading": "Se încarcă..."
|
||||
},
|
||||
"downloads": {
|
||||
"resume": "Reia",
|
||||
"pause": "Pauză",
|
||||
"eta": "Concluzie {{eta}}",
|
||||
"paused": "Pauzat",
|
||||
"verifying": "Se verifică...",
|
||||
"completed": "Completat",
|
||||
"removed": "Nu este descărcat",
|
||||
"cancel": "Anulează",
|
||||
"filter": "Filtrează jocurile descărcate",
|
||||
"remove": "Elimină",
|
||||
"downloading_metadata": "Se descarcă metadata...",
|
||||
"deleting": "Se șterge programul de instalare...",
|
||||
"delete": "Elimină programul de instalare",
|
||||
"delete_modal_title": "Ești sigur?",
|
||||
"delete_modal_description": "Aceasta va elimina toate fișierele de instalare de pe computer",
|
||||
"install": "Instalează"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Locația de descărcare",
|
||||
"change": "Actualizează",
|
||||
"notifications": "Notificări",
|
||||
"enable_download_notifications": "Când o descărcare este completă",
|
||||
"enable_repack_list_notifications": "Când un nou repack este adăugat",
|
||||
"real_debrid_api_token_label": "Token API Real-Debrid",
|
||||
"quit_app_instead_hiding": "Nu ascunde Hydra la închidere",
|
||||
"launch_with_system": "Lansează Hydra la pornirea sistemului",
|
||||
"general": "General",
|
||||
"behavior": "Comportament",
|
||||
"language": "Limbă",
|
||||
"real_debrid_api_token": "Token API",
|
||||
"enable_real_debrid": "Activează Real-Debrid",
|
||||
"real_debrid_description": "Real-Debrid este un descărcător fără restricții care îți permite să descarci fișiere instantaneu și la cea mai bună viteză a internetului tău.",
|
||||
"real_debrid_invalid_token": "Token API invalid",
|
||||
"real_debrid_api_token_hint": "Poți obține token-ul tău API <0>aici</0>",
|
||||
"real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid",
|
||||
"real_debrid_linked_message": "Contul \"{{username}}\" a fost legat",
|
||||
"save_changes": "Salvează modificările",
|
||||
"changes_saved": "Modificările au fost salvate cu succes"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Descărcare completă",
|
||||
"game_ready_to_install": "{{title}} este gata de instalare",
|
||||
"repack_list_updated": "Lista de repack-uri a fost actualizată",
|
||||
"repack_count_one": "{{count}} repack adăugat",
|
||||
"repack_count_other": "{{count}} repack-uri adăugate"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Deschide Hydra",
|
||||
"quit": "Ieși"
|
||||
},
|
||||
"game_card": {
|
||||
"no_downloads": "Nicio descărcare disponibilă"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Programele nu sunt instalate",
|
||||
"description": "Fișierele executabile Wine sau Lutris nu au fost găsite pe sistemul tău",
|
||||
"instructions": "Verifică modul corect de instalare a oricăruia dintre acestea pe distribuția ta Linux pentru ca jocul să ruleze normal"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Buton de închidere"
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,8 @@
|
||||
"no_downloads_in_progress": "Нет активных загрузок",
|
||||
"downloading_metadata": "Загрузка метаданных {{title}}…",
|
||||
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…"
|
||||
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…",
|
||||
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Следующая страница",
|
||||
@@ -144,7 +145,8 @@
|
||||
"downloads_completed": "Завершено",
|
||||
"queued": "В очереди",
|
||||
"no_downloads_title": "Здесь так пусто...",
|
||||
"no_downloads_description": "Вы ещё ничего не скачали через Hydra, но никогда не поздно начать."
|
||||
"no_downloads_description": "Вы ещё ничего не скачали через Hydra, но никогда не поздно начать.",
|
||||
"checking_files": "Проверка файлов…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Путь загрузок",
|
||||
@@ -153,11 +155,11 @@
|
||||
"enable_download_notifications": "По завершении загрузки",
|
||||
"enable_repack_list_notifications": "При добавлении нового репака",
|
||||
"real_debrid_api_token_label": "Real-Debrid API-токен",
|
||||
"quit_app_instead_hiding": "Закрывать Hydra вместо того, чтобы сворачивать его в трей",
|
||||
"launch_with_system": "Запуск Hydra вместе с системой",
|
||||
"quit_app_instead_hiding": "Закрывать приложение вместо сворачивания в трей",
|
||||
"launch_with_system": "Запускать Hydra вместе с системой",
|
||||
"general": "Основные",
|
||||
"behavior": "Поведение",
|
||||
"download_sources": "Скачать исходный код",
|
||||
"download_sources": "Источники загрузки",
|
||||
"language": "Язык",
|
||||
"real_debrid_api_token": "API Ключ",
|
||||
"enable_real_debrid": "Включить Real-Debrid",
|
||||
@@ -192,12 +194,27 @@
|
||||
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
|
||||
"import": "Импортировать"
|
||||
},
|
||||
"collections": {
|
||||
"collections": "Коллекции",
|
||||
"add_the_game_to_the_collection": "Добавьте игру в коллекцию",
|
||||
"select_a_collection": "Выберите коллекцию",
|
||||
"enter_the_name_of_the_collection": "Введите название коллекции",
|
||||
"add": "Добавить",
|
||||
"remove": "Удалить",
|
||||
"you_cant_give_collections_existing_or_empty_names": "Нельзя давать коллекциям существующие или пустые названия",
|
||||
"the_collection_has_been_added_successfully": "Коллекция успешно добавлена",
|
||||
"the_collection_has_been_removed_successfully": "Коллекция успешно удалена",
|
||||
"the_game_has_been_added_to_the_collection": "Игра добавлена в коллекцию",
|
||||
"the_game_has_been_removed_from_the_collection": "Игра удалена из коллекции"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Загрузка завершена",
|
||||
"game_ready_to_install": "{{title}} готова к установке",
|
||||
"repack_list_updated": "Список репаков обновлен",
|
||||
"repack_count_one": "{{count}} репак добавлен",
|
||||
"repack_count_other": "{{count}} репаков добавлено"
|
||||
"repack_count_other": "{{count}} репаков добавлено",
|
||||
"new_update_available": "Доступна версия {{version}}",
|
||||
"restart_to_install_update": "Перезапустите Hydra для установки обновления"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Открыть Hydra",
|
||||
@@ -228,7 +245,7 @@
|
||||
"no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!",
|
||||
"display_name": "Отображаемое имя",
|
||||
"saving": "Сохранение",
|
||||
"save": "Сохранено",
|
||||
"save": "Сохранить",
|
||||
"edit_profile": "Редактировать Профиль",
|
||||
"saved_successfully": "Успешно сохранено",
|
||||
"try_again": "Пожалуйста, попробуйте ещё раз",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"app": {
|
||||
"successfully_signed_in": "已成功登录"
|
||||
},
|
||||
"home": {
|
||||
"featured": "特色推荐",
|
||||
"trending": "最近热门",
|
||||
@@ -14,20 +17,26 @@
|
||||
"paused": "{{title}} (已暂停)",
|
||||
"downloading": "{{title}} ({{percentage}} - 正在下载…)",
|
||||
"filter": "筛选游戏库",
|
||||
"home": "主页"
|
||||
"home": "主页",
|
||||
"queued": "{{title}} (已加入下载队列)",
|
||||
"game_has_no_executable": "未选择游戏的可执行文件",
|
||||
"sign_in": "登入"
|
||||
},
|
||||
"header": {
|
||||
"search": "搜索",
|
||||
"search": "搜索游戏",
|
||||
"home": "主页",
|
||||
"catalogue": "游戏目录",
|
||||
"downloads": "下载中心",
|
||||
"search_results": "搜索结果",
|
||||
"settings": "设置"
|
||||
"settings": "设置",
|
||||
"version_available_install": "版本 {{version}} 已可用. 点击此处重新启动并安装.",
|
||||
"version_available_download": "版本 {{version}} 可用. 点击此处下载."
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "没有正在进行的下载",
|
||||
"downloading_metadata": "正在下载{{title}}的元数据…",
|
||||
"downloading": "正在下载{{title}}… ({{percentage}}完成) - 剩余时间{{eta}} - 速度{{speed}}"
|
||||
"downloading": "正在下载{{title}}… ({{percentage}}完成) - 剩余时间{{eta}} - 速度{{speed}}",
|
||||
"calculating_eta": "正在下载 {{title}}… (已完成{{percentage}}.) - 正在计算剩余时间..."
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "下一页",
|
||||
@@ -76,7 +85,29 @@
|
||||
"previous_screenshot": "上一张截图",
|
||||
"next_screenshot": "下一张截图",
|
||||
"screenshot": "截图 {{number}}",
|
||||
"open_screenshot": "打开截图 {{number}}"
|
||||
"open_screenshot": "打开截图 {{number}}",
|
||||
"download_settings": "下载设置",
|
||||
"downloader": "下载器",
|
||||
"select_executable": "选择",
|
||||
"no_executable_selected": "没有可执行文件被指定",
|
||||
"open_folder": "打开目录",
|
||||
"open_download_location": "查看已下载的文件",
|
||||
"create_shortcut": "创建桌面快捷方式",
|
||||
"remove_files": "删除文件",
|
||||
"remove_from_library_title": "你确定吗?",
|
||||
"remove_from_library_description": "这将会把 {{game}} 从你的库中移除",
|
||||
"options": "选项",
|
||||
"executable_section_title": "可执行文件",
|
||||
"executable_section_description": "点击 \"Play\" 时将执行的文件的路径",
|
||||
"downloads_secion_title": "下载",
|
||||
"downloads_section_description": "查看此游戏的更新或其他版本",
|
||||
"danger_zone_section_title": "危险操作",
|
||||
"danger_zone_section_description": "从您的库或Hydra下载的文件中删除此游戏",
|
||||
"download_in_progress": "下载进行中",
|
||||
"download_paused": "下载暂停",
|
||||
"last_downloaded_option": "上次下载的选项",
|
||||
"create_shortcut_success": "成功创建快捷方式",
|
||||
"create_shortcut_error": "创建快捷方式出错"
|
||||
},
|
||||
"activation": {
|
||||
"title": "激活 Hydra",
|
||||
@@ -101,7 +132,13 @@
|
||||
"delete": "移除安装程序",
|
||||
"delete_modal_title": "您确定吗?",
|
||||
"delete_modal_description": "这将从您的电脑上移除所有的安装文件",
|
||||
"install": "安装"
|
||||
"install": "安装",
|
||||
"download_in_progress": "进行中",
|
||||
"queued_downloads": "在队列中的下载",
|
||||
"downloads_completed": "已完成",
|
||||
"queued": "下载列表",
|
||||
"no_downloads_title": "空空如也",
|
||||
"no_downloads_description": "你还未使用Hydra下载任何游戏,但什么时候开始,都为时不晚。"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "下载路径",
|
||||
@@ -109,34 +146,72 @@
|
||||
"notifications": "通知",
|
||||
"enable_download_notifications": "下载完成时",
|
||||
"enable_repack_list_notifications": "添加新重打包时",
|
||||
"real_debrid_api_token_label": "Real-Debrid API 令牌",
|
||||
"quit_app_instead_hiding": "关闭Hydra而不是最小化到托盘",
|
||||
"launch_with_system": "系统启动时运行 Hydra",
|
||||
"general": "通用",
|
||||
"behavior": "行为",
|
||||
"general": "常规",
|
||||
"quit_app_instead_hiding": "关闭应用程序而不是最小化到托盘",
|
||||
"launch_with_system": "随系统启动时运行应用程序",
|
||||
"download_sources": "下载源",
|
||||
"language": "语言",
|
||||
"real_debrid_api_token": "API 令牌",
|
||||
"enable_real_debrid": "启用 Real-Debrid",
|
||||
"real_debrid_description": "Real-Debrid 是一个无限制的下载器,允许您以最快的互联网速度即时下载文件。",
|
||||
"real_debrid_invalid_token": "无效的 API 令牌",
|
||||
"real_debrid_api_token_hint": "您可以从<0>这里</0>获取API密钥.",
|
||||
"save_changes": "保存更改"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "下载完成",
|
||||
"game_ready_to_install": "{{title}}已准备好安装",
|
||||
"repack_list_updated": "重打包列表已更新",
|
||||
"repack_count_one": "已添加{{count}}个重打包",
|
||||
"repack_count_other": "已添加{{count}}个重打包"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "打开Hydra",
|
||||
"quit": "退出"
|
||||
},
|
||||
"game_card": {
|
||||
"no_downloads": "没有可用的下载"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "程序未安装",
|
||||
"description": "在您的系统上未找到Wine或Lutris的可执行文件",
|
||||
"instructions": "检查在您的Linux发行版上正确安装它们的方法,以便游戏可以正常运行"
|
||||
"real_debrid_free_account_error": "账户 \"{{username}}\" 是免费账户。请订阅 Real-Debrid",
|
||||
"real_debrid_linked_message": "账户 \"{{username}}\" 已链接",
|
||||
"save_changes": "保存更改",
|
||||
"changes_saved": "更改已成功保存",
|
||||
"download_sources_description": "Hydra 将从这些源获取下载链接。源 URL 必须是直接链接到包含下载链接的 .json 文件。",
|
||||
"validate_download_source": "验证",
|
||||
"remove_download_source": "移除",
|
||||
"add_download_source": "添加源",
|
||||
"download_count_zero": "列表中无下载",
|
||||
"download_count_one": "列表中有 {{countFormatted}} 个下载",
|
||||
"download_count_other": "列表中有 {{countFormatted}} 个下载",
|
||||
"download_options_zero": "无可用下载",
|
||||
"download_options_one": "有 {{countFormatted}} 个下载可用",
|
||||
"download_options_other": "有 {{countFormatted}} 个下载可用",
|
||||
"download_source_url": "下载源 URL",
|
||||
"add_download_source_description": "插入包含 .json 文件的 URL",
|
||||
"download_source_up_to_date": "已更新",
|
||||
"download_source_errored": "出错",
|
||||
"sync_download_sources": "同步源",
|
||||
"removed_download_source": "已移除下载源",
|
||||
"added_download_source": "已添加下载源",
|
||||
"download_sources_synced": "所有下载源已同步",
|
||||
"insert_valid_json_url": "插入有效的 JSON 网址",
|
||||
"found_download_option_zero": "未找到下载选项",
|
||||
"found_download_option_one": "找到 {{countFormatted}} 个下载选项",
|
||||
"found_download_option_other": "找到 {{countFormatted}} 个下载选项",
|
||||
"import": "导入"
|
||||
},
|
||||
"modal": {
|
||||
"close": "关闭按钮"
|
||||
},
|
||||
"forms": {
|
||||
"toggle_password_visibility": "切换密码可见性"
|
||||
},
|
||||
"user_profile": {
|
||||
"amount_hours": "{{amount}} 小时",
|
||||
"amount_minutes": "{{amount}} 分钟",
|
||||
"last_time_played": "上次游玩时间 {{period}}",
|
||||
"activity": "近期活动",
|
||||
"library": "库",
|
||||
"total_play_time": "总游戏时长: {{amount}}",
|
||||
"no_recent_activity_title": "Emmm… 这里暂时啥都没有",
|
||||
"no_recent_activity_description": "你最近没玩过任何游戏。是时候做出改变了!",
|
||||
"display_name": "昵称",
|
||||
"saving": "保存中",
|
||||
"save": "保存",
|
||||
"edit_profile": "编辑资料",
|
||||
"saved_successfully": "成功保存",
|
||||
"try_again": "请重试",
|
||||
"sign_out_modal_title": "你确定吗?",
|
||||
"cancel": "取消",
|
||||
"successfully_signed_out": "登出成功",
|
||||
"sign_out": "登出",
|
||||
"playing_for": "Playing for {{amount}}",
|
||||
"sign_out_modal_text": "您的资料库与您当前的账户相关联。注销后,您的资料库将不再可见,任何进度也不会保存。继续退出吗?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource } from "typeorm";
|
||||
import {
|
||||
Collection,
|
||||
DownloadQueue,
|
||||
DownloadSource,
|
||||
Game,
|
||||
@@ -19,6 +20,7 @@ export const createDataSource = (
|
||||
new DataSource({
|
||||
type: "better-sqlite3",
|
||||
entities: [
|
||||
Collection,
|
||||
Game,
|
||||
Repack,
|
||||
UserPreferences,
|
||||
|
||||
21
src/main/entity/collection.entity.ts
Normal file
21
src/main/entity/collection.entity.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToMany,
|
||||
JoinTable,
|
||||
} from "typeorm";
|
||||
import { Game } from "./game.entity";
|
||||
|
||||
@Entity("collection")
|
||||
export class Collection {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column("text", { unique: true })
|
||||
title: string;
|
||||
|
||||
@ManyToMany("Game", "collections")
|
||||
@JoinTable()
|
||||
games: Game[];
|
||||
}
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
} from "typeorm";
|
||||
import { Repack } from "./repack.entity";
|
||||
|
||||
import type { GameShop } from "@types";
|
||||
import type { GameShop, GameStatus } from "@types";
|
||||
import { Downloader } from "@shared";
|
||||
import type { Aria2Status } from "aria2";
|
||||
import type { DownloadQueue } from "./download-queue.entity";
|
||||
|
||||
@Entity("game")
|
||||
@@ -47,7 +46,7 @@ export class Game {
|
||||
shop: GameShop;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
status: Aria2Status | null;
|
||||
status: GameStatus | null;
|
||||
|
||||
@Column("int", { default: Downloader.Torrent })
|
||||
downloader: Downloader;
|
||||
|
||||
@@ -4,4 +4,5 @@ export * from "./user-preferences.entity";
|
||||
export * from "./game-shop-cache.entity";
|
||||
export * from "./download-source.entity";
|
||||
export * from "./download-queue.entity";
|
||||
export * from "./collection.entity";
|
||||
export * from "./user-auth";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import * as Sentry from "@sentry/electron/main";
|
||||
|
||||
import { userAuthRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
@@ -8,6 +9,9 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
|
||||
if (!auth) return null;
|
||||
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
|
||||
|
||||
Sentry.setContext("sessionId", payload.sessionId);
|
||||
|
||||
return payload.sessionId;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
|
||||
import * as Sentry from "@sentry/electron/main";
|
||||
import { HydraApi, PythonInstance, gamesPlaytime } from "@main/services";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import { DownloadQueue, Game, UserAuth } from "@main/entity";
|
||||
|
||||
@@ -19,12 +20,15 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
gamesPlaytime.clear();
|
||||
});
|
||||
|
||||
/* Disconnects aria2 */
|
||||
DownloadManager.disconnect();
|
||||
/* Removes user from Sentry */
|
||||
Sentry.setUser(null);
|
||||
|
||||
/* Disconnects libtorrent */
|
||||
PythonInstance.killTorrent();
|
||||
|
||||
await Promise.all([
|
||||
databaseOperations,
|
||||
HydraApi.post("/auth/logout").catch(),
|
||||
HydraApi.post("/auth/logout").catch(() => {}),
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { registerEvent } from "../register-event";
|
||||
import updater, { UpdateInfo } from "electron-updater";
|
||||
import { WindowManager } from "@main/services";
|
||||
import { app } from "electron";
|
||||
import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications";
|
||||
|
||||
const { autoUpdater } = updater;
|
||||
|
||||
@@ -20,13 +21,17 @@ const mockValuesForDebug = () => {
|
||||
sendEvent({ type: "update-downloaded" });
|
||||
};
|
||||
|
||||
const newVersionInfo = { version: "" };
|
||||
|
||||
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
autoUpdater
|
||||
.once("update-available", (info: UpdateInfo) => {
|
||||
sendEvent({ type: "update-available", info });
|
||||
newVersionInfo.version = info.version;
|
||||
})
|
||||
.once("update-downloaded", () => {
|
||||
sendEvent({ type: "update-downloaded" });
|
||||
publishNotificationUpdateReadyToInstall(newVersionInfo.version);
|
||||
});
|
||||
|
||||
if (app.isPackaged) {
|
||||
|
||||
18
src/main/events/collections/add-collection-game.ts
Normal file
18
src/main/events/collections/add-collection-game.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { collectionRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { Collection, Game } from "@main/entity";
|
||||
|
||||
const addCollectionGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
collectionId: number,
|
||||
game: Game
|
||||
) => {
|
||||
return await collectionRepository
|
||||
.createQueryBuilder()
|
||||
.relation(Collection, "games")
|
||||
.of(collectionId)
|
||||
.add(game);
|
||||
};
|
||||
|
||||
registerEvent("addCollectionGame", addCollectionGame);
|
||||
14
src/main/events/collections/add-collection.ts
Normal file
14
src/main/events/collections/add-collection.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { collectionRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const addCollection = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
title: string
|
||||
) => {
|
||||
return await collectionRepository.insert({
|
||||
title: title,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("addCollection", addCollection);
|
||||
19
src/main/events/collections/get-collections.ts
Normal file
19
src/main/events/collections/get-collections.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { collectionRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getCollections = async () =>
|
||||
collectionRepository.find({
|
||||
relations: {
|
||||
games: true,
|
||||
},
|
||||
select: {
|
||||
games: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
title: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
registerEvent("getCollections", getCollections);
|
||||
18
src/main/events/collections/remove-collection-game.ts
Normal file
18
src/main/events/collections/remove-collection-game.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { collectionRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { Collection, Game } from "@main/entity";
|
||||
|
||||
const removeCollectionGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
collectionId: number,
|
||||
game: Game
|
||||
) => {
|
||||
return await collectionRepository
|
||||
.createQueryBuilder()
|
||||
.relation(Collection, "games")
|
||||
.of(collectionId)
|
||||
.remove(game);
|
||||
};
|
||||
|
||||
registerEvent("removeCollectionGame", removeCollectionGame);
|
||||
13
src/main/events/collections/remove-collection.ts
Normal file
13
src/main/events/collections/remove-collection.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { collectionRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { Collection } from "@main/entity";
|
||||
|
||||
const removeCollection = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
collection: Collection
|
||||
) => {
|
||||
return await collectionRepository.remove(collection);
|
||||
};
|
||||
|
||||
registerEvent("removeCollection", removeCollection);
|
||||
@@ -1,17 +1,12 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import axios from "axios";
|
||||
import { downloadSourceRepository } from "@main/repository";
|
||||
import { downloadSourceSchema } from "../helpers/validators";
|
||||
import { RepacksManager } from "@main/services";
|
||||
import { downloadSourceWorker } from "@main/workers";
|
||||
|
||||
const validateDownloadSource = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
url: string
|
||||
) => {
|
||||
const response = await axios.get(url);
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const existingSource = await downloadSourceRepository.findOne({
|
||||
where: { url },
|
||||
});
|
||||
@@ -21,14 +16,12 @@ const validateDownloadSource = async (
|
||||
|
||||
const repacks = RepacksManager.repacks;
|
||||
|
||||
const existingUris = source.downloads
|
||||
.flatMap((download) => download.uris)
|
||||
.filter((uri) => repacks.some((repack) => repack.magnet === uri));
|
||||
|
||||
return {
|
||||
name: source.name,
|
||||
downloadCount: source.downloads.length - existingUris.length,
|
||||
};
|
||||
return downloadSourceWorker.run(
|
||||
{ url, repacks },
|
||||
{
|
||||
name: "validateDownloadSource",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("validateDownloadSource", validateDownloadSource);
|
||||
|
||||
10
src/main/events/helpers/parse-executable-path.ts
Normal file
10
src/main/events/helpers/parse-executable-path.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { shell } from "electron";
|
||||
|
||||
export const parseExecutablePath = (path: string) => {
|
||||
if (process.platform === "win32" && path.endsWith(".lnk")) {
|
||||
const { target } = shell.readShortcutLink(path);
|
||||
|
||||
return target;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
@@ -8,6 +8,11 @@ import "./catalogue/get-how-long-to-beat";
|
||||
import "./catalogue/get-random-game";
|
||||
import "./catalogue/search-games";
|
||||
import "./catalogue/search-game-repacks";
|
||||
import "./collections/add-collection";
|
||||
import "./collections/add-collection-game";
|
||||
import "./collections/get-collections";
|
||||
import "./collections/remove-collection";
|
||||
import "./collections/remove-collection-game";
|
||||
import "./hardware/get-disk-free-space";
|
||||
import "./library/add-game-to-library";
|
||||
import "./library/create-game-shortcut";
|
||||
@@ -22,7 +27,6 @@ import "./library/open-game-installer-path";
|
||||
import "./library/update-executable-path";
|
||||
import "./library/remove-game";
|
||||
import "./library/remove-game-from-library";
|
||||
import "./misc/is-user-logged-in";
|
||||
import "./misc/open-external";
|
||||
import "./misc/show-open-dialog";
|
||||
import "./torrenting/cancel-game-download";
|
||||
@@ -44,9 +48,16 @@ import "./auth/sign-out";
|
||||
import "./auth/open-auth-window";
|
||||
import "./auth/get-session-hash";
|
||||
import "./user/get-user";
|
||||
import "./profile/get-friend-requests";
|
||||
import "./profile/get-me";
|
||||
import "./profile/update-friend-request";
|
||||
import "./profile/update-profile";
|
||||
import "./profile/send-friend-request";
|
||||
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
ipcMain.handle("getVersion", () => app.getVersion());
|
||||
ipcMain.handle(
|
||||
"isPortableVersion",
|
||||
() => process.env.PORTABLE_EXECUTABLE_FILE != null
|
||||
);
|
||||
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);
|
||||
|
||||
@@ -53,18 +53,7 @@ const addGameToLibrary = async (
|
||||
|
||||
const game = await gameRepository.findOne({ where: { objectID } });
|
||||
|
||||
createGame(game!).then((response) => {
|
||||
const {
|
||||
id: remoteId,
|
||||
playTimeInMilliseconds,
|
||||
lastTimePlayed,
|
||||
} = response.data;
|
||||
|
||||
gameRepository.update(
|
||||
{ objectID },
|
||||
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
|
||||
);
|
||||
});
|
||||
createGame(game!);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { getProcesses } from "@main/helpers";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { PythonInstance, logger } from "@main/services";
|
||||
import sudo from "sudo-prompt";
|
||||
import { app } from "electron";
|
||||
|
||||
const getKillCommand = (pid: number) => {
|
||||
if (process.platform == "win32") {
|
||||
return `taskkill /PID ${pid}`;
|
||||
}
|
||||
|
||||
return `kill -9 ${pid}`;
|
||||
};
|
||||
|
||||
const closeGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number
|
||||
) => {
|
||||
const processes = await getProcesses();
|
||||
const processes = await PythonInstance.getProcessList();
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
});
|
||||
|
||||
if (!game) return false;
|
||||
|
||||
const executablePath = game.executablePath!;
|
||||
|
||||
const basename = path.win32.basename(executablePath);
|
||||
const basenameWithoutExtension = path.win32.basename(
|
||||
executablePath,
|
||||
path.extname(executablePath)
|
||||
);
|
||||
if (!game) return;
|
||||
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
if (process.platform === "win32") {
|
||||
return runningProcess.name === basename;
|
||||
}
|
||||
|
||||
return [basename, basenameWithoutExtension].includes(runningProcess.name);
|
||||
return runningProcess.exe === game.executablePath;
|
||||
});
|
||||
|
||||
if (gameProcess) return process.kill(gameProcess.pid);
|
||||
return false;
|
||||
if (gameProcess) {
|
||||
try {
|
||||
process.kill(gameProcess.pid);
|
||||
} catch (err) {
|
||||
sudo.exec(
|
||||
getKillCommand(gameProcess.pid),
|
||||
{ name: app.getName() },
|
||||
(error, _stdout, _stderr) => {
|
||||
logger.error(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("closeGame", closeGame);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IsNull, Not } from "typeorm";
|
||||
import createDesktopShortcut from "create-desktop-shortcuts";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
import { removeSymbolsFromName } from "@shared";
|
||||
|
||||
const createGameShortcut = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -22,7 +23,7 @@ const createGameShortcut = async (
|
||||
|
||||
const options = {
|
||||
filePath,
|
||||
name: game.title,
|
||||
name: removeSymbolsFromName(game.title),
|
||||
};
|
||||
|
||||
return createDesktopShortcut({
|
||||
|
||||
@@ -45,10 +45,6 @@ const deleteGameFolder = async (
|
||||
reject();
|
||||
}
|
||||
|
||||
const aria2ControlFilePath = `${folderPath}.aria2`;
|
||||
if (fs.existsSync(aria2ControlFilePath))
|
||||
fs.rmSync(aria2ControlFilePath);
|
||||
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,15 +2,18 @@ import { gameRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { shell } from "electron";
|
||||
import { parseExecutablePath } from "../helpers/parse-executable-path";
|
||||
|
||||
const openGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number,
|
||||
executablePath: string
|
||||
) => {
|
||||
await gameRepository.update({ id: gameId }, { executablePath });
|
||||
const parsedPath = parseExecutablePath(executablePath);
|
||||
|
||||
shell.openPath(executablePath);
|
||||
await gameRepository.update({ id: gameId }, { executablePath: parsedPath });
|
||||
|
||||
shell.openPath(parsedPath);
|
||||
};
|
||||
|
||||
registerEvent("openGame", openGame);
|
||||
|
||||
@@ -20,7 +20,7 @@ const removeRemoveGameFromLibrary = async (gameId: number) => {
|
||||
const game = await gameRepository.findOne({ where: { id: gameId } });
|
||||
|
||||
if (game?.remoteId) {
|
||||
HydraApi.delete(`/games/${game.remoteId}`);
|
||||
HydraApi.delete(`/games/${game.remoteId}`).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { parseExecutablePath } from "../helpers/parse-executable-path";
|
||||
|
||||
const updateExecutablePath = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -12,7 +13,7 @@ const updateExecutablePath = async (
|
||||
id,
|
||||
},
|
||||
{
|
||||
executablePath,
|
||||
executablePath: parseExecutablePath(executablePath),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
|
||||
const isUserLoggedIn = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.isLoggedIn();
|
||||
};
|
||||
|
||||
registerEvent("isUserLoggedIn", isUserLoggedIn);
|
||||
11
src/main/events/profile/get-friend-requests.ts
Normal file
11
src/main/events/profile/get-friend-requests.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { FriendRequest } from "@types";
|
||||
|
||||
const getFriendRequests = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<FriendRequest[]> => {
|
||||
return HydraApi.get(`/profile/friend-requests`).catch(() => []);
|
||||
};
|
||||
|
||||
registerEvent("getFriendRequests", getFriendRequests);
|
||||
@@ -1,16 +1,15 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import * as Sentry from "@sentry/electron/main";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { UserProfile } from "@types";
|
||||
import { userAuthRepository } from "@main/repository";
|
||||
import { logger } from "@main/services";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
|
||||
const getMe = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<UserProfile | null> => {
|
||||
return HydraApi.get(`/profile/me`)
|
||||
.then((response) => {
|
||||
const me = response.data;
|
||||
|
||||
.then((me) => {
|
||||
userAuthRepository.upsert(
|
||||
{
|
||||
id: 1,
|
||||
@@ -21,11 +20,22 @@ const getMe = async (
|
||||
["id"]
|
||||
);
|
||||
|
||||
Sentry.setUser({ id: me.id, username: me.username });
|
||||
|
||||
return me;
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("getMe", err);
|
||||
return userAuthRepository.findOne({ where: { id: 1 } });
|
||||
.catch(async (err) => {
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } });
|
||||
|
||||
if (loggedUser) {
|
||||
return { ...loggedUser, id: loggedUser.userId };
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
11
src/main/events/profile/send-friend-request.ts
Normal file
11
src/main/events/profile/send-friend-request.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
|
||||
const sendFriendRequest = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
userId: string
|
||||
) => {
|
||||
return HydraApi.post("/profile/friend-requests", { friendCode: userId });
|
||||
};
|
||||
|
||||
registerEvent("sendFriendRequest", sendFriendRequest);
|
||||
19
src/main/events/profile/update-friend-request.ts
Normal file
19
src/main/events/profile/update-friend-request.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { FriendRequestAction } from "@types";
|
||||
|
||||
const updateFriendRequest = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
userId: string,
|
||||
action: FriendRequestAction
|
||||
) => {
|
||||
if (action == "CANCEL") {
|
||||
return HydraApi.delete(`/profile/friend-requests/${userId}`);
|
||||
}
|
||||
|
||||
return HydraApi.patch(`/profile/friend-requests/${userId}`, {
|
||||
requestState: action,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("updateFriendRequest", updateFriendRequest);
|
||||
@@ -28,7 +28,7 @@ const updateProfile = async (
|
||||
newProfileImagePath: string | null
|
||||
): Promise<UserProfile> => {
|
||||
if (!newProfileImagePath) {
|
||||
return (await patchUserProfile(displayName)).data;
|
||||
return patchUserProfile(displayName);
|
||||
}
|
||||
|
||||
const stats = fs.statSync(newProfileImagePath);
|
||||
@@ -40,7 +40,7 @@ const updateProfile = async (
|
||||
imageLength: fileSizeInBytes,
|
||||
})
|
||||
.then(async (preSignedResponse) => {
|
||||
const { presignedUrl, profileImageUrl } = preSignedResponse.data;
|
||||
const { presignedUrl, profileImageUrl } = preSignedResponse;
|
||||
|
||||
const mimeType = await fileTypeFromFile(newProfileImagePath);
|
||||
|
||||
@@ -49,13 +49,11 @@ const updateProfile = async (
|
||||
"Content-Type": mimeType?.mime,
|
||||
},
|
||||
});
|
||||
return profileImageUrl;
|
||||
return profileImageUrl as string;
|
||||
})
|
||||
.catch(() => {
|
||||
return undefined;
|
||||
});
|
||||
.catch(() => undefined);
|
||||
|
||||
return (await patchUserProfile(displayName, profileImageUrl)).data;
|
||||
return patchUserProfile(displayName, profileImageUrl);
|
||||
};
|
||||
|
||||
registerEvent("updateProfile", updateProfile);
|
||||
|
||||
@@ -95,18 +95,7 @@ const startGameDownload = async (
|
||||
},
|
||||
});
|
||||
|
||||
createGame(updatedGame!).then((response) => {
|
||||
const {
|
||||
id: remoteId,
|
||||
playTimeInMilliseconds,
|
||||
lastTimePlayed,
|
||||
} = response.data;
|
||||
|
||||
gameRepository.update(
|
||||
{ objectID },
|
||||
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
|
||||
);
|
||||
});
|
||||
createGame(updatedGame!);
|
||||
|
||||
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
|
||||
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import AutoLaunch from "auto-launch";
|
||||
import { app } from "electron";
|
||||
import path from "path";
|
||||
import fs from "node:fs";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
const windowsStartupPath = path.join(
|
||||
app.getPath("appData"),
|
||||
"Microsoft",
|
||||
"Windows",
|
||||
"Start Menu",
|
||||
"Programs",
|
||||
"Startup"
|
||||
);
|
||||
|
||||
const autoLaunch = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
enabled: boolean
|
||||
) => {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
const appLauncher = new AutoLaunch({
|
||||
name: app.getName(),
|
||||
});
|
||||
|
||||
if (enabled) {
|
||||
appLauncher.enable().catch();
|
||||
appLauncher.enable().catch((err) => {
|
||||
logger.error(err);
|
||||
});
|
||||
} else {
|
||||
appLauncher.disable().catch();
|
||||
if (process.platform == "win32") {
|
||||
fs.rm(path.join(windowsStartupPath, "Hydra.vbs"), () => {});
|
||||
}
|
||||
|
||||
appLauncher.disable().catch((err) => {
|
||||
logger.error(err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,17 +2,23 @@ import { userPreferencesRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
import type { UserPreferences } from "@types";
|
||||
import i18next from "i18next";
|
||||
|
||||
const updateUserPreferences = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
preferences: Partial<UserPreferences>
|
||||
) =>
|
||||
userPreferencesRepository.upsert(
|
||||
) => {
|
||||
if (preferences.language) {
|
||||
i18next.changeLanguage(preferences.language);
|
||||
}
|
||||
|
||||
return userPreferencesRepository.upsert(
|
||||
{
|
||||
id: 1,
|
||||
...preferences,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("updateUserPreferences", updateUserPreferences);
|
||||
|
||||
@@ -10,8 +10,7 @@ const getUser = async (
|
||||
userId: string
|
||||
): Promise<UserProfile | null> => {
|
||||
try {
|
||||
const response = await HydraApi.get(`/user/${userId}`);
|
||||
const profile = response.data;
|
||||
const profile = await HydraApi.get(`/user/${userId}`);
|
||||
|
||||
const recentGames = await Promise.all(
|
||||
profile.recentGames.map(async (game) => {
|
||||
|
||||
@@ -57,5 +57,4 @@ export const requestWebPage = async (url: string) => {
|
||||
.then((response) => response.data);
|
||||
};
|
||||
|
||||
export * from "./ps";
|
||||
export * from "./download-source";
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import psList from "ps-list";
|
||||
import path from "node:path";
|
||||
import childProcess from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { app } from "electron";
|
||||
|
||||
const TEN_MEGABYTES = 1000 * 1000 * 10;
|
||||
const execFile = promisify(childProcess.execFile);
|
||||
|
||||
export const getProcesses = async () => {
|
||||
if (process.platform == "win32") {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "fastlist.exe")
|
||||
: path.join(__dirname, "..", "..", "fastlist.exe");
|
||||
|
||||
const { stdout } = await execFile(binaryPath, {
|
||||
maxBuffer: TEN_MEGABYTES,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\r\n")
|
||||
.map((line) => line.split("\t"))
|
||||
.map(([pid, ppid, name]) => ({
|
||||
pid: Number.parseInt(pid, 10),
|
||||
ppid: Number.parseInt(ppid, 10),
|
||||
name,
|
||||
}));
|
||||
} else {
|
||||
return psList();
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { app, BrowserWindow, net, protocol } from "electron";
|
||||
import { init } from "@sentry/electron/main";
|
||||
import updater from "electron-updater";
|
||||
import i18n from "i18next";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
||||
import { DownloadManager, logger, WindowManager } from "@main/services";
|
||||
import { logger, PythonInstance, WindowManager } from "@main/services";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import * as resources from "@locales";
|
||||
import { userPreferencesRepository } from "@main/repository";
|
||||
@@ -19,9 +20,17 @@ autoUpdater.setFeedURL({
|
||||
|
||||
autoUpdater.logger = logger;
|
||||
|
||||
logger.log("Init Hydra");
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) app.quit();
|
||||
|
||||
if (import.meta.env.MAIN_VITE_SENTRY_DSN) {
|
||||
init({
|
||||
dsn: import.meta.env.MAIN_VITE_SENTRY_DSN,
|
||||
});
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch("--no-sandbox");
|
||||
|
||||
i18n.init({
|
||||
@@ -65,6 +74,10 @@ app.whenReady().then(async () => {
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (userPreferences?.language) {
|
||||
i18n.changeLanguage(userPreferences.language);
|
||||
}
|
||||
|
||||
WindowManager.createMainWindow();
|
||||
WindowManager.createSystemTray(userPreferences?.language || "en");
|
||||
});
|
||||
@@ -108,7 +121,9 @@ app.on("window-all-closed", () => {
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
DownloadManager.disconnect();
|
||||
/* Disconnects libtorrent */
|
||||
PythonInstance.kill();
|
||||
logger.log("Quit Hydra");
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { DownloadManager, RepacksManager, startMainLoop } from "./services";
|
||||
import {
|
||||
DownloadManager,
|
||||
RepacksManager,
|
||||
PythonInstance,
|
||||
startMainLoop,
|
||||
} from "./services";
|
||||
import {
|
||||
downloadQueueRepository,
|
||||
repackRepository,
|
||||
@@ -12,18 +17,16 @@ import { MoreThan } from "typeorm";
|
||||
import { HydraApi } from "./services/hydra-api";
|
||||
import { uploadGamesBatch } from "./services/library-sync";
|
||||
|
||||
startMainLoop();
|
||||
|
||||
const loadState = async (userPreferences: UserPreferences | null) => {
|
||||
await RepacksManager.updateRepacks();
|
||||
RepacksManager.updateRepacks();
|
||||
|
||||
import("./events");
|
||||
|
||||
if (userPreferences?.realDebridApiToken)
|
||||
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
||||
|
||||
HydraApi.setupApi().then(async () => {
|
||||
if (HydraApi.isLoggedIn()) uploadGamesBatch();
|
||||
HydraApi.setupApi().then(() => {
|
||||
uploadGamesBatch();
|
||||
});
|
||||
|
||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||
@@ -35,8 +38,13 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (nextQueueItem?.game.status === "active")
|
||||
if (nextQueueItem?.game.status === "active") {
|
||||
DownloadManager.startDownload(nextQueueItem.game);
|
||||
} else {
|
||||
PythonInstance.spawn();
|
||||
}
|
||||
|
||||
startMainLoop();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { dataSource } from "./data-source";
|
||||
import {
|
||||
Collection,
|
||||
DownloadQueue,
|
||||
DownloadSource,
|
||||
Game,
|
||||
@@ -24,3 +25,5 @@ export const downloadSourceRepository =
|
||||
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
||||
|
||||
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
||||
|
||||
export const collectionRepository = dataSource.getRepository(Collection);
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import Aria2, { StatusResponse } from "aria2";
|
||||
|
||||
import path from "node:path";
|
||||
|
||||
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
||||
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { RealDebridClient } from "./real-debrid";
|
||||
|
||||
import { Downloader } from "@shared";
|
||||
import { DownloadProgress } from "@types";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { Game } from "@main/entity";
|
||||
import { startAria2 } from "./aria2c";
|
||||
import { sleep } from "@main/helpers";
|
||||
import { logger } from "./logger";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { publishDownloadCompleteNotification } from "./notifications";
|
||||
|
||||
export class DownloadManager {
|
||||
private static downloads = new Map<number, string>();
|
||||
|
||||
private static connected = false;
|
||||
private static gid: string | null = null;
|
||||
private static game: Game | null = null;
|
||||
private static realDebridTorrentId: string | null = null;
|
||||
private static aria2c: ChildProcess | null = null;
|
||||
|
||||
private static aria2 = new Aria2({});
|
||||
|
||||
private static async connect() {
|
||||
this.aria2c = startAria2();
|
||||
|
||||
let retries = 0;
|
||||
|
||||
while (retries < 4 && !this.connected) {
|
||||
try {
|
||||
await this.aria2.open();
|
||||
logger.log("Connected to aria2");
|
||||
|
||||
this.connected = true;
|
||||
} catch (err) {
|
||||
await sleep(100);
|
||||
logger.log("Failed to connect to aria2, retrying...");
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static disconnect() {
|
||||
if (this.aria2c) {
|
||||
this.aria2c.kill();
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static getETA(
|
||||
totalLength: number,
|
||||
completedLength: number,
|
||||
speed: number
|
||||
) {
|
||||
const remainingBytes = totalLength - completedLength;
|
||||
|
||||
if (remainingBytes >= 0 && speed > 0) {
|
||||
return (remainingBytes / speed) * 1000;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static getFolderName(status: StatusResponse) {
|
||||
if (status.bittorrent?.info) return status.bittorrent.info.name;
|
||||
|
||||
const [file] = status.files;
|
||||
if (file) return path.win32.basename(file.path);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async getRealDebridDownloadUrl() {
|
||||
if (this.realDebridTorrentId) {
|
||||
const torrentInfo = await RealDebridClient.getTorrentInfo(
|
||||
this.realDebridTorrentId
|
||||
);
|
||||
|
||||
const { status, links } = torrentInfo;
|
||||
|
||||
if (status === "waiting_files_selection") {
|
||||
await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === "downloaded") {
|
||||
const [link] = links;
|
||||
const { download } = await RealDebridClient.unrestrictLink(link);
|
||||
return decodeURIComponent(download);
|
||||
}
|
||||
|
||||
if (WindowManager.mainWindow) {
|
||||
const progress = torrentInfo.progress / 100;
|
||||
const totalDownloaded = progress * torrentInfo.bytes;
|
||||
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
|
||||
const payload = {
|
||||
numPeers: 0,
|
||||
numSeeds: torrentInfo.seeders,
|
||||
downloadSpeed: torrentInfo.speed,
|
||||
timeRemaining: this.getETA(
|
||||
torrentInfo.bytes,
|
||||
totalDownloaded,
|
||||
torrentInfo.speed
|
||||
),
|
||||
isDownloadingMetadata: status === "magnet_conversion",
|
||||
game: {
|
||||
...this.game,
|
||||
bytesDownloaded: progress * torrentInfo.bytes,
|
||||
progress,
|
||||
},
|
||||
} as DownloadProgress;
|
||||
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(JSON.stringify(payload))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async watchDownloads() {
|
||||
if (!this.game) return;
|
||||
|
||||
if (!this.gid && this.realDebridTorrentId) {
|
||||
const options = { dir: this.game.downloadPath! };
|
||||
const downloadUrl = await this.getRealDebridDownloadUrl();
|
||||
|
||||
if (downloadUrl) {
|
||||
this.gid = await this.aria2.call("addUri", [downloadUrl], options);
|
||||
this.downloads.set(this.game.id, this.gid);
|
||||
this.realDebridTorrentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.gid) return;
|
||||
|
||||
const status = await this.aria2.call("tellStatus", this.gid);
|
||||
|
||||
const isDownloadingMetadata = status.bittorrent && !status.bittorrent?.info;
|
||||
|
||||
if (status.followedBy?.length) {
|
||||
this.gid = status.followedBy[0];
|
||||
this.downloads.set(this.game.id, this.gid);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress =
|
||||
Number(status.completedLength) / Number(status.totalLength);
|
||||
|
||||
if (!isDownloadingMetadata) {
|
||||
const update: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded: Number(status.completedLength),
|
||||
fileSize: Number(status.totalLength),
|
||||
status: status.status,
|
||||
};
|
||||
|
||||
if (!isNaN(progress)) update.progress = progress;
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: this.game.id },
|
||||
{
|
||||
...update,
|
||||
status: status.status,
|
||||
folderName: this.getFolderName(status),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: this.game.id, isDeleted: false },
|
||||
});
|
||||
|
||||
if (WindowManager.mainWindow && game) {
|
||||
if (!isNaN(progress))
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
|
||||
const payload = {
|
||||
numPeers: Number(status.connections),
|
||||
numSeeds: Number(status.numSeeders ?? 0),
|
||||
downloadSpeed: Number(status.downloadSpeed),
|
||||
timeRemaining: this.getETA(
|
||||
Number(status.totalLength),
|
||||
Number(status.completedLength),
|
||||
Number(status.downloadSpeed)
|
||||
),
|
||||
isDownloadingMetadata: !!isDownloadingMetadata,
|
||||
game,
|
||||
} as DownloadProgress;
|
||||
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(JSON.stringify(payload))
|
||||
);
|
||||
}
|
||||
|
||||
if (progress === 1 && this.game && !isDownloadingMetadata) {
|
||||
publishDownloadCompleteNotification(this.game);
|
||||
|
||||
await downloadQueueRepository.delete({ game: this.game });
|
||||
|
||||
/*
|
||||
Only cancel bittorrent downloads to stop seeding
|
||||
*/
|
||||
if (status.bittorrent) {
|
||||
await this.cancelDownload(this.game.id);
|
||||
} else {
|
||||
this.clearCurrentDownload();
|
||||
}
|
||||
|
||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||
order: {
|
||||
id: "DESC",
|
||||
},
|
||||
relations: {
|
||||
game: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (nextQueueItem) {
|
||||
this.resumeDownload(nextQueueItem.game);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static clearCurrentDownload() {
|
||||
if (this.game) {
|
||||
this.downloads.delete(this.game.id);
|
||||
this.gid = null;
|
||||
this.game = null;
|
||||
this.realDebridTorrentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
const gid = this.downloads.get(gameId);
|
||||
|
||||
if (gid) {
|
||||
await this.aria2.call("forceRemove", gid);
|
||||
|
||||
if (this.gid === gid) {
|
||||
this.clearCurrentDownload();
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
} else {
|
||||
this.downloads.delete(gameId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
if (this.gid) {
|
||||
await this.aria2.call("forcePause", this.gid);
|
||||
this.gid = null;
|
||||
}
|
||||
|
||||
this.game = null;
|
||||
this.realDebridTorrentId = null;
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
}
|
||||
|
||||
static async resumeDownload(game: Game) {
|
||||
if (this.downloads.has(game.id)) {
|
||||
const gid = this.downloads.get(game.id)!;
|
||||
await this.aria2.call("unpause", gid);
|
||||
|
||||
this.gid = gid;
|
||||
this.game = game;
|
||||
this.realDebridTorrentId = null;
|
||||
} else {
|
||||
return this.startDownload(game);
|
||||
}
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (!this.connected) await this.connect();
|
||||
|
||||
const options = {
|
||||
dir: game.downloadPath!,
|
||||
};
|
||||
|
||||
if (game.downloader === Downloader.RealDebrid) {
|
||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
|
||||
game!.uri!
|
||||
);
|
||||
} else {
|
||||
this.gid = await this.aria2.call("addUri", [game.uri!], options);
|
||||
this.downloads.set(game.id, this.gid);
|
||||
}
|
||||
|
||||
this.game = game;
|
||||
}
|
||||
}
|
||||
105
src/main/services/download/download-manager.ts
Normal file
105
src/main/services/download/download-manager.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Game } from "@main/entity";
|
||||
import { Downloader } from "@shared";
|
||||
import { PythonInstance } from "./python-instance";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
||||
import { publishDownloadCompleteNotification } from "../notifications";
|
||||
import { RealDebridDownloader } from "./real-debrid-downloader";
|
||||
import type { DownloadProgress } from "@types";
|
||||
|
||||
export class DownloadManager {
|
||||
private static currentDownloader: Downloader | null = null;
|
||||
|
||||
public static async watchDownloads() {
|
||||
let status: DownloadProgress | null = null;
|
||||
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
status = await RealDebridDownloader.getStatus();
|
||||
} else {
|
||||
status = await PythonInstance.getStatus();
|
||||
}
|
||||
|
||||
if (status) {
|
||||
const { gameId, progress } = status;
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
});
|
||||
|
||||
if (WindowManager.mainWindow && game) {
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(
|
||||
JSON.stringify({
|
||||
...status,
|
||||
game,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (progress === 1 && game) {
|
||||
publishDownloadCompleteNotification(game);
|
||||
|
||||
await downloadQueueRepository.delete({ game });
|
||||
|
||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||
order: {
|
||||
id: "DESC",
|
||||
},
|
||||
relations: {
|
||||
game: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (nextQueueItem) {
|
||||
this.resumeDownload(nextQueueItem.game);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
await RealDebridDownloader.pauseDownload();
|
||||
} else {
|
||||
await PythonInstance.pauseDownload();
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
this.currentDownloader = null;
|
||||
}
|
||||
|
||||
static async resumeDownload(game: Game) {
|
||||
if (game.downloader === Downloader.RealDebrid) {
|
||||
RealDebridDownloader.startDownload(game);
|
||||
this.currentDownloader = Downloader.RealDebrid;
|
||||
} else {
|
||||
PythonInstance.startDownload(game);
|
||||
this.currentDownloader = Downloader.Torrent;
|
||||
}
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
RealDebridDownloader.cancelDownload(gameId);
|
||||
} else {
|
||||
PythonInstance.cancelDownload(gameId);
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
this.currentDownloader = null;
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (game.downloader === Downloader.RealDebrid) {
|
||||
RealDebridDownloader.startDownload(game);
|
||||
this.currentDownloader = Downloader.RealDebrid;
|
||||
} else {
|
||||
PythonInstance.startDownload(game);
|
||||
this.currentDownloader = Downloader.Torrent;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/main/services/download/helpers.ts
Normal file
13
src/main/services/download/helpers.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const calculateETA = (
|
||||
totalLength: number,
|
||||
completedLength: number,
|
||||
speed: number
|
||||
) => {
|
||||
const remainingBytes = totalLength - completedLength;
|
||||
|
||||
if (remainingBytes >= 0 && speed > 0) {
|
||||
return (remainingBytes / speed) * 1000;
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
68
src/main/services/download/http-download.ts
Normal file
68
src/main/services/download/http-download.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { logger } from "../logger";
|
||||
import { sleep } from "@main/helpers";
|
||||
import { startAria2 } from "../aria2c";
|
||||
import Aria2 from "aria2";
|
||||
|
||||
export class HttpDownload {
|
||||
private static connected = false;
|
||||
private static aria2c: ChildProcess | null = null;
|
||||
|
||||
private static aria2 = new Aria2({});
|
||||
|
||||
private static async connect() {
|
||||
this.aria2c = startAria2();
|
||||
|
||||
let retries = 0;
|
||||
|
||||
while (retries < 4 && !this.connected) {
|
||||
try {
|
||||
await this.aria2.open();
|
||||
logger.log("Connected to aria2");
|
||||
|
||||
this.connected = true;
|
||||
} catch (err) {
|
||||
await sleep(100);
|
||||
logger.log("Failed to connect to aria2, retrying...");
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static getStatus(gid: string) {
|
||||
if (this.connected) {
|
||||
return this.aria2.call("tellStatus", gid);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static disconnect() {
|
||||
if (this.aria2c) {
|
||||
this.aria2c.kill();
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
static async cancelDownload(gid: string) {
|
||||
await this.aria2.call("forceRemove", gid);
|
||||
}
|
||||
|
||||
static async pauseDownload(gid: string) {
|
||||
await this.aria2.call("forcePause", gid);
|
||||
}
|
||||
|
||||
static async resumeDownload(gid: string) {
|
||||
await this.aria2.call("unpause", gid);
|
||||
}
|
||||
|
||||
static async startDownload(downloadPath: string, downloadUrl: string) {
|
||||
if (!this.connected) await this.connect();
|
||||
|
||||
const options = {
|
||||
dir: downloadPath,
|
||||
};
|
||||
|
||||
return this.aria2.call("addUri", [downloadUrl], options);
|
||||
}
|
||||
}
|
||||
2
src/main/services/download/index.ts
Normal file
2
src/main/services/download/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./download-manager";
|
||||
export * from "./python-instance";
|
||||
162
src/main/services/download/python-instance.ts
Normal file
162
src/main/services/download/python-instance.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import cp from "node:child_process";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
import {
|
||||
RPC_PASSWORD,
|
||||
RPC_PORT,
|
||||
startTorrentClient as startRPCClient,
|
||||
} from "./torrent-client";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { DownloadProgress } from "@types";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { calculateETA } from "./helpers";
|
||||
import axios from "axios";
|
||||
import {
|
||||
CancelDownloadPayload,
|
||||
StartDownloadPayload,
|
||||
PauseDownloadPayload,
|
||||
LibtorrentStatus,
|
||||
LibtorrentPayload,
|
||||
ProcessPayload,
|
||||
} from "./types";
|
||||
|
||||
export class PythonInstance {
|
||||
private static pythonProcess: cp.ChildProcess | null = null;
|
||||
private static downloadingGameId = -1;
|
||||
|
||||
private static rpc = axios.create({
|
||||
baseURL: `http://localhost:${RPC_PORT}`,
|
||||
headers: {
|
||||
"x-hydra-rpc-password": RPC_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
public static spawn(args?: StartDownloadPayload) {
|
||||
this.pythonProcess = startRPCClient(args);
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
if (this.pythonProcess) {
|
||||
this.pythonProcess.kill();
|
||||
this.pythonProcess = null;
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static killTorrent() {
|
||||
if (this.pythonProcess) {
|
||||
this.rpc.post("/action", { action: "kill-torrent" });
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getProcessList() {
|
||||
return (
|
||||
(await this.rpc.get<ProcessPayload[] | null>("/process-list")).data || []
|
||||
);
|
||||
}
|
||||
|
||||
public static async getStatus() {
|
||||
if (this.downloadingGameId === -1) return null;
|
||||
|
||||
const response = await this.rpc.get<LibtorrentPayload | null>("/status");
|
||||
|
||||
if (response.data === null) return null;
|
||||
|
||||
try {
|
||||
const {
|
||||
progress,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
folderName,
|
||||
status,
|
||||
gameId,
|
||||
} = response.data;
|
||||
|
||||
this.downloadingGameId = gameId;
|
||||
|
||||
const isDownloadingMetadata =
|
||||
status === LibtorrentStatus.DownloadingMetadata;
|
||||
|
||||
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
||||
|
||||
if (!isDownloadingMetadata && !isCheckingFiles) {
|
||||
const update: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
progress,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: gameId },
|
||||
{
|
||||
...update,
|
||||
folderName,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (progress === 1 && !isCheckingFiles) {
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
|
||||
return {
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
|
||||
isDownloadingMetadata,
|
||||
isCheckingFiles,
|
||||
progress,
|
||||
gameId,
|
||||
} as DownloadProgress;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
await this.rpc
|
||||
.post("/action", {
|
||||
action: "pause",
|
||||
game_id: this.downloadingGameId,
|
||||
} as PauseDownloadPayload)
|
||||
.catch(() => {});
|
||||
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (!this.pythonProcess) {
|
||||
this.spawn({
|
||||
game_id: game.id,
|
||||
magnet: game.uri!,
|
||||
save_path: game.downloadPath!,
|
||||
});
|
||||
} else {
|
||||
await this.rpc.post("/action", {
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
magnet: game.uri,
|
||||
save_path: game.downloadPath,
|
||||
} as StartDownloadPayload);
|
||||
}
|
||||
|
||||
this.downloadingGameId = game.id;
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
await this.rpc
|
||||
.post("/action", {
|
||||
action: "cancel",
|
||||
game_id: gameId,
|
||||
} as CancelDownloadPayload)
|
||||
.catch(() => {});
|
||||
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
}
|
||||
162
src/main/services/download/real-debrid-downloader.ts
Normal file
162
src/main/services/download/real-debrid-downloader.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Game } from "@main/entity";
|
||||
import { RealDebridClient } from "../real-debrid";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { calculateETA } from "./helpers";
|
||||
import { DownloadProgress } from "@types";
|
||||
import { HttpDownload } from "./http-download";
|
||||
|
||||
export class RealDebridDownloader {
|
||||
private static downloads = new Map<number, string>();
|
||||
private static downloadingGame: Game | null = null;
|
||||
|
||||
private static realDebridTorrentId: string | null = null;
|
||||
|
||||
private static async getRealDebridDownloadUrl() {
|
||||
if (this.realDebridTorrentId) {
|
||||
const torrentInfo = await RealDebridClient.getTorrentInfo(
|
||||
this.realDebridTorrentId
|
||||
);
|
||||
|
||||
const { status, links } = torrentInfo;
|
||||
|
||||
if (status === "waiting_files_selection") {
|
||||
await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === "downloaded") {
|
||||
const [link] = links;
|
||||
const { download } = await RealDebridClient.unrestrictLink(link);
|
||||
return decodeURIComponent(download);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async getStatus() {
|
||||
if (this.downloadingGame) {
|
||||
const gid = this.downloads.get(this.downloadingGame.id)!;
|
||||
const status = await HttpDownload.getStatus(gid);
|
||||
|
||||
if (status) {
|
||||
const progress =
|
||||
Number(status.completedLength) / Number(status.totalLength);
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: this.downloadingGame!.id },
|
||||
{
|
||||
bytesDownloaded: Number(status.completedLength),
|
||||
fileSize: Number(status.totalLength),
|
||||
progress,
|
||||
status: "active",
|
||||
}
|
||||
);
|
||||
|
||||
const result = {
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
downloadSpeed: Number(status.downloadSpeed),
|
||||
timeRemaining: calculateETA(
|
||||
Number(status.totalLength),
|
||||
Number(status.completedLength),
|
||||
Number(status.downloadSpeed)
|
||||
),
|
||||
isDownloadingMetadata: false,
|
||||
isCheckingFiles: false,
|
||||
progress,
|
||||
gameId: this.downloadingGame!.id,
|
||||
} as DownloadProgress;
|
||||
|
||||
if (progress === 1) {
|
||||
this.downloads.delete(this.downloadingGame.id);
|
||||
this.realDebridTorrentId = null;
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.realDebridTorrentId && this.downloadingGame) {
|
||||
const torrentInfo = await RealDebridClient.getTorrentInfo(
|
||||
this.realDebridTorrentId
|
||||
);
|
||||
|
||||
const { status } = torrentInfo;
|
||||
|
||||
if (status === "downloaded") {
|
||||
this.startDownload(this.downloadingGame);
|
||||
}
|
||||
|
||||
const progress = torrentInfo.progress / 100;
|
||||
const totalDownloaded = progress * torrentInfo.bytes;
|
||||
|
||||
return {
|
||||
numPeers: 0,
|
||||
numSeeds: torrentInfo.seeders,
|
||||
downloadSpeed: torrentInfo.speed,
|
||||
timeRemaining: calculateETA(
|
||||
torrentInfo.bytes,
|
||||
totalDownloaded,
|
||||
torrentInfo.speed
|
||||
),
|
||||
isDownloadingMetadata: status === "magnet_conversion",
|
||||
} as DownloadProgress;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
const gid = this.downloads.get(this.downloadingGame!.id!);
|
||||
if (gid) {
|
||||
await HttpDownload.pauseDownload(gid);
|
||||
}
|
||||
|
||||
this.realDebridTorrentId = null;
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
this.downloadingGame = game;
|
||||
|
||||
if (this.downloads.has(game.id)) {
|
||||
await this.resumeDownload(game.id!);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(game!.uri!);
|
||||
|
||||
const downloadUrl = await this.getRealDebridDownloadUrl();
|
||||
|
||||
if (downloadUrl) {
|
||||
this.realDebridTorrentId = null;
|
||||
|
||||
const gid = await HttpDownload.startDownload(
|
||||
game.downloadPath!,
|
||||
downloadUrl
|
||||
);
|
||||
|
||||
this.downloads.set(game.id!, gid);
|
||||
}
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
const gid = this.downloads.get(gameId);
|
||||
|
||||
if (gid) {
|
||||
await HttpDownload.cancelDownload(gid);
|
||||
this.downloads.delete(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
static async resumeDownload(gameId: number) {
|
||||
const gid = this.downloads.get(gameId);
|
||||
|
||||
if (gid) {
|
||||
await HttpDownload.resumeDownload(gid);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/main/services/download/torrent-client.ts
Normal file
60
src/main/services/download/torrent-client.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { app, dialog } from "electron";
|
||||
import type { StartDownloadPayload } from "./types";
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "hydra-download-manager",
|
||||
linux: "hydra-download-manager",
|
||||
win32: "hydra-download-manager.exe",
|
||||
};
|
||||
|
||||
export const BITTORRENT_PORT = "5881";
|
||||
export const RPC_PORT = "8084";
|
||||
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
export const startTorrentClient = (args?: StartDownloadPayload) => {
|
||||
const commonArgs = [
|
||||
BITTORRENT_PORT,
|
||||
RPC_PORT,
|
||||
RPC_PASSWORD,
|
||||
args ? encodeURIComponent(JSON.stringify(args)) : "",
|
||||
];
|
||||
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
||||
const binaryPath = path.join(
|
||||
process.resourcesPath,
|
||||
"hydra-download-manager",
|
||||
binaryName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
dialog.showErrorBox(
|
||||
"Fatal",
|
||||
"Hydra Download Manager binary not found. Please check if it has been removed by Windows Defender."
|
||||
);
|
||||
|
||||
app.quit();
|
||||
}
|
||||
|
||||
return cp.spawn(binaryPath, commonArgs, {
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
} else {
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"torrent-client",
|
||||
"main.py"
|
||||
);
|
||||
|
||||
return cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
};
|
||||
38
src/main/services/download/types.ts
Normal file
38
src/main/services/download/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface StartDownloadPayload {
|
||||
game_id: number;
|
||||
magnet: string;
|
||||
save_path: string;
|
||||
}
|
||||
|
||||
export interface PauseDownloadPayload {
|
||||
game_id: number;
|
||||
}
|
||||
|
||||
export interface CancelDownloadPayload {
|
||||
game_id: number;
|
||||
}
|
||||
|
||||
export enum LibtorrentStatus {
|
||||
CheckingFiles = 1,
|
||||
DownloadingMetadata = 2,
|
||||
Downloading = 3,
|
||||
Finished = 4,
|
||||
Seeding = 5,
|
||||
}
|
||||
|
||||
export interface LibtorrentPayload {
|
||||
progress: number;
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
downloadSpeed: number;
|
||||
bytesDownloaded: number;
|
||||
fileSize: number;
|
||||
folderName: string;
|
||||
status: LibtorrentStatus;
|
||||
gameId: number;
|
||||
}
|
||||
|
||||
export interface ProcessPayload {
|
||||
exe: string;
|
||||
pid: number;
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import url from "url";
|
||||
import { uploadGamesBatch } from "./library-sync";
|
||||
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id";
|
||||
import { logger } from "./logger";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
|
||||
export class HydraApi {
|
||||
private static instance: AxiosInstance;
|
||||
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5;
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
|
||||
|
||||
@@ -19,7 +20,7 @@ export class HydraApi {
|
||||
expirationTimestamp: 0,
|
||||
};
|
||||
|
||||
static isLoggedIn() {
|
||||
private static isLoggedIn() {
|
||||
return this.userAuth.authToken !== "";
|
||||
}
|
||||
|
||||
@@ -44,6 +45,8 @@ export class HydraApi {
|
||||
expirationTimestamp: tokenExpirationTimestamp,
|
||||
};
|
||||
|
||||
logger.log("Sign in received", this.userAuth);
|
||||
|
||||
await userAuthRepository.upsert(
|
||||
{
|
||||
id: 1,
|
||||
@@ -73,7 +76,7 @@ export class HydraApi {
|
||||
return request;
|
||||
},
|
||||
(error) => {
|
||||
logger.log("request error", error);
|
||||
logger.error("request error", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@@ -90,7 +93,27 @@ export class HydraApi {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
logger.error("response error", error);
|
||||
logger.error(" ---- RESPONSE ERROR -----");
|
||||
|
||||
const { config } = error;
|
||||
|
||||
logger.error(
|
||||
config.method,
|
||||
config.baseURL,
|
||||
config.url,
|
||||
config.headers,
|
||||
config.data
|
||||
);
|
||||
|
||||
if (error.response) {
|
||||
logger.error("Response", error.response.status, error.response.data);
|
||||
} else if (error.request) {
|
||||
logger.error("Request", error.request);
|
||||
} else {
|
||||
logger.error("Error", error.message);
|
||||
}
|
||||
|
||||
logger.error(" ----- END RESPONSE ERROR -------");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@@ -106,14 +129,15 @@ export class HydraApi {
|
||||
};
|
||||
}
|
||||
|
||||
private static async revalidateAccessTokenIfExpired() {
|
||||
if (!this.userAuth.authToken) {
|
||||
userAuthRepository.delete({ id: 1 });
|
||||
logger.error("user is not logged in");
|
||||
throw new Error("user is not logged in");
|
||||
private static sendSignOutEvent() {
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send("on-signout");
|
||||
}
|
||||
}
|
||||
|
||||
private static async revalidateAccessTokenIfExpired() {
|
||||
const now = new Date();
|
||||
|
||||
if (this.userAuth.expirationTimestamp < now.getTime()) {
|
||||
try {
|
||||
const response = await this.instance.post(`/auth/refresh`, {
|
||||
@@ -130,6 +154,8 @@ export class HydraApi {
|
||||
this.userAuth.authToken = accessToken;
|
||||
this.userAuth.expirationTimestamp = tokenExpirationTimestamp;
|
||||
|
||||
logger.log("Token refreshed", this.userAuth);
|
||||
|
||||
userAuthRepository.upsert(
|
||||
{
|
||||
id: 1,
|
||||
@@ -139,26 +165,7 @@ export class HydraApi {
|
||||
["id"]
|
||||
);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AxiosError &&
|
||||
(err?.response?.status === 401 || err?.response?.status === 403)
|
||||
) {
|
||||
this.userAuth = {
|
||||
authToken: "",
|
||||
expirationTimestamp: 0,
|
||||
refreshToken: "",
|
||||
};
|
||||
|
||||
userAuthRepository.delete({ id: 1 });
|
||||
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send("on-signout");
|
||||
}
|
||||
|
||||
logger.log("user refresh token expired");
|
||||
}
|
||||
|
||||
throw err;
|
||||
this.handleUnauthorizedError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,28 +178,71 @@ export class HydraApi {
|
||||
};
|
||||
}
|
||||
|
||||
private static handleUnauthorizedError = (err) => {
|
||||
if (err instanceof AxiosError && err.response?.status === 401) {
|
||||
logger.error("401 - Current credentials:", this.userAuth);
|
||||
|
||||
this.userAuth = {
|
||||
authToken: "",
|
||||
expirationTimestamp: 0,
|
||||
refreshToken: "",
|
||||
};
|
||||
|
||||
userAuthRepository.delete({ id: 1 });
|
||||
|
||||
this.sendSignOutEvent();
|
||||
}
|
||||
|
||||
throw err;
|
||||
};
|
||||
|
||||
static async get(url: string) {
|
||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
return this.instance.get(url, this.getAxiosConfig());
|
||||
return this.instance
|
||||
.get(url, this.getAxiosConfig())
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
|
||||
static async post(url: string, data?: any) {
|
||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
return this.instance.post(url, data, this.getAxiosConfig());
|
||||
return this.instance
|
||||
.post(url, data, this.getAxiosConfig())
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
|
||||
static async put(url: string, data?: any) {
|
||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
return this.instance.put(url, data, this.getAxiosConfig());
|
||||
return this.instance
|
||||
.put(url, data, this.getAxiosConfig())
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
|
||||
static async patch(url: string, data?: any) {
|
||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
return this.instance.patch(url, data, this.getAxiosConfig());
|
||||
return this.instance
|
||||
.patch(url, data, this.getAxiosConfig())
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
|
||||
static async delete(url: string) {
|
||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
return this.instance.delete(url, this.getAxiosConfig());
|
||||
return this.instance
|
||||
.delete(url, this.getAxiosConfig())
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export * from "./steam";
|
||||
export * from "./steam-250";
|
||||
export * from "./steam-grid";
|
||||
export * from "./window-manager";
|
||||
export * from "./download-manager";
|
||||
export * from "./download";
|
||||
export * from "./how-long-to-beat";
|
||||
export * from "./process-watcher";
|
||||
export * from "./main-loop";
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Game } from "@main/entity";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
export const createGame = async (game: Game) => {
|
||||
return HydraApi.post(`/games`, {
|
||||
HydraApi.post(`/games`, {
|
||||
objectId: game.objectID,
|
||||
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
||||
shop: game.shop,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
});
|
||||
})
|
||||
.then((response) => {
|
||||
const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
|
||||
|
||||
gameRepository.update(
|
||||
{ objectID: game.objectID },
|
||||
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
@@ -2,71 +2,63 @@ import { gameRepository } from "@main/repository";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { getSteamAppAsset } from "@main/helpers";
|
||||
import { logger } from "../logger";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
export const mergeWithRemoteGames = async () => {
|
||||
try {
|
||||
const games = await HydraApi.get("/games");
|
||||
|
||||
for (const game of games.data) {
|
||||
const localGame = await gameRepository.findOne({
|
||||
where: {
|
||||
objectID: game.objectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (localGame) {
|
||||
const updatedLastTimePlayed =
|
||||
localGame.lastTimePlayed == null ||
|
||||
(game.lastTimePlayed &&
|
||||
new Date(game.lastTimePlayed) > localGame.lastTimePlayed)
|
||||
? game.lastTimePlayed
|
||||
: localGame.lastTimePlayed;
|
||||
|
||||
const updatedPlayTime =
|
||||
localGame.playTimeInMilliseconds < game.playTimeInMilliseconds
|
||||
? game.playTimeInMilliseconds
|
||||
: localGame.playTimeInMilliseconds;
|
||||
|
||||
gameRepository.update(
|
||||
{
|
||||
return HydraApi.get("/games")
|
||||
.then(async (response) => {
|
||||
for (const game of response) {
|
||||
const localGame = await gameRepository.findOne({
|
||||
where: {
|
||||
objectID: game.objectId,
|
||||
shop: "steam",
|
||||
},
|
||||
{
|
||||
remoteId: game.id,
|
||||
lastTimePlayed: updatedLastTimePlayed,
|
||||
playTimeInMilliseconds: updatedPlayTime,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
if (steamGame) {
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
|
||||
: null;
|
||||
if (localGame) {
|
||||
const updatedLastTimePlayed =
|
||||
localGame.lastTimePlayed == null ||
|
||||
(game.lastTimePlayed &&
|
||||
new Date(game.lastTimePlayed) > localGame.lastTimePlayed)
|
||||
? game.lastTimePlayed
|
||||
: localGame.lastTimePlayed;
|
||||
|
||||
gameRepository.insert({
|
||||
objectID: game.objectId,
|
||||
title: steamGame?.name,
|
||||
remoteId: game.id,
|
||||
shop: game.shop,
|
||||
iconUrl,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||
const updatedPlayTime =
|
||||
localGame.playTimeInMilliseconds < game.playTimeInMilliseconds
|
||||
? game.playTimeInMilliseconds
|
||||
: localGame.playTimeInMilliseconds;
|
||||
|
||||
gameRepository.update(
|
||||
{
|
||||
objectID: game.objectId,
|
||||
shop: "steam",
|
||||
},
|
||||
{
|
||||
remoteId: game.id,
|
||||
lastTimePlayed: updatedLastTimePlayed,
|
||||
playTimeInMilliseconds: updatedPlayTime,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
if (steamGame) {
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
|
||||
: null;
|
||||
|
||||
gameRepository.insert({
|
||||
objectID: game.objectId,
|
||||
title: steamGame?.name,
|
||||
remoteId: game.id,
|
||||
shop: game.shop,
|
||||
iconUrl,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
logger.error("getRemoteGames", err.response, err.message);
|
||||
} else {
|
||||
logger.error("getRemoteGames", err);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@ export const updateGamePlaytime = async (
|
||||
deltaInMillis: number,
|
||||
lastTimePlayed: Date
|
||||
) => {
|
||||
return HydraApi.put(`/games/${game.remoteId}`, {
|
||||
HydraApi.put(`/games/${game.remoteId}`, {
|
||||
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
|
||||
lastTimePlayed,
|
||||
});
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -2,43 +2,32 @@ import { gameRepository } from "@main/repository";
|
||||
import { chunk } from "lodash-es";
|
||||
import { IsNull } from "typeorm";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { logger } from "../logger";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { mergeWithRemoteGames } from "./merge-with-remote-games";
|
||||
import { WindowManager } from "../window-manager";
|
||||
|
||||
export const uploadGamesBatch = async () => {
|
||||
try {
|
||||
const games = await gameRepository.find({
|
||||
where: { remoteId: IsNull(), isDeleted: false },
|
||||
});
|
||||
const games = await gameRepository.find({
|
||||
where: { remoteId: IsNull(), isDeleted: false },
|
||||
});
|
||||
|
||||
const gamesChunks = chunk(games, 200);
|
||||
const gamesChunks = chunk(games, 200);
|
||||
|
||||
for (const chunk of gamesChunks) {
|
||||
await HydraApi.post(
|
||||
"/games/batch",
|
||||
chunk.map((game) => {
|
||||
return {
|
||||
objectId: game.objectID,
|
||||
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
||||
shop: game.shop,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await mergeWithRemoteGames();
|
||||
|
||||
if (WindowManager.mainWindow)
|
||||
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
logger.error("uploadGamesBatch", err.response, err.message);
|
||||
} else {
|
||||
logger.error("uploadGamesBatch", err);
|
||||
}
|
||||
for (const chunk of gamesChunks) {
|
||||
await HydraApi.post(
|
||||
"/games/batch",
|
||||
chunk.map((game) => {
|
||||
return {
|
||||
objectId: game.objectID,
|
||||
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
||||
shop: game.shop,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
};
|
||||
})
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
await mergeWithRemoteGames();
|
||||
|
||||
if (WindowManager.mainWindow)
|
||||
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sleep } from "@main/helpers";
|
||||
import { DownloadManager } from "./download-manager";
|
||||
import { DownloadManager } from "./download";
|
||||
import { watchProcesses } from "./process-watcher";
|
||||
|
||||
export const startMainLoop = async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Notification, nativeImage } from "electron";
|
||||
import { t } from "i18next";
|
||||
import { parseICO } from "icojs";
|
||||
|
||||
import trayIcon from "@resources/tray-icon.png?asset";
|
||||
import { Game } from "@main/entity";
|
||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||
|
||||
@@ -39,11 +39,9 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
|
||||
new Notification({
|
||||
title: t("download_complete", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences.language,
|
||||
}),
|
||||
body: t("game_ready_to_install", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences.language,
|
||||
title: game.title,
|
||||
}),
|
||||
icon,
|
||||
@@ -60,13 +58,26 @@ export const publishNewRepacksNotifications = async (count: number) => {
|
||||
new Notification({
|
||||
title: t("repack_list_updated", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences?.language || "en",
|
||||
}),
|
||||
body: t("repack_count", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences?.language || "en",
|
||||
count: count,
|
||||
}),
|
||||
}).show();
|
||||
}
|
||||
};
|
||||
|
||||
export const publishNotificationUpdateReadyToInstall = async (
|
||||
version: string
|
||||
) => {
|
||||
new Notification({
|
||||
title: t("new_update_available", {
|
||||
ns: "notifications",
|
||||
version,
|
||||
}),
|
||||
body: t("restart_to_install_update", {
|
||||
ns: "notifications",
|
||||
}),
|
||||
icon: trayIcon,
|
||||
}).show();
|
||||
};
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { IsNull, Not } from "typeorm";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { getProcesses } from "@main/helpers";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||
import { GameRunning } from "@types";
|
||||
import { PythonInstance } from "./download";
|
||||
|
||||
export const gamesPlaytime = new Map<
|
||||
number,
|
||||
@@ -21,23 +19,13 @@ export const watchProcesses = async () => {
|
||||
});
|
||||
|
||||
if (games.length === 0) return;
|
||||
|
||||
const processes = await getProcesses();
|
||||
const processes = await PythonInstance.getProcessList();
|
||||
|
||||
for (const game of games) {
|
||||
const executablePath = game.executablePath!;
|
||||
const basename = path.win32.basename(executablePath);
|
||||
const basenameWithoutExtension = path.win32.basename(
|
||||
executablePath,
|
||||
path.extname(executablePath)
|
||||
);
|
||||
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
if (process.platform === "win32") {
|
||||
return runningProcess.name === basename;
|
||||
}
|
||||
|
||||
return [basename, basenameWithoutExtension].includes(runningProcess.name);
|
||||
return executablePath == runningProcess.exe;
|
||||
});
|
||||
|
||||
if (gameProcess) {
|
||||
@@ -60,12 +48,7 @@ export const watchProcesses = async () => {
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(game, 0, new Date());
|
||||
} else {
|
||||
createGame({ ...game, lastTimePlayed: new Date() }).then(
|
||||
(response) => {
|
||||
const { id: remoteId } = response.data;
|
||||
gameRepository.update({ objectID: game.objectID }, { remoteId });
|
||||
}
|
||||
);
|
||||
createGame({ ...game, lastTimePlayed: new Date() });
|
||||
}
|
||||
|
||||
gamesPlaytime.set(game.id, {
|
||||
@@ -84,10 +67,7 @@ export const watchProcesses = async () => {
|
||||
game.lastTimePlayed!
|
||||
);
|
||||
} else {
|
||||
createGame(game).then((response) => {
|
||||
const { id: remoteId } = response.data;
|
||||
gameRepository.update({ objectID: game.objectID }, { remoteId });
|
||||
});
|
||||
createGame(game);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export class WindowManager {
|
||||
minimizable: false,
|
||||
webPreferences: {
|
||||
sandbox: false,
|
||||
nodeIntegrationInSubFrames: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
@@ -3,6 +3,7 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
|
||||
readonly MAIN_VITE_API_URL: string;
|
||||
readonly MAIN_VITE_SENTRY_DSN: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { downloadSourceSchema } from "@main/events/helpers/validators";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import type { DownloadSource } from "@types";
|
||||
import type { DownloadSource, GameRepack } from "@types";
|
||||
import axios, { AxiosError, AxiosHeaders } from "axios";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -48,3 +48,24 @@ export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const validateDownloadSource = async ({
|
||||
url,
|
||||
repacks,
|
||||
}: {
|
||||
url: string;
|
||||
repacks: GameRepack[];
|
||||
}) => {
|
||||
const response = await axios.get(url);
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const existingUris = source.downloads
|
||||
.flatMap((download) => download.uris)
|
||||
.filter((uri) => repacks.some((repack) => repack.magnet === uri));
|
||||
|
||||
return {
|
||||
name: source.name,
|
||||
downloadCount: source.downloads.length - existingUris.length,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
AppUpdaterEvent,
|
||||
StartGameDownloadPayload,
|
||||
GameRunning,
|
||||
Collection,
|
||||
Game,
|
||||
FriendRequestAction,
|
||||
} from "@types";
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
@@ -102,6 +105,16 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.removeListener("on-library-batch-complete", listener);
|
||||
},
|
||||
|
||||
/* Collections */
|
||||
addCollection: (title: string) => ipcRenderer.invoke("addCollection", title),
|
||||
addCollectionGame: (id: number, game: Game) =>
|
||||
ipcRenderer.invoke("addCollectionGame", id, game),
|
||||
getCollections: () => ipcRenderer.invoke("getCollections"),
|
||||
removeCollection: (collection: Collection) =>
|
||||
ipcRenderer.invoke("removeCollection", collection),
|
||||
removeCollectionGame: (id: number, game: Game) =>
|
||||
ipcRenderer.invoke("removeCollectionGame", id, game),
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) =>
|
||||
ipcRenderer.invoke("getDiskFreeSpace", path),
|
||||
@@ -110,8 +123,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ping: () => ipcRenderer.invoke("ping"),
|
||||
getVersion: () => ipcRenderer.invoke("getVersion"),
|
||||
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
|
||||
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
|
||||
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
|
||||
isUserLoggedIn: () => ipcRenderer.invoke("isUserLoggedIn"),
|
||||
showOpenDialog: (options: Electron.OpenDialogOptions) =>
|
||||
ipcRenderer.invoke("showOpenDialog", options),
|
||||
platform: process.platform,
|
||||
@@ -136,6 +149,11 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getMe: () => ipcRenderer.invoke("getMe"),
|
||||
updateProfile: (displayName: string, newProfileImagePath: string | null) =>
|
||||
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath),
|
||||
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
|
||||
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
|
||||
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
||||
sendFriendRequest: (userId: string) =>
|
||||
ipcRenderer.invoke("sendFriendRequest", userId),
|
||||
|
||||
/* User */
|
||||
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Hydra</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://cdn.discordapp.com https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
|
||||
/>
|
||||
</head>
|
||||
<body style="background-color: #1c1c1c">
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
setGameRunning,
|
||||
} from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
|
||||
export interface AppProps {
|
||||
children: React.ReactNode;
|
||||
@@ -38,6 +39,13 @@ export function App() {
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const {
|
||||
isFriendsModalVisible,
|
||||
friendRequetsModalTab,
|
||||
updateFriendRequests,
|
||||
hideFriendsModal,
|
||||
} = useUserDetails();
|
||||
|
||||
const { fetchUserDetails, updateUserDetails, clearUserDetails } =
|
||||
useUserDetails();
|
||||
|
||||
@@ -93,11 +101,10 @@ export function App() {
|
||||
dispatch(setProfileBackground(profileBackground));
|
||||
}
|
||||
|
||||
window.electron.isUserLoggedIn().then((isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) updateUserDetails(response);
|
||||
});
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) {
|
||||
updateUserDetails(response);
|
||||
updateFriendRequests();
|
||||
}
|
||||
});
|
||||
}, [fetchUserDetails, updateUserDetails, dispatch]);
|
||||
@@ -106,6 +113,7 @@ export function App() {
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) {
|
||||
updateUserDetails(response);
|
||||
updateFriendRequests();
|
||||
showSuccessToast(t("successfully_signed_in"));
|
||||
}
|
||||
});
|
||||
@@ -210,6 +218,12 @@ export function App() {
|
||||
onClose={handleToastClose}
|
||||
/>
|
||||
|
||||
<UserFriendModal
|
||||
visible={isFriendsModalVisible}
|
||||
initialTab={friendRequetsModalTab}
|
||||
onClose={hideFriendsModal}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<Sidebar />
|
||||
|
||||
|
||||
@@ -32,8 +32,17 @@ export function BottomPanel() {
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (isGameDownloading) {
|
||||
if (lastPacket?.isCheckingFiles)
|
||||
return t("checking_files", {
|
||||
title: lastPacket?.game.title,
|
||||
percentage: progress,
|
||||
});
|
||||
|
||||
if (lastPacket?.isDownloadingMetadata)
|
||||
return t("downloading_metadata", { title: lastPacket?.game.title });
|
||||
return t("downloading_metadata", {
|
||||
title: lastPacket?.game.title,
|
||||
percentage: progress,
|
||||
});
|
||||
|
||||
if (!eta) {
|
||||
return t("calculating_eta", {
|
||||
@@ -56,6 +65,7 @@ export function BottomPanel() {
|
||||
isGameDownloading,
|
||||
lastPacket?.game,
|
||||
lastPacket?.isDownloadingMetadata,
|
||||
lastPacket?.isCheckingFiles,
|
||||
progress,
|
||||
eta,
|
||||
downloadSpeed,
|
||||
|
||||
@@ -47,10 +47,8 @@ export function AutoUpdateSubHeader() {
|
||||
return (
|
||||
<header className={styles.subheader}>
|
||||
<Link to={releasesPageUrl} className={styles.newVersionLink}>
|
||||
<SyncIcon size={12} />
|
||||
<small>
|
||||
{t("version_available_download", { version: newVersion })}
|
||||
</small>
|
||||
<SyncIcon className={styles.newVersionIcon} size={12} />
|
||||
{t("version_available_download", { version: newVersion })}
|
||||
</Link>
|
||||
</header>
|
||||
);
|
||||
@@ -64,10 +62,8 @@ export function AutoUpdateSubHeader() {
|
||||
className={styles.newVersionButton}
|
||||
onClick={handleClickInstallUpdate}
|
||||
>
|
||||
<SyncIcon size={12} />
|
||||
<small>
|
||||
{t("version_available_install", { version: newVersion })}
|
||||
</small>
|
||||
<SyncIcon className={styles.newVersionIcon} size={12} />
|
||||
{t("version_available_install", { version: newVersion })}
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -157,7 +157,7 @@ export const newVersionButton = style({
|
||||
justifyContent: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.body,
|
||||
fontSize: "13px",
|
||||
fontSize: "12px",
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
@@ -169,5 +169,9 @@ export const newVersionLink = style({
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: "#8e919b",
|
||||
fontSize: "13px",
|
||||
fontSize: "12px",
|
||||
});
|
||||
|
||||
export const newVersionIcon = style({
|
||||
color: vars.color.success,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { createVar, style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const profileContainerBackground = createVar();
|
||||
|
||||
export const profileContainer = style({
|
||||
background: profileContainerBackground,
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
});
|
||||
|
||||
export const profileButton = style({
|
||||
display: "flex",
|
||||
cursor: "pointer",
|
||||
@@ -10,9 +21,8 @@ export const profileButton = style({
|
||||
color: vars.color.muted,
|
||||
borderBottom: `solid 1px ${vars.color.border}`,
|
||||
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
width: "100%",
|
||||
zIndex: "10",
|
||||
});
|
||||
|
||||
export const profileButtonContent = style({
|
||||
@@ -64,3 +74,25 @@ export const profileButtonTitle = style({
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
export const friendRequestContainer = style({
|
||||
position: "absolute",
|
||||
padding: "8px",
|
||||
right: `${SPACING_UNIT}px`,
|
||||
display: "flex",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const friendRequestButton = style({
|
||||
color: vars.color.success,
|
||||
cursor: "pointer",
|
||||
borderRadius: "50%",
|
||||
overflow: "hidden",
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
":hover": {
|
||||
color: vars.color.muted,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PersonIcon } from "@primer/octicons-react";
|
||||
import { PersonAddIcon, PersonIcon } from "@primer/octicons-react";
|
||||
import * as styles from "./sidebar-profile.css";
|
||||
|
||||
import { assignInlineVars } from "@vanilla-extract/dynamic";
|
||||
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { profileContainerBackground } from "./sidebar-profile.css";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
|
||||
export function SidebarProfile() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation("sidebar");
|
||||
|
||||
const { userDetails, profileBackground } = useUserDetails();
|
||||
const { userDetails, profileBackground, friendRequests, showFriendsModal } =
|
||||
useUserDetails();
|
||||
|
||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||
|
||||
@@ -30,46 +33,64 @@ export function SidebarProfile() {
|
||||
}, [profileBackground]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.profileButton}
|
||||
style={{ background: profileButtonBackground }}
|
||||
onClick={handleButtonClick}
|
||||
<div
|
||||
className={styles.profileContainer}
|
||||
style={assignInlineVars({
|
||||
[profileContainerBackground]: profileButtonBackground,
|
||||
})}
|
||||
>
|
||||
<div className={styles.profileButtonContent}>
|
||||
<div className={styles.profileAvatar}>
|
||||
{userDetails?.profileImageUrl ? (
|
||||
<img
|
||||
className={styles.profileAvatar}
|
||||
src={userDetails.profileImageUrl}
|
||||
alt={userDetails.displayName}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.profileButton}
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<div className={styles.profileButtonContent}>
|
||||
<div className={styles.profileAvatar}>
|
||||
{userDetails?.profileImageUrl ? (
|
||||
<img
|
||||
className={styles.profileAvatar}
|
||||
src={userDetails.profileImageUrl}
|
||||
alt={userDetails.displayName}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.profileButtonInformation}>
|
||||
<p className={styles.profileButtonTitle}>
|
||||
{userDetails ? userDetails.displayName : t("sign_in")}
|
||||
</p>
|
||||
<div className={styles.profileButtonInformation}>
|
||||
<p className={styles.profileButtonTitle}>
|
||||
{userDetails ? userDetails.displayName : t("sign_in")}
|
||||
</p>
|
||||
|
||||
{userDetails && gameRunning && (
|
||||
<div>
|
||||
<small>{gameRunning.title}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userDetails && gameRunning && (
|
||||
<div>
|
||||
<small>{gameRunning.title}</small>
|
||||
</div>
|
||||
<img
|
||||
alt={gameRunning.title}
|
||||
width={24}
|
||||
style={{ borderRadius: 4 }}
|
||||
src={gameRunning.iconUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userDetails && gameRunning && (
|
||||
<img
|
||||
alt={gameRunning.title}
|
||||
width={24}
|
||||
style={{ borderRadius: 4 }}
|
||||
src={gameRunning.iconUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
{userDetails && friendRequests.length > 0 && !gameRunning && (
|
||||
<div className={styles.friendRequestContainer}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.friendRequestButton}
|
||||
onClick={() => showFriendsModal(UserFriendModalTab.AddFriend)}
|
||||
>
|
||||
<PersonAddIcon size={24} />
|
||||
{friendRequests.length}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -14,6 +14,8 @@ import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { SidebarProfile } from "./sidebar-profile";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { useCollections } from "@renderer/hooks/use-collections";
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 200;
|
||||
const SIDEBAR_INITIAL_WIDTH = 250;
|
||||
@@ -24,6 +26,7 @@ const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
|
||||
export function Sidebar() {
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
const { collections, updateCollections } = useCollections();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
|
||||
@@ -32,18 +35,24 @@ export function Sidebar() {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(
|
||||
initialSidebarWidth ? Number(initialSidebarWidth) : SIDEBAR_INITIAL_WIDTH
|
||||
);
|
||||
const [showCollections, setShowCollections] = useState(true);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const sortedLibrary = useMemo(() => {
|
||||
return sortBy(library, (game) => game.title);
|
||||
}, [library]);
|
||||
|
||||
const { lastPacket, progress } = useDownload();
|
||||
|
||||
const { showWarningToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
}, [lastPacket?.game.id, updateLibrary]);
|
||||
updateCollections();
|
||||
}, [lastPacket?.game.id, updateLibrary, updateCollections]);
|
||||
|
||||
const isDownloading = library.some(
|
||||
const isDownloading = sortedLibrary.some(
|
||||
(game) => game.status === "active" && game.progress !== 1
|
||||
);
|
||||
|
||||
@@ -62,18 +71,27 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const val = event.target.value.toLocaleLowerCase();
|
||||
|
||||
setFilteredLibrary(
|
||||
library.filter((game) =>
|
||||
game.title
|
||||
.toLowerCase()
|
||||
.includes(event.target.value.toLocaleLowerCase())
|
||||
)
|
||||
sortedLibrary.filter((game) => game.title.toLowerCase().includes(val))
|
||||
);
|
||||
|
||||
setShowCollections(val == "");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredLibrary(library);
|
||||
}, [library]);
|
||||
setFilteredLibrary(
|
||||
sortedLibrary.filter(
|
||||
(game) =>
|
||||
!collections.some((collection) =>
|
||||
collection.games.some(
|
||||
(collectionGame) => collectionGame.id == game.id
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}, [sortedLibrary, collections]);
|
||||
|
||||
useEffect(() => {
|
||||
window.onmousemove = (event: MouseEvent) => {
|
||||
@@ -194,6 +212,58 @@ export function Sidebar() {
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
{collections.map((collection) =>
|
||||
collection.games?.length && showCollections ? (
|
||||
<section className={styles.section} key={collection.id}>
|
||||
<small className={styles.sectionTitle}>
|
||||
{collection.title}
|
||||
</small>
|
||||
|
||||
<ul className={styles.menu}>
|
||||
{sortedLibrary
|
||||
.filter((game) =>
|
||||
collection.games.some(
|
||||
(collectionGame) => game.id == collectionGame.id
|
||||
)
|
||||
)
|
||||
.map((game) => (
|
||||
<li
|
||||
key={game.id}
|
||||
className={styles.menuItem({
|
||||
active:
|
||||
location.pathname ===
|
||||
`/game/${game.shop}/${game.objectID}`,
|
||||
muted: game.status === "removed",
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
onClick={(event) =>
|
||||
handleSidebarGameClick(event, game)
|
||||
}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className={styles.gameIcon}
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className={styles.gameIcon} />
|
||||
)}
|
||||
|
||||
<span className={styles.menuItemButtonLabel}>
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<ul className={styles.menu}>
|
||||
{filteredLibrary.map((game) => (
|
||||
<li
|
||||
|
||||
@@ -140,7 +140,7 @@ export function GameDetailsContextProvider({
|
||||
filters: [
|
||||
{
|
||||
name: "Game executable",
|
||||
extensions: ["exe"],
|
||||
extensions: ["exe", "lnk"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
18
src/renderer/src/declaration.d.ts
vendored
18
src/renderer/src/declaration.d.ts
vendored
@@ -14,6 +14,9 @@ import type {
|
||||
RealDebridUser,
|
||||
DownloadSource,
|
||||
UserProfile,
|
||||
Collection,
|
||||
FriendRequest,
|
||||
FriendRequestAction,
|
||||
} from "@types";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
@@ -78,6 +81,13 @@ declare global {
|
||||
) => () => Electron.IpcRenderer;
|
||||
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Collections */
|
||||
addCollection: (title: string) => Promise<void>;
|
||||
addCollectionGame: (id: number, game: Game) => Promise<void>;
|
||||
getCollections: () => Promise<Collection[]>;
|
||||
removeCollection: (collection: Collection) => Promise<void>;
|
||||
removeCollectionGame: (id: number, game: Game) => Promise<void>;
|
||||
|
||||
/* User preferences */
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
updateUserPreferences: (
|
||||
@@ -100,10 +110,10 @@ declare global {
|
||||
|
||||
/* Misc */
|
||||
openExternal: (src: string) => Promise<void>;
|
||||
isUserLoggedIn: () => Promise<boolean>;
|
||||
getVersion: () => Promise<string>;
|
||||
ping: () => string;
|
||||
getDefaultDownloadsPath: () => Promise<string>;
|
||||
isPortableVersion: () => Promise<boolean>;
|
||||
showOpenDialog: (
|
||||
options: Electron.OpenDialogOptions
|
||||
) => Promise<Electron.OpenDialogReturnValue>;
|
||||
@@ -132,6 +142,12 @@ declare global {
|
||||
displayName: string,
|
||||
newProfileImagePath: string | null
|
||||
) => Promise<UserProfile>;
|
||||
getFriendRequests: () => Promise<FriendRequest[]>;
|
||||
updateFriendRequest: (
|
||||
userId: string,
|
||||
action: FriendRequestAction
|
||||
) => Promise<void>;
|
||||
sendFriendRequest: (userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
26
src/renderer/src/features/collections-slice.ts
Normal file
26
src/renderer/src/features/collections-slice.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
import { Collection } from "../../../types/index";
|
||||
|
||||
export interface CollectionsState {
|
||||
value: Collection[];
|
||||
}
|
||||
|
||||
const initialState: CollectionsState = {
|
||||
value: [],
|
||||
};
|
||||
|
||||
export const collectionsSlice = createSlice({
|
||||
name: "collections",
|
||||
initialState,
|
||||
reducers: {
|
||||
setCollections: (
|
||||
state,
|
||||
action: PayloadAction<CollectionsState["value"]>
|
||||
) => {
|
||||
state.value = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setCollections } = collectionsSlice.actions;
|
||||
@@ -6,3 +6,4 @@ export * from "./window-slice";
|
||||
export * from "./toast-slice";
|
||||
export * from "./user-details-slice";
|
||||
export * from "./running-game-slice";
|
||||
export * from "./collections-slice";
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
import type { UserDetails } from "@types";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
import type { FriendRequest, UserDetails } from "@types";
|
||||
|
||||
export interface UserDetailsState {
|
||||
userDetails: UserDetails | null;
|
||||
profileBackground: null | string;
|
||||
friendRequests: FriendRequest[];
|
||||
isFriendsModalVisible: boolean;
|
||||
friendRequetsModalTab: UserFriendModalTab | null;
|
||||
}
|
||||
|
||||
const initialState: UserDetailsState = {
|
||||
userDetails: null,
|
||||
profileBackground: null,
|
||||
friendRequests: [],
|
||||
isFriendsModalVisible: false,
|
||||
friendRequetsModalTab: null,
|
||||
};
|
||||
|
||||
export const userDetailsSlice = createSlice({
|
||||
@@ -21,8 +28,27 @@ export const userDetailsSlice = createSlice({
|
||||
setProfileBackground: (state, action: PayloadAction<string | null>) => {
|
||||
state.profileBackground = action.payload;
|
||||
},
|
||||
setFriendRequests: (state, action: PayloadAction<FriendRequest[]>) => {
|
||||
state.friendRequests = action.payload;
|
||||
},
|
||||
setFriendsModalVisible: (
|
||||
state,
|
||||
action: PayloadAction<UserFriendModalTab>
|
||||
) => {
|
||||
state.isFriendsModalVisible = true;
|
||||
state.friendRequetsModalTab = action.payload;
|
||||
},
|
||||
setFriendsModalHidden: (state) => {
|
||||
state.isFriendsModalVisible = false;
|
||||
state.friendRequetsModalTab = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setUserDetails, setProfileBackground } =
|
||||
userDetailsSlice.actions;
|
||||
export const {
|
||||
setUserDetails,
|
||||
setProfileBackground,
|
||||
setFriendRequests,
|
||||
setFriendsModalVisible,
|
||||
setFriendsModalHidden,
|
||||
} = userDetailsSlice.actions;
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./use-date";
|
||||
export * from "./use-toast";
|
||||
export * from "./redux";
|
||||
export * from "./use-user-details";
|
||||
export * from "./use-collections";
|
||||
|
||||
64
src/renderer/src/hooks/use-collections.ts
Normal file
64
src/renderer/src/hooks/use-collections.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "./redux";
|
||||
import { setCollections } from "@renderer/features";
|
||||
import { Collection, Game } from "@types";
|
||||
import { useToast } from "./use-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useCollections() {
|
||||
const { t } = useTranslation("collections");
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const collections = useAppSelector((state) => state.collections.value);
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const updateCollections = useCallback(async () => {
|
||||
return window.electron
|
||||
.getCollections()
|
||||
.then((updatedCollection) => dispatch(setCollections(updatedCollection)));
|
||||
}, [dispatch]);
|
||||
|
||||
const addCollection = async (title: string) => {
|
||||
if (
|
||||
!collections.some((collection) => collection.title === title) &&
|
||||
title !== ""
|
||||
) {
|
||||
await window.electron.addCollection(title);
|
||||
|
||||
updateCollections();
|
||||
showSuccessToast(t("the_collection_has_been_added_successfully"));
|
||||
} else {
|
||||
showErrorToast(t("you_cant_give_collections_existing_or_empty_names"));
|
||||
}
|
||||
};
|
||||
|
||||
const removeCollection = async (collection: Collection) => {
|
||||
await window.electron.removeCollection(collection);
|
||||
|
||||
updateCollections();
|
||||
showSuccessToast(t("the_collection_has_been_removed_successfully"));
|
||||
};
|
||||
|
||||
const addCollectionGame = async (collectionId: number, game: Game) => {
|
||||
await window.electron.addCollectionGame(collectionId, game);
|
||||
|
||||
updateCollections();
|
||||
showSuccessToast(t("the_game_has_been_added_to_the_collection"));
|
||||
};
|
||||
|
||||
const removeCollectionGame = async (collectionId: number, game: Game) => {
|
||||
await window.electron.removeCollectionGame(collectionId, game);
|
||||
|
||||
updateCollections();
|
||||
showSuccessToast(t("the_game_has_been_removed_from_the_collection"));
|
||||
};
|
||||
|
||||
return {
|
||||
collections,
|
||||
updateCollections,
|
||||
addCollection,
|
||||
removeCollection,
|
||||
addCollectionGame,
|
||||
removeCollectionGame,
|
||||
};
|
||||
}
|
||||
@@ -22,13 +22,14 @@ export function useDownload() {
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const startDownload = (payload: StartGameDownloadPayload) =>
|
||||
const startDownload = (payload: StartGameDownloadPayload) => {
|
||||
dispatch(clearDownload());
|
||||
window.electron.startGameDownload(payload).then((game) => {
|
||||
dispatch(clearDownload());
|
||||
updateLibrary();
|
||||
|
||||
return game;
|
||||
});
|
||||
};
|
||||
|
||||
const pauseDownload = async (gameId: number) => {
|
||||
await window.electron.pauseGameDownload(gameId);
|
||||
@@ -65,7 +66,7 @@ export function useDownload() {
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
const getETA = () => {
|
||||
const calculateETA = () => {
|
||||
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
|
||||
|
||||
try {
|
||||
@@ -85,9 +86,9 @@ export function useDownload() {
|
||||
|
||||
return {
|
||||
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
|
||||
progress: formatDownloadProgress(lastPacket?.game.progress),
|
||||
progress: formatDownloadProgress(lastPacket?.progress ?? 0),
|
||||
lastPacket,
|
||||
eta: getETA(),
|
||||
eta: calculateETA(),
|
||||
startDownload,
|
||||
pauseDownload,
|
||||
resumeDownload,
|
||||
|
||||
@@ -2,16 +2,27 @@ import { useCallback } from "react";
|
||||
import { average } from "color.js";
|
||||
|
||||
import { useAppDispatch, useAppSelector } from "./redux";
|
||||
import { setProfileBackground, setUserDetails } from "@renderer/features";
|
||||
import {
|
||||
setProfileBackground,
|
||||
setUserDetails,
|
||||
setFriendRequests,
|
||||
setFriendsModalVisible,
|
||||
setFriendsModalHidden,
|
||||
} from "@renderer/features";
|
||||
import { darkenColor } from "@renderer/helpers";
|
||||
import { UserDetails } from "@types";
|
||||
import { FriendRequestAction, UserDetails } from "@types";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
|
||||
export function useUserDetails() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { userDetails, profileBackground } = useAppSelector(
|
||||
(state) => state.userDetails
|
||||
);
|
||||
const {
|
||||
userDetails,
|
||||
profileBackground,
|
||||
friendRequests,
|
||||
isFriendsModalVisible,
|
||||
friendRequetsModalTab,
|
||||
} = useAppSelector((state) => state.userDetails);
|
||||
|
||||
const clearUserDetails = useCallback(async () => {
|
||||
dispatch(setUserDetails(null));
|
||||
@@ -57,8 +68,14 @@ export function useUserDetails() {
|
||||
);
|
||||
|
||||
const fetchUserDetails = useCallback(async () => {
|
||||
return window.electron.getMe();
|
||||
}, []);
|
||||
return window.electron.getMe().then((userDetails) => {
|
||||
if (userDetails == null) {
|
||||
clearUserDetails();
|
||||
}
|
||||
|
||||
return userDetails;
|
||||
});
|
||||
}, [clearUserDetails]);
|
||||
|
||||
const patchUser = useCallback(
|
||||
async (displayName: string, imageProfileUrl: string | null) => {
|
||||
@@ -72,13 +89,56 @@ export function useUserDetails() {
|
||||
[updateUserDetails]
|
||||
);
|
||||
|
||||
const updateFriendRequests = useCallback(async () => {
|
||||
const friendRequests = await window.electron.getFriendRequests();
|
||||
dispatch(setFriendRequests(friendRequests));
|
||||
}, [dispatch]);
|
||||
|
||||
const showFriendsModal = useCallback(
|
||||
(tab: UserFriendModalTab) => {
|
||||
dispatch(setFriendsModalVisible(tab));
|
||||
updateFriendRequests();
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const hideFriendsModal = useCallback(() => {
|
||||
dispatch(setFriendsModalHidden());
|
||||
}, [dispatch]);
|
||||
|
||||
const sendFriendRequest = useCallback(
|
||||
async (userId: string) => {
|
||||
return window.electron
|
||||
.sendFriendRequest(userId)
|
||||
.then(() => updateFriendRequests());
|
||||
},
|
||||
[updateFriendRequests]
|
||||
);
|
||||
|
||||
const updateFriendRequestState = useCallback(
|
||||
async (userId: string, action: FriendRequestAction) => {
|
||||
return window.electron
|
||||
.updateFriendRequest(userId, action)
|
||||
.then(() => updateFriendRequests());
|
||||
},
|
||||
[updateFriendRequests]
|
||||
);
|
||||
|
||||
return {
|
||||
userDetails,
|
||||
profileBackground,
|
||||
friendRequests,
|
||||
friendRequetsModalTab,
|
||||
isFriendsModalVisible,
|
||||
showFriendsModal,
|
||||
hideFriendsModal,
|
||||
fetchUserDetails,
|
||||
signOut,
|
||||
clearUserDetails,
|
||||
updateUserDetails,
|
||||
patchUser,
|
||||
profileBackground,
|
||||
sendFriendRequest,
|
||||
updateFriendRequests,
|
||||
updateFriendRequestState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Provider } from "react-redux";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { HashRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
import * as Sentry from "@sentry/electron/renderer";
|
||||
|
||||
import "@fontsource/fira-mono/400.css";
|
||||
import "@fontsource/fira-mono/500.css";
|
||||
import "@fontsource/fira-mono/700.css";
|
||||
@@ -29,6 +31,8 @@ import { store } from "./store";
|
||||
import * as resources from "@locales";
|
||||
import { User } from "./pages/user/user";
|
||||
|
||||
Sentry.init({});
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
|
||||
@@ -67,6 +67,19 @@ export function DownloadGroup({
|
||||
}
|
||||
|
||||
if (isGameDownloading) {
|
||||
if (lastPacket?.isDownloadingMetadata) {
|
||||
return <p>{t("downloading_metadata")}</p>;
|
||||
}
|
||||
|
||||
if (lastPacket?.isCheckingFiles) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
<p>{t("checking_files")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
@@ -110,7 +123,7 @@ export function DownloadGroup({
|
||||
);
|
||||
}
|
||||
|
||||
return <p>{t(game.status)}</p>;
|
||||
return <p>{t(game.status as string)}</p>;
|
||||
};
|
||||
|
||||
const getGameActions = (game: LibraryGame) => {
|
||||
|
||||
@@ -122,7 +122,7 @@ export function HeroPanelActions() {
|
||||
<Button
|
||||
onClick={() => setShowGameOptionsModal(true)}
|
||||
theme="outline"
|
||||
disabled={deleting || isGameRunning}
|
||||
disabled={deleting}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
<GearIcon />
|
||||
|
||||
@@ -54,7 +54,7 @@ export function HeroPanelPlaytime() {
|
||||
if (!game) return null;
|
||||
|
||||
const hasDownload =
|
||||
["active", "paused"].includes(game.status) && game.progress !== 1;
|
||||
["active", "paused"].includes(game.status as string) && game.progress !== 1;
|
||||
|
||||
const isGameDownloading =
|
||||
game.status === "active" && lastPacket?.game.id === game.id;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user