mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-15 16:33:02 -03:00
Compare commits
158 Commits
v3.3.1
...
feat/httpd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91bb371e0b | ||
|
|
e507676088 | ||
|
|
f3c7010930 | ||
|
|
66d40c566b | ||
|
|
2452a3a51a | ||
|
|
4520f6bb20 | ||
|
|
d7b5bb5940 | ||
|
|
f2efbfba3a | ||
|
|
b7bc5e8cb2 | ||
|
|
ff447c0ed3 | ||
|
|
d1c40b061e | ||
|
|
e6a9ea6139 | ||
|
|
b519275800 | ||
|
|
d56cedb08f | ||
|
|
8e8c79185c | ||
|
|
dd854121f7 | ||
|
|
e4776c8e16 | ||
|
|
e63738ca7b | ||
|
|
3756249013 | ||
|
|
33713cfb9f | ||
|
|
41049be522 | ||
|
|
d9747b7985 | ||
|
|
e0c6d2fe3d | ||
|
|
8db03bcccf | ||
|
|
50acc33a41 | ||
|
|
81936d076e | ||
|
|
7d61d1219d | ||
|
|
19613b69cc | ||
|
|
42a768f715 | ||
|
|
456c0ad6ff | ||
|
|
76181342f9 | ||
|
|
8f95fa70d4 | ||
|
|
4743b9f082 | ||
|
|
db92ef255d | ||
|
|
d1cdfc0ba5 | ||
|
|
77a4642b7b | ||
|
|
148e272c4d | ||
|
|
3bdd8b90d4 | ||
|
|
6569b66801 | ||
|
|
4a11d741eb | ||
|
|
6e8a844a92 | ||
|
|
3821b9836c | ||
|
|
57390c814b | ||
|
|
007fa6f009 | ||
|
|
009cb1d7d7 | ||
|
|
306b49eaf3 | ||
|
|
be232d88e4 | ||
|
|
e3670f5b5a | ||
|
|
bd018399fb | ||
|
|
975eec96be | ||
|
|
44b711f674 | ||
|
|
539ff34b69 | ||
|
|
f99da1d7bf | ||
|
|
75c3bbf858 | ||
|
|
afa78e4634 | ||
|
|
ee1dda90d9 | ||
|
|
5b62b9c593 | ||
|
|
4d76182f2e | ||
|
|
85fb57527a | ||
|
|
9e6b6be0b9 | ||
|
|
3c3f77fc50 | ||
|
|
614cb8a297 | ||
|
|
ba3f010576 | ||
|
|
8c442e742a | ||
|
|
555b3dbb1d | ||
|
|
d2a868b504 | ||
|
|
e27536c6b3 | ||
|
|
cd367faec2 | ||
|
|
087dd9fb2e | ||
|
|
c5d8403843 | ||
|
|
8e01142225 | ||
|
|
a0ef59a13c | ||
|
|
13d5e4469f | ||
|
|
22e92eb8f6 | ||
|
|
d28bb825a3 | ||
|
|
96d59a0fd7 | ||
|
|
84600ea0dc | ||
|
|
9264fa3664 | ||
|
|
5b0ea980de | ||
|
|
622fc393fc | ||
|
|
f76ba5975d | ||
|
|
4da0dac0e6 | ||
|
|
7c468ac9bb | ||
|
|
2ee3bebfc7 | ||
|
|
98ed07d6d2 | ||
|
|
97cf02577a | ||
|
|
fbce53d61a | ||
|
|
1835adf8b4 | ||
|
|
a4dd037cba | ||
|
|
30be12afeb | ||
|
|
258f891a12 | ||
|
|
02662fa993 | ||
|
|
1746f14adb | ||
|
|
7fd0894b56 | ||
|
|
3bb8c17b16 | ||
|
|
417bb69b86 | ||
|
|
a55b5dafa0 | ||
|
|
b7bec47a2c | ||
|
|
f565226a6d | ||
|
|
e7427b8184 | ||
|
|
cc505acbdc | ||
|
|
934831eaae | ||
|
|
a35dae3e18 | ||
|
|
5e58762151 | ||
|
|
21535d8acc | ||
|
|
6106325f0a | ||
|
|
3ff9dd705e | ||
|
|
04f061afa3 | ||
|
|
f8ea4f29d9 | ||
|
|
1520e2b2ae | ||
|
|
d29fc50f68 | ||
|
|
ede538392f | ||
|
|
f464850c38 | ||
|
|
379e90568a | ||
|
|
01d440590b | ||
|
|
56da86d63d | ||
|
|
36cf2060c1 | ||
|
|
5fa4d128c3 | ||
|
|
d15ef33a86 | ||
|
|
54ab719935 | ||
|
|
9eb909cd17 | ||
|
|
b2b4b1ff3c | ||
|
|
f547b85df7 | ||
|
|
0d75878b07 | ||
|
|
9470068de6 | ||
|
|
74a92f57bd | ||
|
|
73f4b0e869 | ||
|
|
e6fde48dbd | ||
|
|
554d195d5b | ||
|
|
fc899197b4 | ||
|
|
7597933aa9 | ||
|
|
3a36bfb75c | ||
|
|
93adb070e5 | ||
|
|
9df58b9918 | ||
|
|
0e164acdf2 | ||
|
|
fffa2d2c01 | ||
|
|
2c35d7da13 | ||
|
|
9a81a45738 | ||
|
|
d284fc914e | ||
|
|
b867eaa34b | ||
|
|
f353d1fc9b | ||
|
|
b03d2a78f8 | ||
|
|
1a6cb5f866 | ||
|
|
fbc81db2d0 | ||
|
|
663e5a46ba | ||
|
|
bc07f73387 | ||
|
|
a697d4ec8b | ||
|
|
008b88f888 | ||
|
|
0e7e5ba920 | ||
|
|
7c98f4afa7 | ||
|
|
12d6b744d7 | ||
|
|
ff7c15a013 | ||
|
|
16d192443f | ||
|
|
0c6b3f2254 | ||
|
|
f92d6839c8 | ||
|
|
d3e383bc1b | ||
|
|
abec986cc1 | ||
|
|
135eef986a |
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -11,6 +11,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
fail-fast: false
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -31,6 +32,16 @@ jobs:
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt
|
||||
|
||||
- name: Build Rust
|
||||
run: cargo build --release
|
||||
working-directory: ./rust_rpc
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
@@ -80,7 +91,6 @@ jobs:
|
||||
BUILDS_URL: ${{ secrets.BUILDS_URL }}
|
||||
BUILD_WEBHOOK_URL: ${{ secrets.BUILD_WEBHOOK_URL }}
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
|
||||
run: node scripts/upload-build.cjs
|
||||
|
||||
- name: Create artifact
|
||||
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -33,6 +33,16 @@ jobs:
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt
|
||||
|
||||
- name: Build Rust
|
||||
run: cargo build --release
|
||||
working-directory: ./rust_rpc
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,10 +9,12 @@ out
|
||||
.vite
|
||||
ludusavi/
|
||||
hydra-python-rpc/
|
||||
aria2/
|
||||
.python-version
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
*storybook.log
|
||||
|
||||
|
||||
target/
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
[](./docs/README.cs.md)
|
||||
[](./docs/README.da.md)
|
||||
[](./docs/README.nb.md)
|
||||
[](./docs/README.et.md)
|
||||
[](./docs/README.et.md)
|
||||
[](./docs/README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
BIN
binaries/7z.dll
Normal file
BIN
binaries/7z.dll
Normal file
Binary file not shown.
BIN
binaries/7z.exe
Normal file
BIN
binaries/7z.exe
Normal file
Binary file not shown.
BIN
binaries/7zz
Normal file
BIN
binaries/7zz
Normal file
Binary file not shown.
BIN
binaries/7zzs
Normal file
BIN
binaries/7zzs
Normal file
Binary file not shown.
BIN
binaries/hydra-httpdl.exe
Normal file
BIN
binaries/hydra-httpdl.exe
Normal file
Binary file not shown.
@@ -26,6 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
[](README.cs.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
194
docs/README.tr.md
Normal file
194
docs/README.tr.md
Normal file
@@ -0,0 +1,194 @@
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[<img src="../resources/icon.png" width="144"/>](https://help.hydralauncher.gg)
|
||||
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra, kendi gömülü BitTorrent istemcisine sahip bir oyun başlatıcısıdır.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
[](https://github.com/hydralauncher/hydra/releases)
|
||||
|
||||
[](README.pt-BR.md)
|
||||
[](../README.md)
|
||||
[](README.ru.md)
|
||||
[](README.uk-UA.md)
|
||||
[](README.be.md)
|
||||
[](README.es.md)
|
||||
[](README.fr.md)
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## <a name="içindekiler"></a> İçindekiler
|
||||
|
||||
- [İçindekiler](#içindekiler)
|
||||
- [Hakkında](#hakkında)
|
||||
- [Özellikler](#özellikler)
|
||||
- [Kurulum](#kurulum)
|
||||
- [Katkıda bulunma](#katkıda-bulunma)
|
||||
- [Telegram grubumuza katılın](#telegram-katıl)
|
||||
- [Repoyu forklayın ve klonlayın](#repo-fork-klon)
|
||||
- [Katkıda bulunabileceğin yollar](#katkı-yolları)
|
||||
- [Proje yapısı](#proje-yapısı)
|
||||
- [Kaynak kodundan derleme](#kaynak-kodundan-derleme)
|
||||
- [Node.js'i yükleme](#nodejs-yükle)
|
||||
- [Yarn'ı yükleme](#yarn-yükle)
|
||||
- [Node bağımlılıklarını yükleme](#node-bağımlılık-yükle)
|
||||
- [OpenSSL 1.1'i yükleme](#openssl-1-1-yükle)
|
||||
- [Python 3.9'u yükleme](#python-3-9-yükle)
|
||||
- [Python bağımlılıklarını yükleme](#python-bağımlılık-yükle)
|
||||
- [Ortam değişkenleri](#ortam-değişkenleri)
|
||||
- [Çalıştırma](#çalıştırma)
|
||||
- [Derleme](#derleme)
|
||||
- [BitTorrent istemcisini derleme](#bittorrent-istemci-derle)
|
||||
- [Electron uygulamasını derleme](#electron-uygulama-derle)
|
||||
- [Katkıda bulunanlar](#katkıda-bulunanlar)
|
||||
- [Lisans](#lisans)
|
||||
|
||||
## <a name="hakkında"></a> Hakkında
|
||||
|
||||
**Hydra**, kendi gömülü **BitTorrent istemci**sine sahip bir **oyun başlatıcısı**dır.
|
||||
<br>
|
||||
Başlatıcı, torrent sistemini libtorrent kullanarak yöneten Python ve TypeScript (Electron) ile yazılmıştır.
|
||||
|
||||
## <a name="özellikler"></a> Özellikler
|
||||
|
||||
- Kendi gömülü BitTorrent istemcisi
|
||||
- Oyun sayfasında How Long To Beat (HLTB) entegrasyonu
|
||||
- İndirme yolu özelleştirmesi
|
||||
- Windows ve Linux desteği
|
||||
- Sürekli güncelleme
|
||||
- Ve daha fazlası...
|
||||
|
||||
## <a name="kurulum"></a> Kurulum
|
||||
|
||||
Aşağıdaki adımları izleyerek Hydra'yı kurun:
|
||||
|
||||
1. Hydra'nın en son sürümünü [Releases](https://github.com/hydralauncher/hydra/releases/latest) sayfasından indirin.
|
||||
- Hydra'yı Windows'a kurmak istiyorsanız sadece .exe dosyasını indirin.
|
||||
- Hydra'yı Linux'a kurmak istiyorsanız .deb, .rpm veya .zip dosyasını indirin (kullandığınız Linux dağıtımına bağlı olarak).
|
||||
2. İndirilen dosyayı çalıştırın.
|
||||
3. Hydra'nın keyfini çıkarın!
|
||||
|
||||
## <a name="katkıda-bulunma"></a> Katkıda Bulunma
|
||||
|
||||
### <a name="telegram-katıl"></a> Telegram grubumuza katılın
|
||||
|
||||
Tartışmalarımızı [Telegram](https://t.me/hydralauncher) kanalımız üzerinde yürütüyoruz.
|
||||
|
||||
### <a name="repo-fork-klon"></a> Repoyu forklayın ve klonlayın
|
||||
|
||||
1. Depoyu fork'layın [(şimdi forklamak için tıklayın)](https://github.com/hydralauncher/hydra/fork)
|
||||
2. Forkladığınız kodu klonlayın `git clone https://github.com/kullanıcı_adınız/hydra`
|
||||
3. Yeni bir branch oluşturun
|
||||
4. Commitlerinizi gönderin (push)
|
||||
5. Yeni bir Pull Request gönderin
|
||||
|
||||
### <a name="katkı-yolları"></a> Katkıda bulunabileceğin yollar
|
||||
|
||||
- Çeviri: Hydra'nın mümkün olduğunca fazla kişiye ulaşmasını istiyoruz. Yeni dillere çeviri yapmak ya da mevcut dillere güncelleme ve iyileştirme yapmak için yardımcı olmaktan çekinmeyin.
|
||||
- Kod: Hydra, Typescript, Electron ve biraz Python ile inşa edilmiştir. Katkıda bulunmak isterseniz, [Telegram](https://t.me/hydralauncher) kanalımıza katılın!
|
||||
|
||||
### <a name="proje-yapısı"></a> Proje yapısı
|
||||
|
||||
- torrent-client: Torrent indirmelerini yönetmek için libtorrent adlı bir Python kütüphanesini kullanıyoruz.
|
||||
- src/renderer: Uygulamanın kullanıcı arayüzü burada bulunur.
|
||||
- src/main: Uygulamanın tüm işleyişi ve iş mantığı bu bölümde bulunur.
|
||||
|
||||
## <a name="kaynak-kodundan-derleme"></a> Kaynak kodundan derleme
|
||||
|
||||
### <a name="nodejs-yükle"></a> Node.js'i yükleme
|
||||
|
||||
Makinenizde Node.js'in yüklü olduğundan emin olun. Yüklü değilse, [nodejs.org](https://nodejs.org/) adresinden indirip kurun.
|
||||
|
||||
### <a name="yarn-yükle"></a> Yarn'ı yükleme
|
||||
|
||||
Yarn, Node.js için bir paket yöneticisidir. Eğer Yarn'ı henüz kurmadıysanız, [yarnpkg.com](https://classic.yarnpkg.com/lang/en/docs/install/) adresindeki talimatları izleyerek kurabilirsiniz.
|
||||
|
||||
### <a name="node-bağımlılık-yükle"></a> Node bağımlılıklarını yükleme
|
||||
|
||||
Proje dizinine gidin ve Yarn kullanarak Node bağımlılıklarını yükleyin:
|
||||
|
||||
```bash
|
||||
cd hydra
|
||||
yarn
|
||||
```
|
||||
|
||||
### <a name="openssl-1-1-yükle"></a> OpenSSL 1.1'i yükleme
|
||||
|
||||
Windows ortamlarında libtorrent tarafından gerekli olan [OpenSSL 1.1](https://slproweb.com/download/Win64OpenSSL-1_1_1w.exe)'i indirip yükleyin.
|
||||
|
||||
### <a name="python-3-9-yükle"></a> Python 3.9'u yükleme
|
||||
|
||||
Makinenizde Python 3.9'un yüklü olduğundan emin olun. Bunu [python.org](https://www.python.org/downloads/release/python-3913/) adresinden indirip kurarak yapabilirsiniz.
|
||||
|
||||
### <a name="python-bağımlılık-yükle"></a> Python bağımlılıklarını yükleme
|
||||
|
||||
Gerekli Python bağımlılıklarını pip kullanarak yükleyin:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## <a name="ortam-değişkenleri"></a> Ortam değişkenleri
|
||||
|
||||
Oyun simgelerini yüklemek için bir SteamGridDB API Anahtarına ihtiyacınız olacak.
|
||||
|
||||
Bu anahtara sahip olduktan sonra, `.env.example` dosyasını kopyalayabilir veya adını `.env` olarak değiştirebilir ve `STEAMGRIDDB_API_KEY` değerini buraya ekleyebilirsiniz.
|
||||
|
||||
## <a name="çalıştırma"></a> Çalıştırma
|
||||
|
||||
Tüm ayarları tamamladıktan sonra, hem Electron sürecini hem de bittorrent istemcisini başlatmak için aşağıdaki komutu çalıştırabilirsiniz:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## <a name="derleme"></a> Derleme
|
||||
|
||||
### <a name="bittorrent-istemci-derle"></a> BitTorrent istemcisini derleme
|
||||
|
||||
Bittorrent istemcisini aşağıdaki komutla derleyin:
|
||||
|
||||
```bash
|
||||
python torrent-client/setup.py build
|
||||
```
|
||||
|
||||
### <a name="electron-uygulama-derle"></a> Electron uygulamasını derleme
|
||||
|
||||
Electron uygulamasını aşağıdaki komutlarla derleyebilirsiniz:
|
||||
|
||||
Windows'ta:
|
||||
|
||||
```bash
|
||||
yarn build:win
|
||||
```
|
||||
|
||||
Linux'ta:
|
||||
|
||||
```bash
|
||||
yarn build:linux
|
||||
```
|
||||
|
||||
## <a name="katkıda-bulunanlar"></a> Katkıda bulunanlar
|
||||
|
||||
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=hydralauncher/hydra" />
|
||||
</a>
|
||||
|
||||
## <a name="lisans"></a> Lisans
|
||||
|
||||
Hydra, [MIT Lisansı](https://github.com/hydralauncher/hydra/blob/main/LICENSE) altında lisanlanmıştır.
|
||||
@@ -26,6 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
[](README.et.md)
|
||||
[](README.tr.md)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ productName: Hydra
|
||||
directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- aria2
|
||||
- ludusavi
|
||||
- hydra-python-rpc
|
||||
- seeds
|
||||
@@ -20,6 +19,10 @@ asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
executableName: Hydra
|
||||
extraResources:
|
||||
- from: binaries/7z.exe
|
||||
- from: binaries/7z.dll
|
||||
- from: rust_rpc/target/release/hydra-httpdl.exe
|
||||
target:
|
||||
- nsis
|
||||
- portable
|
||||
@@ -35,6 +38,9 @@ portable:
|
||||
artifactName: ${name}-${version}-portable.${ext}
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extraResources:
|
||||
- from: binaries/7zz
|
||||
- from: rust_rpc/target/release/hydra-httpdl
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
@@ -44,6 +50,9 @@ mac:
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
linux:
|
||||
extraResources:
|
||||
- from: binaries/7zzs
|
||||
- from: rust_rpc/target/release/hydra-httpdl
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
|
||||
@@ -16,9 +16,6 @@ export default defineConfig(({ mode }) => {
|
||||
main: {
|
||||
build: {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
external: ["better-sqlite3"],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.3.1",
|
||||
"version": "3.4.2",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
@@ -21,15 +21,14 @@
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"dev": "cargo build --manifest-path=rust_rpc/Cargo.toml && electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "electron-vite build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux",
|
||||
"prepare": "husky",
|
||||
"knex:migrate:make": "knex --knexfile src/main/knexfile.ts migrate:make --esm"
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
@@ -45,7 +44,6 @@
|
||||
"auto-launch": "^5.0.6",
|
||||
"axios": "^1.7.9",
|
||||
"axios-cookiejar-support": "^5.0.5",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"classic-level": "^2.0.0",
|
||||
"classnames": "^2.5.1",
|
||||
"color": "^4.2.3",
|
||||
@@ -55,14 +53,13 @@
|
||||
"dexie": "^4.0.10",
|
||||
"diskusage": "^1.2.0",
|
||||
"electron-log": "^5.2.4",
|
||||
"electron-updater": "^6.3.9",
|
||||
"electron-updater": "^6.6.2",
|
||||
"file-type": "^19.6.0",
|
||||
"i18next": "^23.11.2",
|
||||
"i18next-browser-languagedetector": "^7.2.1",
|
||||
"jsdom": "^24.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kill-port": "^2.0.1",
|
||||
"knex": "^3.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parse-torrent": "^11.0.17",
|
||||
"piscina": "^4.7.0",
|
||||
@@ -103,7 +100,7 @@
|
||||
"@types/user-agents": "^1.0.4",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"electron": "^31.7.7",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
|
||||
@@ -1,48 +1,94 @@
|
||||
import aria2p
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
class HttpDownloader:
|
||||
def __init__(self):
|
||||
self.download = None
|
||||
self.aria2 = aria2p.API(
|
||||
aria2p.Client(
|
||||
host="http://localhost",
|
||||
port=6800,
|
||||
secret=""
|
||||
)
|
||||
)
|
||||
def __init__(self, hydra_httpdl_bin: str):
|
||||
self.hydra_exe = hydra_httpdl_bin
|
||||
self.process = None
|
||||
self.last_status = None
|
||||
|
||||
def start_download(self, url: str, save_path: str, header: str, out: str = None):
|
||||
if self.download:
|
||||
self.aria2.resume([self.download])
|
||||
def start_download(self, url: str, save_path: str, header: str = None, allow_multiple_connections: bool = False, connections_limit: int = 1):
|
||||
cmd = [self.hydra_exe]
|
||||
|
||||
cmd.append(url)
|
||||
|
||||
cmd.extend([
|
||||
"--chunk-size", "64",
|
||||
"--buffer-size", "20",
|
||||
"--force-download",
|
||||
"--log",
|
||||
"--silent"
|
||||
])
|
||||
|
||||
if header:
|
||||
cmd.extend(["--header", header])
|
||||
|
||||
if allow_multiple_connections:
|
||||
cmd.extend(["--connections", str(connections_limit)])
|
||||
else:
|
||||
downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out})
|
||||
|
||||
self.download = downloads[0]
|
||||
|
||||
def pause_download(self):
|
||||
if self.download:
|
||||
self.aria2.pause([self.download])
|
||||
|
||||
def cancel_download(self):
|
||||
if self.download:
|
||||
self.aria2.remove([self.download])
|
||||
self.download = None
|
||||
|
||||
def get_download_status(self):
|
||||
if self.download == None:
|
||||
cmd.extend(["--connections", "1"])
|
||||
|
||||
print(f"running hydra-httpdl: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=save_path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"error running hydra-httpdl: {e}")
|
||||
return None
|
||||
|
||||
download = self.aria2.get_download(self.download.gid)
|
||||
|
||||
response = {
|
||||
'folderName': download.name,
|
||||
'fileSize': download.total_length,
|
||||
'progress': download.completed_length / download.total_length if download.total_length else 0,
|
||||
'downloadSpeed': download.download_speed,
|
||||
'numPeers': 0,
|
||||
'numSeeds': 0,
|
||||
'status': download.status,
|
||||
'bytesDownloaded': download.completed_length,
|
||||
}
|
||||
|
||||
return response
|
||||
def get_download_status(self):
|
||||
|
||||
if not self.process:
|
||||
return None
|
||||
|
||||
try:
|
||||
line = self.process.stdout.readline()
|
||||
if line:
|
||||
status = json.loads(line.strip())
|
||||
self.last_status = status
|
||||
elif self.last_status:
|
||||
status = self.last_status
|
||||
else:
|
||||
return None
|
||||
|
||||
response = {
|
||||
"status": "active",
|
||||
"progress": status["progress"],
|
||||
"downloadSpeed": status["speed_bps"],
|
||||
"numPeers": 0,
|
||||
"numSeeds": 0,
|
||||
"bytesDownloaded": status["downloaded_bytes"],
|
||||
"fileSize": status["total_bytes"],
|
||||
"folderName": status["filename"]
|
||||
}
|
||||
|
||||
if status["progress"] == 1:
|
||||
response["status"] = "complete"
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
print(f"error getting download status: {e}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def stop_download(self):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
self.process = None
|
||||
self.last_status = None
|
||||
|
||||
def pause_download(self):
|
||||
self.stop_download()
|
||||
|
||||
def cancel_download(self):
|
||||
self.stop_download()
|
||||
|
||||
@@ -13,6 +13,7 @@ http_port = sys.argv[2]
|
||||
rpc_password = sys.argv[3]
|
||||
start_download_payload = sys.argv[4]
|
||||
start_seeding_payload = sys.argv[5]
|
||||
hydra_httpdl_bin = sys.argv[6]
|
||||
|
||||
downloads = {}
|
||||
# This can be streamed down from Node
|
||||
@@ -32,10 +33,10 @@ if start_download_payload:
|
||||
except Exception as e:
|
||||
print("Error starting torrent download", e)
|
||||
else:
|
||||
http_downloader = HttpDownloader()
|
||||
http_downloader = HttpDownloader(hydra_httpdl_bin)
|
||||
downloads[initial_download['game_id']] = http_downloader
|
||||
try:
|
||||
http_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out"))
|
||||
http_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get('allow_multiple_connections', False), initial_download.get('connections_limit', 8))
|
||||
except Exception as e:
|
||||
print("Error starting http download", e)
|
||||
|
||||
@@ -147,11 +148,11 @@ def action():
|
||||
torrent_downloader.start_download(url, data['save_path'])
|
||||
else:
|
||||
if existing_downloader and isinstance(existing_downloader, HttpDownloader):
|
||||
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
||||
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('allow_multiple_connections', False), data.get('connections_limit', 8))
|
||||
else:
|
||||
http_downloader = HttpDownloader()
|
||||
http_downloader = HttpDownloader(hydra_httpdl_bin)
|
||||
downloads[game_id] = http_downloader
|
||||
http_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
||||
http_downloader.start_download(url, data['save_path'], data.get('header'), data.get('allow_multiple_connections', False), data.get('connections_limit', 8))
|
||||
|
||||
downloading_game_id = game_id
|
||||
|
||||
|
||||
@@ -5,4 +5,3 @@ pywin32; sys_platform == 'win32'
|
||||
psutil
|
||||
Pillow
|
||||
flask
|
||||
aria2p
|
||||
|
||||
2040
rust_rpc/Cargo.lock
generated
Normal file
2040
rust_rpc/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
rust_rpc/Cargo.toml
Normal file
25
rust_rpc/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "hydra-httpdl"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] }
|
||||
reqwest = { version = "0.12.5", features = ["stream"] }
|
||||
futures = "0.3"
|
||||
bytes = "1.4"
|
||||
indicatif = "0.17"
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
urlencoding = "2.1"
|
||||
serde_json = "1.0"
|
||||
bitvec = "1.0"
|
||||
sha2 = "0.10"
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
982
rust_rpc/src/main.rs
Normal file
982
rust_rpc/src/main.rs
Normal file
@@ -0,0 +1,982 @@
|
||||
use anyhow::Result;
|
||||
use bitvec::prelude::*;
|
||||
use clap::Parser;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const DEFAULT_MAX_RETRIES: usize = 3;
|
||||
const RETRY_BACKOFF_MS: u64 = 500;
|
||||
const DEFAULT_OUTPUT_FILENAME: &str = "output.bin";
|
||||
const DEFAULT_CONNECTIONS: usize = 16;
|
||||
const DEFAULT_CHUNK_SIZE_MB: usize = 5;
|
||||
const DEFAULT_BUFFER_SIZE_MB: usize = 8;
|
||||
const DEFAULT_VERBOSE: bool = false;
|
||||
const DEFAULT_SILENT: bool = false;
|
||||
const DEFAULT_LOG: bool = false;
|
||||
const DEFAULT_FORCE_NEW: bool = false;
|
||||
const DEFAULT_RESUME_ONLY: bool = false;
|
||||
const DEFAULT_FORCE_DOWNLOAD: bool = false;
|
||||
const HEADER_SIZE: usize = 4096;
|
||||
const MAGIC_NUMBER: &[u8; 5] = b"HYDRA";
|
||||
const FORMAT_VERSION: u8 = 1;
|
||||
// const FINALIZE_BUFFER_SIZE: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hydra-httpdl")]
|
||||
#[command(author = "los-broxas")]
|
||||
#[command(version = "0.2.0")]
|
||||
#[command(about = "high speed and low resource usage http downloader with resume capability", long_about = None)]
|
||||
struct CliArgs {
|
||||
/// file url to download
|
||||
#[arg(required = true)]
|
||||
url: String,
|
||||
|
||||
/// output file path (or directory to save with original filename)
|
||||
#[arg(default_value = DEFAULT_OUTPUT_FILENAME)]
|
||||
output: String,
|
||||
|
||||
/// number of concurrent connections for parallel download
|
||||
#[arg(short = 'c', long, default_value_t = DEFAULT_CONNECTIONS)]
|
||||
connections: usize,
|
||||
|
||||
/// chunk size in MB for each connection
|
||||
#[arg(short = 'k', long, default_value_t = DEFAULT_CHUNK_SIZE_MB)]
|
||||
chunk_size: usize,
|
||||
|
||||
/// buffer size in MB for file writing
|
||||
#[arg(short, long, default_value_t = DEFAULT_BUFFER_SIZE_MB)]
|
||||
buffer_size: usize,
|
||||
|
||||
/// show detailed progress information
|
||||
#[arg(short = 'v', long, default_value_t = DEFAULT_VERBOSE)]
|
||||
verbose: bool,
|
||||
|
||||
/// suppress progress bar
|
||||
#[arg(short = 's', long, default_value_t = DEFAULT_SILENT)]
|
||||
silent: bool,
|
||||
|
||||
/// log download statistics in JSON format every second
|
||||
#[arg(short = 'l', long, default_value_t = DEFAULT_LOG)]
|
||||
log: bool,
|
||||
|
||||
/// force new download, ignore existing partial files
|
||||
#[arg(short = 'f', long, default_value_t = DEFAULT_FORCE_NEW)]
|
||||
force_new: bool,
|
||||
|
||||
/// only resume existing download, exit if no partial file exists
|
||||
#[arg(short = 'r', long, default_value_t = DEFAULT_RESUME_ONLY)]
|
||||
resume_only: bool,
|
||||
|
||||
/// force download, ignore some verification checks
|
||||
#[arg(short = 'F', long, default_value_t = DEFAULT_FORCE_DOWNLOAD)]
|
||||
force_download: bool,
|
||||
|
||||
/// HTTP headers to send with request (format: "Key: Value")
|
||||
#[arg(short = 'H', long)]
|
||||
header: Vec<String>,
|
||||
}
|
||||
|
||||
struct DownloadConfig {
|
||||
url: String,
|
||||
output_path: String,
|
||||
num_connections: usize,
|
||||
chunk_size: usize,
|
||||
buffer_size: usize,
|
||||
verbose: bool,
|
||||
silent: bool,
|
||||
log: bool,
|
||||
force_new: bool,
|
||||
resume_only: bool,
|
||||
headers: Vec<String>,
|
||||
force_download: bool,
|
||||
}
|
||||
|
||||
impl DownloadConfig {
|
||||
fn should_log(&self) -> bool {
|
||||
self.verbose && !self.silent
|
||||
}
|
||||
|
||||
fn should_log_stats(&self) -> bool {
|
||||
self.log
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadStats {
|
||||
progress_percent: f64,
|
||||
bytes_downloaded: u64,
|
||||
total_size: u64,
|
||||
speed_bytes_per_sec: f64,
|
||||
eta_seconds: u64,
|
||||
elapsed_seconds: u64,
|
||||
}
|
||||
|
||||
struct HydraHeader {
|
||||
magic: [u8; 5], // "HYDRA" identifier
|
||||
version: u8, // header version
|
||||
file_size: u64, // file size
|
||||
etag: [u8; 32], // etag hash
|
||||
url_hash: [u8; 32], // url hash
|
||||
chunk_size: u32, // chunk size
|
||||
chunk_count: u32, // chunk count
|
||||
chunks_bitmap: BitVec<u8>, // chunks bitmap
|
||||
}
|
||||
|
||||
impl HydraHeader {
|
||||
fn new(file_size: u64, etag: &str, url: &str, chunk_size: u32) -> Self {
|
||||
let chunk_count = ((file_size as f64) / (chunk_size as f64)).ceil() as u32;
|
||||
let chunks_bitmap = bitvec![u8, Lsb0; 0; chunk_count as usize];
|
||||
|
||||
let mut etag_hash = [0u8; 32];
|
||||
let etag_digest = Sha256::digest(etag.as_bytes());
|
||||
etag_hash.copy_from_slice(&etag_digest[..]);
|
||||
|
||||
let mut url_hash = [0u8; 32];
|
||||
let url_digest = Sha256::digest(url.as_bytes());
|
||||
url_hash.copy_from_slice(&url_digest[..]);
|
||||
|
||||
Self {
|
||||
magic: *MAGIC_NUMBER,
|
||||
version: FORMAT_VERSION,
|
||||
file_size,
|
||||
etag: etag_hash,
|
||||
url_hash,
|
||||
chunk_size,
|
||||
chunk_count,
|
||||
chunks_bitmap,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_to_file<W: Write + Seek>(&self, writer: &mut W) -> Result<()> {
|
||||
writer.write_all(&self.magic)?;
|
||||
writer.write_all(&[self.version])?;
|
||||
writer.write_all(&self.file_size.to_le_bytes())?;
|
||||
writer.write_all(&self.etag)?;
|
||||
writer.write_all(&self.url_hash)?;
|
||||
writer.write_all(&self.chunk_size.to_le_bytes())?;
|
||||
writer.write_all(&self.chunk_count.to_le_bytes())?;
|
||||
|
||||
let bitmap_bytes = self.chunks_bitmap.as_raw_slice();
|
||||
writer.write_all(bitmap_bytes)?;
|
||||
|
||||
let header_size = 5 + 1 + 8 + 32 + 32 + 4 + 4 + bitmap_bytes.len();
|
||||
let padding_size = HEADER_SIZE - header_size;
|
||||
let padding = vec![0u8; padding_size];
|
||||
writer.write_all(&padding)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_from_file<R: Read + Seek>(reader: &mut R) -> Result<Self> {
|
||||
let mut magic = [0u8; 5];
|
||||
reader.read_exact(&mut magic)?;
|
||||
|
||||
if magic != *MAGIC_NUMBER {
|
||||
anyhow::bail!("Not a valid Hydra download file");
|
||||
}
|
||||
|
||||
let mut version = [0u8; 1];
|
||||
reader.read_exact(&mut version)?;
|
||||
|
||||
if version[0] != FORMAT_VERSION {
|
||||
anyhow::bail!("Incompatible format version");
|
||||
}
|
||||
|
||||
let mut file_size_bytes = [0u8; 8];
|
||||
reader.read_exact(&mut file_size_bytes)?;
|
||||
let file_size = u64::from_le_bytes(file_size_bytes);
|
||||
|
||||
let mut etag = [0u8; 32];
|
||||
reader.read_exact(&mut etag)?;
|
||||
|
||||
let mut url_hash = [0u8; 32];
|
||||
reader.read_exact(&mut url_hash)?;
|
||||
|
||||
let mut chunk_size_bytes = [0u8; 4];
|
||||
reader.read_exact(&mut chunk_size_bytes)?;
|
||||
let chunk_size = u32::from_le_bytes(chunk_size_bytes);
|
||||
|
||||
let mut chunk_count_bytes = [0u8; 4];
|
||||
reader.read_exact(&mut chunk_count_bytes)?;
|
||||
let chunk_count = u32::from_le_bytes(chunk_count_bytes);
|
||||
|
||||
let bitmap_bytes_len = (chunk_count as usize + 7) / 8;
|
||||
let mut bitmap_bytes = vec![0u8; bitmap_bytes_len];
|
||||
reader.read_exact(&mut bitmap_bytes)?;
|
||||
|
||||
let chunks_bitmap = BitVec::<u8, Lsb0>::from_vec(bitmap_bytes);
|
||||
|
||||
reader.seek(SeekFrom::Start(HEADER_SIZE as u64))?;
|
||||
|
||||
Ok(Self {
|
||||
magic,
|
||||
version: version[0],
|
||||
file_size,
|
||||
etag,
|
||||
url_hash,
|
||||
chunk_size,
|
||||
chunk_count,
|
||||
chunks_bitmap,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_chunk_complete(&mut self, chunk_index: usize) -> Result<()> {
|
||||
if chunk_index >= self.chunk_count as usize {
|
||||
anyhow::bail!("Chunk index out of bounds");
|
||||
}
|
||||
|
||||
self.chunks_bitmap.set(chunk_index, true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_chunk_complete(&self, chunk_index: usize) -> bool {
|
||||
if chunk_index >= self.chunk_count as usize {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.chunks_bitmap[chunk_index]
|
||||
}
|
||||
|
||||
fn get_incomplete_chunks(&self) -> Vec<(u64, u64)> {
|
||||
let incomplete_count = self.chunk_count as usize - self.chunks_bitmap.count_ones();
|
||||
let mut chunks = Vec::with_capacity(incomplete_count);
|
||||
let chunk_size = self.chunk_size as u64;
|
||||
|
||||
for i in 0..self.chunk_count as usize {
|
||||
if !self.is_chunk_complete(i) {
|
||||
let start = i as u64 * chunk_size;
|
||||
let end = std::cmp::min((i as u64 + 1) * chunk_size - 1, self.file_size - 1);
|
||||
chunks.push((start, end));
|
||||
}
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
fn is_download_complete(&self) -> bool {
|
||||
self.chunks_bitmap.count_ones() == self.chunk_count as usize
|
||||
}
|
||||
}
|
||||
|
||||
struct ProgressTracker {
|
||||
bar: Option<ProgressBar>,
|
||||
}
|
||||
|
||||
impl ProgressTracker {
|
||||
fn new(file_size: u64, silent: bool, enable_stats: bool) -> Result<Self> {
|
||||
let bar = if !silent || enable_stats {
|
||||
let pb = ProgressBar::new(file_size);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("[{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")?
|
||||
);
|
||||
if silent {
|
||||
pb.set_draw_target(indicatif::ProgressDrawTarget::hidden());
|
||||
}
|
||||
Some(pb)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self { bar })
|
||||
}
|
||||
|
||||
fn increment(&self, amount: u64) {
|
||||
if let Some(pb) = &self.bar {
|
||||
pb.inc(amount);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self) {
|
||||
if let Some(pb) = &self.bar {
|
||||
pb.finish_with_message("Download complete");
|
||||
}
|
||||
}
|
||||
|
||||
fn get_stats(&self) -> Option<DownloadStats> {
|
||||
if let Some(pb) = &self.bar {
|
||||
let position = pb.position();
|
||||
let total = pb.length().unwrap_or(1);
|
||||
|
||||
Some(DownloadStats {
|
||||
progress_percent: position as f64 / total as f64,
|
||||
bytes_downloaded: position,
|
||||
total_size: total,
|
||||
speed_bytes_per_sec: pb.per_sec(),
|
||||
eta_seconds: pb.eta().as_secs(),
|
||||
elapsed_seconds: pb.elapsed().as_secs(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Downloader {
|
||||
client: Client,
|
||||
config: DownloadConfig,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
async fn download(&self) -> Result<()> {
|
||||
let (file_size, filename, etag) = self.get_file_info().await?;
|
||||
let output_path = self.determine_output_path(filename);
|
||||
|
||||
if self.config.should_log() {
|
||||
println!("Detected filename: {}", output_path);
|
||||
}
|
||||
|
||||
let resume_manager = ResumeManager::try_from_file(
|
||||
&output_path,
|
||||
file_size,
|
||||
&etag,
|
||||
&self.config.url,
|
||||
self.config.chunk_size as u32,
|
||||
self.config.force_new,
|
||||
self.config.resume_only,
|
||||
)?;
|
||||
|
||||
let file = self.prepare_output_file(&output_path, file_size)?;
|
||||
let progress = ProgressTracker::new(file_size, self.config.silent, self.config.log)?;
|
||||
|
||||
let chunks = if resume_manager.is_download_complete() {
|
||||
if self.config.should_log() {
|
||||
println!("File is already fully downloaded, finalizing...");
|
||||
}
|
||||
resume_manager.finalize_download()?;
|
||||
return Ok(());
|
||||
} else {
|
||||
let completed_chunks = resume_manager.header.chunks_bitmap.count_ones() as u32;
|
||||
let total_chunks = resume_manager.header.chunk_count;
|
||||
|
||||
if completed_chunks > 0 {
|
||||
if self.config.should_log() {
|
||||
let percent_done = (completed_chunks as f64 / total_chunks as f64) * 100.0;
|
||||
println!("Resuming download: {:.1}% already downloaded", percent_done);
|
||||
}
|
||||
|
||||
if let Some(pb) = &progress.bar {
|
||||
let downloaded = file_size * completed_chunks as u64 / total_chunks as u64;
|
||||
pb.set_position(downloaded);
|
||||
pb.reset_elapsed();
|
||||
}
|
||||
}
|
||||
|
||||
resume_manager.get_incomplete_chunks()
|
||||
};
|
||||
|
||||
if self.config.should_log() {
|
||||
println!(
|
||||
"Downloading {} chunks of total {}",
|
||||
chunks.len(),
|
||||
resume_manager.header.chunk_count
|
||||
);
|
||||
}
|
||||
|
||||
self.process_chunks_with_resume(
|
||||
chunks,
|
||||
file,
|
||||
file_size,
|
||||
progress,
|
||||
output_path.clone(),
|
||||
resume_manager,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn determine_output_path(&self, filename: Option<String>) -> String {
|
||||
if Path::new(&self.config.output_path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
== DEFAULT_OUTPUT_FILENAME
|
||||
&& filename.is_some()
|
||||
{
|
||||
filename.unwrap()
|
||||
} else {
|
||||
self.config.output_path.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_output_file(&self, path: &str, size: u64) -> Result<Arc<Mutex<BufWriter<File>>>> {
|
||||
let file = if Path::new(path).exists() {
|
||||
OpenOptions::new().read(true).write(true).open(path)?
|
||||
} else {
|
||||
let file = File::create(path)?;
|
||||
file.set_len(HEADER_SIZE as u64 + size)?;
|
||||
file
|
||||
};
|
||||
|
||||
Ok(Arc::new(Mutex::new(BufWriter::with_capacity(
|
||||
self.config.buffer_size,
|
||||
file,
|
||||
))))
|
||||
}
|
||||
|
||||
async fn process_chunks_with_resume(
|
||||
&self,
|
||||
chunks: Vec<(u64, u64)>,
|
||||
file: Arc<Mutex<BufWriter<File>>>,
|
||||
_file_size: u64,
|
||||
progress: ProgressTracker,
|
||||
real_filename: String,
|
||||
resume_manager: ResumeManager,
|
||||
) -> Result<()> {
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
|
||||
let log_progress = if self.config.should_log_stats() {
|
||||
let progress_clone = progress.bar.clone();
|
||||
let filename = real_filename.clone();
|
||||
|
||||
let (log_cancel_tx, mut log_cancel_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
let log_task = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
|
||||
let tracker = ProgressTracker {
|
||||
bar: progress_clone,
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
if let Some(stats) = tracker.get_stats() {
|
||||
let json_output = json!({
|
||||
"progress": stats.progress_percent,
|
||||
"speed_bps": stats.speed_bytes_per_sec,
|
||||
"downloaded_bytes": stats.bytes_downloaded,
|
||||
"total_bytes": stats.total_size,
|
||||
"eta_seconds": stats.eta_seconds,
|
||||
"elapsed_seconds": stats.elapsed_seconds,
|
||||
"filename": filename
|
||||
});
|
||||
println!("{}", json_output);
|
||||
}
|
||||
}
|
||||
_ = &mut log_cancel_rx => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Some((log_task, log_cancel_tx))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let resume_manager = Arc::new(Mutex::new(resume_manager));
|
||||
|
||||
for (start, end) in chunks {
|
||||
let client = self.client.clone();
|
||||
let url = self.config.url.clone();
|
||||
let file_clone = Arc::clone(&file);
|
||||
let pb_clone = progress.bar.clone();
|
||||
let manager_clone = Arc::clone(&resume_manager);
|
||||
let headers = self.config.headers.clone();
|
||||
let force_download = self.config.force_download;
|
||||
let should_log = self.config.should_log();
|
||||
|
||||
let chunk_size = self.config.chunk_size as u64;
|
||||
let chunk_index = (start / chunk_size) as usize;
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let result = Self::download_chunk_with_retry(
|
||||
client,
|
||||
url,
|
||||
start,
|
||||
end,
|
||||
file_clone,
|
||||
pb_clone,
|
||||
DEFAULT_MAX_RETRIES,
|
||||
&headers,
|
||||
force_download,
|
||||
should_log,
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_ok() {
|
||||
let mut manager = manager_clone.lock().await;
|
||||
manager.set_chunk_complete(chunk_index)?;
|
||||
}
|
||||
|
||||
result
|
||||
}));
|
||||
|
||||
if tasks.len() >= self.config.num_connections {
|
||||
if let Some(result) = tasks.next().await {
|
||||
result??;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(result) = tasks.next().await {
|
||||
result??;
|
||||
}
|
||||
|
||||
{
|
||||
let mut writer = file.lock().await;
|
||||
writer.flush()?;
|
||||
}
|
||||
|
||||
progress.finish();
|
||||
|
||||
if let Some((log_handle, log_cancel_tx)) = log_progress {
|
||||
let _ = log_cancel_tx.send(());
|
||||
let _ = log_handle.await;
|
||||
}
|
||||
|
||||
let manager = resume_manager.lock().await;
|
||||
if manager.is_download_complete() {
|
||||
if self.config.should_log() {
|
||||
println!("Download complete, finalizing file...");
|
||||
}
|
||||
manager.finalize_download()?;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
|
||||
if self.config.should_log_stats() {
|
||||
let json_output = json!({
|
||||
"progress": 1.0,
|
||||
"speed_bps": 0.0,
|
||||
"downloaded_bytes": _file_size,
|
||||
"total_bytes": _file_size,
|
||||
"eta_seconds": 0,
|
||||
"elapsed_seconds": if let Some(pb) = &progress.bar { pb.elapsed().as_secs() } else { 0 },
|
||||
"filename": real_filename
|
||||
});
|
||||
println!("{}", json_output);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_chunk_with_retry(
|
||||
client: Client,
|
||||
url: String,
|
||||
start: u64,
|
||||
end: u64,
|
||||
file: Arc<Mutex<BufWriter<File>>>,
|
||||
progress_bar: Option<ProgressBar>,
|
||||
max_retries: usize,
|
||||
headers: &[String],
|
||||
force_download: bool,
|
||||
should_log: bool,
|
||||
) -> Result<()> {
|
||||
let mut retries = 0;
|
||||
loop {
|
||||
match Self::download_chunk(
|
||||
client.clone(),
|
||||
url.clone(),
|
||||
start,
|
||||
end,
|
||||
file.clone(),
|
||||
progress_bar.clone(),
|
||||
headers,
|
||||
force_download,
|
||||
should_log,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
retries += 1;
|
||||
if retries >= max_retries {
|
||||
return Err(e);
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(
|
||||
RETRY_BACKOFF_MS * (2_u64.pow(retries as u32 - 1)),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_chunk(
|
||||
client: Client,
|
||||
url: String,
|
||||
start: u64,
|
||||
end: u64,
|
||||
file: Arc<Mutex<BufWriter<File>>>,
|
||||
progress_bar: Option<ProgressBar>,
|
||||
headers: &[String],
|
||||
force_download: bool,
|
||||
should_log: bool,
|
||||
) -> Result<()> {
|
||||
let mut req = client
|
||||
.get(&url)
|
||||
.header("Range", format!("bytes={}-{}", start, end));
|
||||
|
||||
for header in headers {
|
||||
if let Some(idx) = header.find(':') {
|
||||
let (name, value) = header.split_at(idx);
|
||||
let value = value[1..].trim();
|
||||
req = req.header(name.trim(), value);
|
||||
}
|
||||
}
|
||||
|
||||
let resp = req.send().await?;
|
||||
|
||||
if resp.status() != StatusCode::PARTIAL_CONTENT && resp.status() != StatusCode::OK {
|
||||
if !force_download {
|
||||
anyhow::bail!("Server does not support Range requests");
|
||||
} else if should_log {
|
||||
println!("Server does not support Range requests, ignoring...");
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut position = start;
|
||||
let mut total_bytes = 0;
|
||||
let expected_bytes = end - start + 1;
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result?;
|
||||
let chunk_size = chunk.len() as u64;
|
||||
|
||||
total_bytes += chunk_size;
|
||||
if total_bytes > expected_bytes {
|
||||
let remaining = expected_bytes - (total_bytes - chunk_size);
|
||||
let mut writer = file.lock().await;
|
||||
writer.seek(SeekFrom::Start(HEADER_SIZE as u64 + position))?;
|
||||
writer.write_all(&chunk[..remaining as usize])?;
|
||||
|
||||
let tracker = ProgressTracker {
|
||||
bar: progress_bar.clone(),
|
||||
};
|
||||
tracker.increment(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
let mut writer = file.lock().await;
|
||||
writer.seek(SeekFrom::Start(HEADER_SIZE as u64 + position))?;
|
||||
writer.write_all(&chunk)?;
|
||||
drop(writer);
|
||||
|
||||
position += chunk_size;
|
||||
let tracker = ProgressTracker {
|
||||
bar: progress_bar.clone(),
|
||||
};
|
||||
tracker.increment(chunk_size);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_file_info(&self) -> Result<(u64, Option<String>, String)> {
|
||||
let mut req = self.client.head(&self.config.url);
|
||||
|
||||
for header in &self.config.headers {
|
||||
if let Some(idx) = header.find(':') {
|
||||
let (name, value) = header.split_at(idx);
|
||||
let value = value[1..].trim();
|
||||
req = req.header(name.trim(), value);
|
||||
}
|
||||
}
|
||||
|
||||
let resp = req.send().await?;
|
||||
|
||||
let accepts_ranges = resp
|
||||
.headers()
|
||||
.get("accept-ranges")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.contains("bytes"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !accepts_ranges {
|
||||
let range_check = self
|
||||
.client
|
||||
.get(&self.config.url)
|
||||
.header("Range", "bytes=0-0")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if range_check.status() != StatusCode::PARTIAL_CONTENT {
|
||||
if !self.config.force_download {
|
||||
anyhow::bail!(
|
||||
"Server does not support Range requests, cannot continue with parallel download"
|
||||
);
|
||||
} else if self.config.should_log() {
|
||||
println!("Server does not support Range requests, ignoring...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let file_size = if let Some(content_length) = resp.headers().get("content-length") {
|
||||
content_length.to_str()?.parse()?
|
||||
} else {
|
||||
anyhow::bail!("Could not determine file size")
|
||||
};
|
||||
|
||||
let etag = if let Some(etag_header) = resp.headers().get("etag") {
|
||||
etag_header.to_str()?.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"no-etag-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
)
|
||||
};
|
||||
|
||||
let filename = self.extract_filename_from_response(&resp);
|
||||
|
||||
Ok((file_size, filename, etag))
|
||||
}
|
||||
|
||||
fn extract_filename_from_response(&self, resp: &reqwest::Response) -> Option<String> {
|
||||
if let Some(disposition) = resp.headers().get("content-disposition") {
|
||||
if let Ok(disposition_str) = disposition.to_str() {
|
||||
if let Some(filename) = Self::parse_content_disposition(disposition_str) {
|
||||
return Some(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self::extract_filename_from_url(&self.config.url)
|
||||
}
|
||||
|
||||
fn parse_content_disposition(disposition: &str) -> Option<String> {
|
||||
if let Some(idx) = disposition.find("filename=") {
|
||||
let start = idx + 9;
|
||||
let mut end = disposition.len();
|
||||
|
||||
if disposition.as_bytes().get(start) == Some(&b'"') {
|
||||
let quoted_name = &disposition[start + 1..];
|
||||
if let Some(quote_end) = quoted_name.find('"') {
|
||||
return Some(quoted_name[..quote_end].to_string());
|
||||
}
|
||||
} else {
|
||||
if let Some(semicolon) = disposition[start..].find(';') {
|
||||
end = start + semicolon;
|
||||
}
|
||||
return Some(disposition[start..end].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_filename_from_url(url: &str) -> Option<String> {
|
||||
if let Ok(parsed_url) = Url::parse(url) {
|
||||
let path = parsed_url.path();
|
||||
if let Some(path_filename) = Path::new(path).file_name() {
|
||||
if let Some(filename_str) = path_filename.to_str() {
|
||||
if !filename_str.is_empty() {
|
||||
if let Ok(decoded) = urlencoding::decode(filename_str) {
|
||||
return Some(decoded.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct ResumeManager {
|
||||
header: HydraHeader,
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
impl ResumeManager {
|
||||
fn try_from_file(
|
||||
path: &str,
|
||||
file_size: u64,
|
||||
etag: &str,
|
||||
url: &str,
|
||||
chunk_size: u32,
|
||||
force_new: bool,
|
||||
resume_only: bool,
|
||||
) -> Result<Self> {
|
||||
if force_new {
|
||||
if Path::new(path).exists() {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
return Self::create_new_file(path, file_size, etag, url, chunk_size);
|
||||
}
|
||||
|
||||
if let Ok(file) = File::open(path) {
|
||||
let mut reader = BufReader::new(file);
|
||||
match HydraHeader::read_from_file(&mut reader) {
|
||||
Ok(header) => {
|
||||
let current_url_hash = Sha256::digest(url.as_bytes());
|
||||
|
||||
let url_matches = header.url_hash == current_url_hash.as_slice();
|
||||
let size_matches = header.file_size == file_size;
|
||||
|
||||
if url_matches && size_matches {
|
||||
return Ok(Self {
|
||||
header,
|
||||
file_path: path.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if resume_only {
|
||||
anyhow::bail!(
|
||||
"Existing file is not compatible and resume_only option is active"
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
Err(e) => {
|
||||
if resume_only {
|
||||
return Err(anyhow::anyhow!("Could not read file to resume: {}", e));
|
||||
}
|
||||
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
} else if resume_only {
|
||||
anyhow::bail!("File not found and resume_only option is active");
|
||||
}
|
||||
|
||||
Self::create_new_file(path, file_size, etag, url, chunk_size)
|
||||
}
|
||||
|
||||
fn create_new_file(
|
||||
path: &str,
|
||||
file_size: u64,
|
||||
etag: &str,
|
||||
url: &str,
|
||||
chunk_size: u32,
|
||||
) -> Result<Self> {
|
||||
let header = HydraHeader::new(file_size, etag, url, chunk_size);
|
||||
let file = File::create(path)?;
|
||||
file.set_len(HEADER_SIZE as u64 + file_size)?;
|
||||
|
||||
let mut writer = BufWriter::new(file);
|
||||
header.write_to_file(&mut writer)?;
|
||||
writer.flush()?;
|
||||
|
||||
Ok(Self {
|
||||
header,
|
||||
file_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn get_incomplete_chunks(&self) -> Vec<(u64, u64)> {
|
||||
self.header.get_incomplete_chunks()
|
||||
}
|
||||
|
||||
fn set_chunk_complete(&mut self, chunk_index: usize) -> Result<()> {
|
||||
self.header.set_chunk_complete(chunk_index)?;
|
||||
|
||||
let file = OpenOptions::new().write(true).open(&self.file_path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
|
||||
let bitmap_offset = 5 + 1 + 8 + 32 + 32 + 4 + 4;
|
||||
writer.seek(SeekFrom::Start(bitmap_offset as u64))?;
|
||||
|
||||
let bitmap_bytes = self.header.chunks_bitmap.as_raw_slice();
|
||||
writer.write_all(bitmap_bytes)?;
|
||||
writer.flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_download_complete(&self) -> bool {
|
||||
self.header.is_download_complete()
|
||||
}
|
||||
|
||||
fn finalize_download(&self) -> Result<()> {
|
||||
if !self.is_download_complete() {
|
||||
anyhow::bail!("Download is not complete");
|
||||
}
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&self.file_path)?;
|
||||
|
||||
let file_size = self.header.file_size;
|
||||
|
||||
let buffer_size = 64 * 1024 * 1024;
|
||||
let mut buffer = vec![0u8; buffer_size.min(file_size as usize)];
|
||||
|
||||
let mut file = BufReader::new(file);
|
||||
let mut write_pos = 0;
|
||||
let mut read_pos = HEADER_SIZE as u64;
|
||||
|
||||
while read_pos < (HEADER_SIZE as u64 + file_size) {
|
||||
file.seek(SeekFrom::Start(read_pos))?;
|
||||
|
||||
let bytes_read = file.read(&mut buffer)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
file.get_mut().seek(SeekFrom::Start(write_pos))?;
|
||||
|
||||
file.get_mut().write_all(&buffer[..bytes_read])?;
|
||||
|
||||
read_pos += bytes_read as u64;
|
||||
write_pos += bytes_read as u64;
|
||||
}
|
||||
|
||||
file.get_mut().set_len(file_size)?;
|
||||
file.get_mut().flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = CliArgs::parse();
|
||||
|
||||
let config = DownloadConfig {
|
||||
url: args.url.clone(),
|
||||
output_path: args.output,
|
||||
num_connections: args.connections,
|
||||
chunk_size: args.chunk_size * 1024 * 1024,
|
||||
buffer_size: args.buffer_size * 1024 * 1024,
|
||||
verbose: args.verbose,
|
||||
silent: args.silent,
|
||||
log: args.log,
|
||||
force_new: args.force_new,
|
||||
resume_only: args.resume_only,
|
||||
headers: args.header,
|
||||
force_download: args.force_download,
|
||||
};
|
||||
|
||||
if config.force_new && config.resume_only {
|
||||
eprintln!("Error: --force-new and --resume-only options cannot be used together");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let downloader = Downloader {
|
||||
client: Client::new(),
|
||||
config,
|
||||
};
|
||||
|
||||
if downloader.config.should_log() {
|
||||
println!(
|
||||
"Starting download with {} connections, chunk size: {}MB, buffer: {}MB",
|
||||
downloader.config.num_connections, args.chunk_size, args.buffer_size
|
||||
);
|
||||
println!("URL: {}", args.url);
|
||||
|
||||
if downloader.config.force_new {
|
||||
println!("Forcing new download, ignoring existing files");
|
||||
} else if downloader.config.resume_only {
|
||||
println!("Only resuming existing download");
|
||||
} else {
|
||||
println!("Resuming download if possible");
|
||||
}
|
||||
}
|
||||
|
||||
downloader.download().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,7 +2,6 @@ const { default: axios } = require("axios");
|
||||
const util = require("node:util");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const exec = util.promisify(require("node:child_process").exec);
|
||||
|
||||
@@ -47,79 +46,11 @@ const downloadLudusavi = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const downloadAria2WindowsAndLinux = async () => {
|
||||
const file =
|
||||
process.platform === "win32"
|
||||
? "aria2-1.37.0-win-64bit-build1.zip"
|
||||
: "aria2-1.37.0-1-x86_64.pkg.tar.zst";
|
||||
|
||||
const downloadUrl =
|
||||
process.platform === "win32"
|
||||
? `https://github.com/aria2/aria2/releases/download/release-1.37.0/${file}`
|
||||
: "https://archlinux.org/packages/extra/x86_64/aria2/download/";
|
||||
|
||||
console.log(`Downloading ${file}...`);
|
||||
|
||||
const response = await axios.get(downloadUrl, { responseType: "stream" });
|
||||
|
||||
const stream = response.data.pipe(fs.createWriteStream(file));
|
||||
|
||||
stream.on("finish", async () => {
|
||||
console.log(`Downloaded ${file}, extracting...`);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await exec(`npx extract-zip ${file}`);
|
||||
console.log("Extracted. Renaming folder...");
|
||||
|
||||
fs.mkdirSync("aria2");
|
||||
fs.copyFileSync(
|
||||
path.join(file.replace(".zip", ""), "aria2c.exe"),
|
||||
"aria2/aria2c.exe"
|
||||
);
|
||||
fs.rmSync(file.replace(".zip", ""), { recursive: true });
|
||||
} else {
|
||||
await exec(`tar --zstd -xvf ${file} usr/bin/aria2c`);
|
||||
console.log("Extracted. Copying binary file...");
|
||||
fs.mkdirSync("aria2");
|
||||
fs.copyFileSync("usr/bin/aria2c", "aria2/aria2c");
|
||||
fs.rmSync("usr", { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`Extracted ${file}, removing compressed downloaded file...`);
|
||||
fs.rmSync(file);
|
||||
});
|
||||
};
|
||||
|
||||
const copyAria2Macos = async () => {
|
||||
console.log("Checking if aria2 is installed...");
|
||||
|
||||
const isAria2Installed = spawnSync("which", ["aria2c"]).status;
|
||||
|
||||
if (isAria2Installed != 0) {
|
||||
console.log("Please install aria2");
|
||||
console.log("brew install aria2");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Copying aria2 binary...");
|
||||
fs.mkdirSync("aria2");
|
||||
await exec(`cp $(which aria2c) aria2/aria2c`);
|
||||
};
|
||||
|
||||
const copyAria2 = () => {
|
||||
const aria2Path =
|
||||
process.platform === "win32" ? "aria2/aria2c.exe" : "aria2/aria2c";
|
||||
|
||||
if (fs.existsSync(aria2Path)) {
|
||||
console.log("Aria2 already exists, skipping download...");
|
||||
return;
|
||||
}
|
||||
if (process.platform == "darwin") {
|
||||
copyAria2Macos();
|
||||
} else {
|
||||
downloadAria2WindowsAndLinux();
|
||||
}
|
||||
};
|
||||
|
||||
copyAria2();
|
||||
downloadLudusavi();
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const binariesPath = path.join(__dirname, "..", "binaries");
|
||||
|
||||
fs.chmodSync(path.join(binariesPath, "7zz"), 0o755);
|
||||
fs.chmodSync(path.join(binariesPath, "7zzs"), 0o755);
|
||||
}
|
||||
|
||||
@@ -44,11 +44,21 @@
|
||||
"downloading_metadata": "Stahuji metadata: {{title}}…",
|
||||
"downloading": "Stahuji {{title}}… ({{percentage}} staženo) - Odhadovaný čas {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Stahuji {{title}}… ({{percentage}} staženo) - Počítám zbývající čas…",
|
||||
"checking_files": "Kontroluji soubory: {{title}}… ({{percentage}} ověřeno)"
|
||||
"checking_files": "Kontroluji soubory: {{title}}… ({{percentage}} ověřeno)",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "Instalace dokončena",
|
||||
"installation_complete_message": "Běžné redistribuovatelné komponenty byly úspěšně nainstalovány"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Další strana",
|
||||
"previous_page": "Předchozí strana"
|
||||
"search": "Filtr…",
|
||||
"developers": "Vývojáři",
|
||||
"genres": "Žánry",
|
||||
"tags": "Tagy",
|
||||
"publishers": "Vydavatelé",
|
||||
"download_sources": "Zdroje stahování",
|
||||
"result_count": "Výsledky: {{resultCount}}",
|
||||
"filter_count": "Dostupné: {{filterCount}}",
|
||||
"clear_filters": "Vyčistit vybrané filtry: {{filterCount}}"
|
||||
},
|
||||
"game_details": {
|
||||
"open_download_options": "Otevřít možnosti stahování",
|
||||
@@ -107,6 +117,7 @@
|
||||
"open_download_location": "Zobrazit stažené soubory",
|
||||
"create_shortcut": "Vytvořit zástupce na ploše",
|
||||
"remove_files": "Odebrat soubory",
|
||||
"clear": "Vyčistit",
|
||||
"remove_from_library_title": "Jste si jisti?",
|
||||
"remove_from_library_description": "Tohle odstraní {{game}} z vaší knihovny",
|
||||
"options": "Možnosti",
|
||||
@@ -160,6 +171,9 @@
|
||||
"loading_save_preview": "Hledání uložených her...",
|
||||
"wine_prefix": "Wine Prefix",
|
||||
"wine_prefix_description": "Wine Prefix použit pro spuštění této hry",
|
||||
"launch_options": "Možnosti spuštění",
|
||||
"launch_options_description": "Pokročilí uživatelé mohou zadat speciální parametry spuštění (experimentální funkce)",
|
||||
"launch_options_placeholder": "Žádné parametery nebyly specifikovány",
|
||||
"no_download_option_info": "Žádné informace nejsou dostupny",
|
||||
"backup_deletion_failed": "Nepovedlo se odstranit zálohu",
|
||||
"max_number_of_artifacts_reached": "Dosáhli jste maximálního počtu záloh pro tuto hru",
|
||||
@@ -167,7 +181,23 @@
|
||||
"manage_files_description": "Spravovat, které soubory budou zálohovány a obnoveny",
|
||||
"select_folder": "Vybrat složku",
|
||||
"backup_from": "Zálohy z {{date}}",
|
||||
"custom_backup_location_set": "Vlastní umístění záloh nastaveno"
|
||||
"custom_backup_location_set": "Vlastní umístění záloh nastaveno",
|
||||
"automatic_backup_from": "Automatická záloha z {{date}}",
|
||||
"enable_automatic_cloud_sync": "Povolit automatické zálohy v cloudu",
|
||||
"no_directory_selected": "Žádná složka není zvolena",
|
||||
"no_write_permission": "Nemohu stahovat do této složky. Klikni sem pro více informací.",
|
||||
"reset_achievements": "Resetovat achievementy",
|
||||
"reset_achievements_description": "Toto zresetuje všechny achievementy pro hru {{game}}",
|
||||
"reset_achievements_title": "Jste si jisti?",
|
||||
"reset_achievements_success": "Achievementy úspěšně resetovány",
|
||||
"reset_achievements_error": "Nepodařilo se resetovat achievementy",
|
||||
"download_error_gofile_quota_exceeded": "Překročili jste vaši měsíční GoFile kvótu. Prosím vyčkejte na resetování kvóty.",
|
||||
"download_error_real_debrid_account_not_authorized": "Váš Real-Debrid účet není autorizován pro vytváření nových stahování. Prosím zkontrolujte nastavení vašeho účtu a zkuste to znovu.",
|
||||
"download_error_not_cached_in_real_debrid": "Toto stahování není dostupné na Real-Debrid a získávání informací o stahování z Real-Debrid není zatím dostupné.",
|
||||
"download_error_not_cached_in_torbox": "Toto stahování není dostupné na Torbox a získávání informací o stahování z Torbox není zatím dostupné.",
|
||||
"game_removed_from_favorites": "Hra odebrána z oblíbených",
|
||||
"game_added_to_favorites": "Hra přidána do oblíbených",
|
||||
"automatically_extract_downloaded_files": "Automaticky rozbalit stažené soubory"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Aktivovat hydru",
|
||||
@@ -200,7 +230,13 @@
|
||||
"queued": "V řadě",
|
||||
"no_downloads_title": "Prázdno..",
|
||||
"no_downloads_description": "Ještě jsi zatím nic nestáhl přes Hydru, ale furt není pozdě začít.",
|
||||
"checking_files": "Kontroluji soubory…"
|
||||
"checking_files": "Kontroluji soubory…",
|
||||
"seeding": "Seedování",
|
||||
"stop_seeding": "Zastavování seedování",
|
||||
"resume_seeding": "Obnovit seedování",
|
||||
"options": "Spravovat",
|
||||
"extract": "Rozbalit soubory",
|
||||
"extracting": "Rozbalování souborů…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Umístění stahování",
|
||||
@@ -261,9 +297,65 @@
|
||||
"must_be_valid_url": "Zdroj musí být platký odkaz URL",
|
||||
"blocked_users": "Zablokovaní uživatelé",
|
||||
"user_unblocked": "Uživatel byl odblokován",
|
||||
"enable_achievement_notifications": "Když je odemknut achievement",
|
||||
"enable_achievement_notifications": "Když je odemčen achievement",
|
||||
"launch_minimized": "Spustit v minimalizovaném režimu",
|
||||
"disable_nsfw_alert": "Deaktivovat upozornění na nevhodný obsah"
|
||||
"disable_nsfw_alert": "Deaktivovat upozornění na nevhodný obsah",
|
||||
"seed_after_download_complete": "Seedovat až skončí stahování",
|
||||
"show_hidden_achievement_description": "Zobrazit popisy skrytých achievementů dříve, než jsou odemčeny",
|
||||
"account": "Účet",
|
||||
"no_users_blocked": "Nemáte žádného uživatele zablokovaného",
|
||||
"subscription_active_until": "Vaše Hydra cloud předplatné je platné do {{date}}",
|
||||
"manage_subscription": "Spravovat předplatné",
|
||||
"update_email": "Změnit email",
|
||||
"update_password": "Změnit heslo",
|
||||
"current_email": "Současný email:",
|
||||
"no_email_account": "Zatím nemáte nastavený email",
|
||||
"account_data_updated_successfully": "Data vašeho účtu byly úspěšně upraveny",
|
||||
"renew_subscription": "Obnovit předplatné Hydra Cloud",
|
||||
"subscription_expired_at": "Vaše předplatné vypršelo {{date}}",
|
||||
"no_subscription": "Užijte si Hydru v nejlepší možné podobě",
|
||||
"become_subscriber": "Připojit se k předplatnému Hydra Cloud",
|
||||
"subscription_renew_cancelled": "Automatické obnovování je zrušenu",
|
||||
"subscription_renews_on": "Vaše předplatné se obnoví {{date}}",
|
||||
"bill_sent_until": "Vaše příští faktura bude odeslána nejpozději do tohoto dne",
|
||||
"no_themes": "Vypadá to že ještě nemáte žádné vzhledy, ale nebojte, klikněte sem pro vytvoření vašeho prvního mistrovského díla!",
|
||||
"editor_tab_code": "Kód",
|
||||
"editor_tab_info": "Info",
|
||||
"editor_tab_save": "Uložit",
|
||||
"web_store": "Webový obchod",
|
||||
"clear_themes": "Vyčistit",
|
||||
"create_theme": "Vytvořit",
|
||||
"create_theme_modal_title": "Vytvořit vlastní vzhled",
|
||||
"create_theme_modal_description": "Vytvořte si vlastní styl, abyste si mohli ozdobit Hydru",
|
||||
"theme_name": "Název",
|
||||
"insert_theme_name": "Vložte název vzhledu",
|
||||
"set_theme": "Nastavit vzhled",
|
||||
"unset_theme": "Zrušit vzhled",
|
||||
"delete_theme": "Odstranit vzhled",
|
||||
"edit_theme": "Upravit vzhled",
|
||||
"delete_all_themes": "Smazat všechny vzhledy",
|
||||
"delete_all_themes_description": "Toto smaže všechny vaše vzhledy",
|
||||
"delete_theme_description": "Toto smaže vzhled {{theme}}",
|
||||
"cancel": "Zrušit",
|
||||
"appearance": "Vzhled",
|
||||
"enable_torbox": "Povolit TorBox",
|
||||
"torbox_description": "TorBox je prémiový seedbox server který se vyrovná i těm nejlepším seedbox serverům na trhu.",
|
||||
"torbox_account_linked": "TorBox účet propojen",
|
||||
"create_real_debrid_account": "Klikni sem pokud ještě nemáš Real-Debrid účet",
|
||||
"create_torbox_account": "Klikni sem pokud ještě nemáš TorBox účet",
|
||||
"real_debrid_account_linked": "Real-Debrid účet propojen",
|
||||
"name_min_length": "Název vzhledu musí být minimálně 3 znaky dlouhý",
|
||||
"import_theme": "Vložit vzhled",
|
||||
"import_theme_description": "Chystáte se vložit vzhled {{theme}} z obchodu vzhledů",
|
||||
"error_importing_theme": "Nastala chyba při vkládání vzhledu",
|
||||
"theme_imported": "Vzhled úspěšně vložen",
|
||||
"enable_friend_request_notifications": "Při obdržení žádosti o přátelství",
|
||||
"enable_auto_install": "Automaticky stahovat aktualizace",
|
||||
"common_redist": "Běžné redistribuovatelné komponenty",
|
||||
"common_redist_description": "Běžné redistribuovatelné komponenty jsou potřeba pro spuštění určitých her. Je doporučeno je nainstalovat, aby se předešlo problémům.",
|
||||
"install_common_redist": "Nainstalovat",
|
||||
"installing_common_redist": "Instalování…",
|
||||
"show_download_speed_in_megabytes": "Ukázat rychlost stahování v megabytech"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Stahování dokončeno",
|
||||
@@ -273,14 +365,20 @@
|
||||
"repack_count_other": "{{count}} repacky přidány",
|
||||
"new_update_available": "Version {{version}} je dostupná",
|
||||
"restart_to_install_update": "Restartuj Hydru pro aktualizaci",
|
||||
"notification_achievement_unlocked_title": "Achievement pro {{game}} byl odemknut",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} a dalších {{count}} byly odemknuty"
|
||||
"notification_achievement_unlocked_title": "Achievement pro {{game}} byl odemčen",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} a dalších {{count}} byly odemčeny",
|
||||
"new_friend_request_description": "Obdrželi jste novou žádost o přátelství",
|
||||
"new_friend_request_title": "Nová žádost o přátelství",
|
||||
"extraction_complete": "Rozbalování dokončeno",
|
||||
"game_extracted": "{{title}} úspěšně rozbaleno"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Otevřít Hydru",
|
||||
"quit": "Odejít"
|
||||
},
|
||||
"game_card": {
|
||||
"available_one": "Dostupné",
|
||||
"available_other": "Dostupné",
|
||||
"no_downloads": "Žádné možnosti stahování nenalezeny"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
@@ -363,7 +461,17 @@
|
||||
"your_friend_code": "Tvůj kód přítele:",
|
||||
"upload_banner": "Nahrát banner profilu",
|
||||
"uploading_banner": "Nahrávání banneru",
|
||||
"background_image_updated": "Obrázek pozadí byl změněn"
|
||||
"background_image_updated": "Obrázek pozadí byl změněn",
|
||||
"stats": "Statistiky",
|
||||
"achievements": "Achievementy",
|
||||
"games": "Hry",
|
||||
"top_percentile": "Top {{percentile}}%",
|
||||
"ranking_updated_weekly": "Žebříčky jsou aktualizovány každý týden",
|
||||
"playing": "Hraje {{game}}",
|
||||
"achievements_unlocked": "Achievements odemčen",
|
||||
"earned_points": "Získané body",
|
||||
"show_achievements_on_profile": "Zobrazit vaše odemčené achievementy na profilu",
|
||||
"show_points_on_profile": "Zobrazit vaše získané body na profilu"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Achievement odemčen",
|
||||
@@ -373,7 +481,12 @@
|
||||
"subscription_needed": "Je vyžadováno předplatné Hydra Cloud pro zobrazení tohoto obsahu",
|
||||
"new_achievements_unlocked": "Odemčeno {{achievementCount}} nových achievementů z {{gameCount}} her",
|
||||
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementů",
|
||||
"achievements_unlocked_for_game": "Odemčeno {{achievementCount}} nových achievementů pro {{gameTitle}}"
|
||||
"achievements_unlocked_for_game": "Odemčeno {{achievementCount}} nových achievementů pro {{gameTitle}}",
|
||||
"hidden_achievement_tooltip": "Toho je skrytý achievement",
|
||||
"achievement_earn_points": "Získej {{points}} bodů tímto achievementem",
|
||||
"earned_points": "Získané body",
|
||||
"available_points": "Dostupné body:",
|
||||
"how_to_earn_achievements_points": "Jak získat body achievementů?"
|
||||
},
|
||||
"hydra_cloud": {
|
||||
"subscription_tour_title": "Předplatné Hydra Cloud",
|
||||
@@ -383,6 +496,10 @@
|
||||
"animated_profile_picture": "Animované profilové obrázky",
|
||||
"premium_support": "Prémiová podpora",
|
||||
"show_and_compare_achievements": "Zobraz a porovnej achievementy s ostatními uživateli",
|
||||
"animated_profile_banner": "Animovaný banner na profilu"
|
||||
"animated_profile_banner": "Animovaný banner na profilu",
|
||||
"hydra_cloud": "Hydra Cloud",
|
||||
"hydra_cloud_feature_found": "Právě jste objevili funkci předplatného Hydra Cloud!",
|
||||
"learn_more": "Zjistit více",
|
||||
"debrid_description": "Stahovat až 4x rychleji s Nimbus"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@
|
||||
"downloading_metadata": "Downloading {{title}} metadata…",
|
||||
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
|
||||
"checking_files": "Checking {{title}} files… ({{percentage}} complete)"
|
||||
"checking_files": "Checking {{title}} files… ({{percentage}} complete)",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "Installation complete",
|
||||
"installation_complete_message": "Common redistributables installed successfully"
|
||||
},
|
||||
"catalogue": {
|
||||
"search": "Filter…",
|
||||
@@ -193,7 +196,8 @@
|
||||
"download_error_not_cached_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.",
|
||||
"download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available.",
|
||||
"game_removed_from_favorites": "Game removed from favorites",
|
||||
"game_added_to_favorites": "Game added to favorites"
|
||||
"game_added_to_favorites": "Game added to favorites",
|
||||
"automatically_extract_downloaded_files": "Automatically extract downloaded files"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activate Hydra",
|
||||
@@ -230,7 +234,9 @@
|
||||
"seeding": "Seeding",
|
||||
"stop_seeding": "Stop seeding",
|
||||
"resume_seeding": "Resume seeding",
|
||||
"options": "Manage"
|
||||
"options": "Manage",
|
||||
"extract": "Extract files",
|
||||
"extracting": "Extracting files…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Downloads path",
|
||||
@@ -344,7 +350,12 @@
|
||||
"error_importing_theme": "Error importing theme",
|
||||
"theme_imported": "Theme imported successfully",
|
||||
"enable_friend_request_notifications": "When a friend request is received",
|
||||
"enable_auto_install": "Download updates automatically"
|
||||
"enable_auto_install": "Download updates automatically",
|
||||
"common_redist": "Common redistributables",
|
||||
"common_redist_description": "Common redistributables are required to run some games. Installing them is recommended to avoid issues.",
|
||||
"install_common_redist": "Install",
|
||||
"installing_common_redist": "Installing…",
|
||||
"show_download_speed_in_megabytes": "Show download speed in megabytes per second"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Download complete",
|
||||
@@ -357,7 +368,9 @@
|
||||
"notification_achievement_unlocked_title": "Achievement unlocked for {{game}}",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked",
|
||||
"new_friend_request_description": "You have received a new friend request",
|
||||
"new_friend_request_title": "New friend request"
|
||||
"new_friend_request_title": "New friend request",
|
||||
"extraction_complete": "Extraction complete",
|
||||
"game_extracted": "{{title}} extracted successfully"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Open Hydra",
|
||||
@@ -486,6 +499,7 @@
|
||||
"animated_profile_banner": "Animated profile banner",
|
||||
"hydra_cloud": "Hydra Cloud",
|
||||
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
|
||||
"learn_more": "Learn More"
|
||||
"learn_more": "Learn More",
|
||||
"debrid_description": "Download up to 4x faster with Nimbus"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@
|
||||
"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…",
|
||||
"checking_files": "Verificando arquivos de {{title}}…"
|
||||
"checking_files": "Verificando arquivos de {{title}}…",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "Instalação concluída",
|
||||
"installation_complete_message": "Componentes recomendados instalados com sucesso"
|
||||
},
|
||||
"game_details": {
|
||||
"open_download_options": "Ver opções de download",
|
||||
@@ -182,7 +185,8 @@
|
||||
"download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.",
|
||||
"download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível.",
|
||||
"game_removed_from_favorites": "Jogo removido dos favoritos",
|
||||
"game_added_to_favorites": "Jogo adicionado aos favoritos"
|
||||
"game_added_to_favorites": "Jogo adicionado aos favoritos",
|
||||
"automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
@@ -219,7 +223,9 @@
|
||||
"seeding": "Semeando",
|
||||
"stop_seeding": "Parar de semear",
|
||||
"resume_seeding": "Semear",
|
||||
"options": "Gerenciar"
|
||||
"options": "Gerenciar",
|
||||
"extract": "Extrair arquivos",
|
||||
"extracting": "Extraindo arquivos…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Diretório dos downloads",
|
||||
@@ -331,7 +337,12 @@
|
||||
"error_importing_theme": "Erro ao importar tema",
|
||||
"theme_imported": "Tema importado com sucesso",
|
||||
"enable_friend_request_notifications": "Quando um pedido de amizade é recebido",
|
||||
"enable_auto_install": "Baixar atualizações automaticamente"
|
||||
"enable_auto_install": "Baixar atualizações automaticamente",
|
||||
"common_redist": "Componentes recomendados",
|
||||
"common_redist_description": "Componentes recomendados são necessários para executar alguns jogos. A instalação deles é recomendada para evitar problemas.",
|
||||
"install_common_redist": "Instalar",
|
||||
"installing_common_redist": "Instalando…",
|
||||
"show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Download concluído",
|
||||
@@ -342,7 +353,9 @@
|
||||
"new_update_available": "Versão {{version}} disponível",
|
||||
"restart_to_install_update": "Reinicie o Hydra para instalar a nova versão",
|
||||
"new_friend_request_title": "Novo pedido de amizade",
|
||||
"new_friend_request_description": "Você recebeu um novo pedido de amizade"
|
||||
"new_friend_request_description": "Você recebeu um novo pedido de amizade",
|
||||
"extraction_complete": "Extração concluída",
|
||||
"game_extracted": "{{title}} extraído com sucesso"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
@@ -481,6 +494,7 @@
|
||||
"animated_profile_banner": "Banner animado no perfil",
|
||||
"cloud_saving": "Saves de jogos em nuvem",
|
||||
"hydra_cloud_feature_found": "Você descobriu uma funcionalidade Hydra Cloud!",
|
||||
"learn_more": "Saiba mais"
|
||||
"learn_more": "Saiba mais",
|
||||
"debrid_description": "Baixe até 4x mais rápido com Nimbus"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"hot": "Сейчас популярно",
|
||||
"start_typing": "Начинаю вводить текст...",
|
||||
"weekly": "📅 Лучшие игры недели",
|
||||
"achievements": "🏆 Игры, в которых нужно победить"
|
||||
"achievements": "🏆 Игры с достижениями"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Каталог",
|
||||
@@ -36,7 +36,7 @@
|
||||
"downloads": "Загрузки",
|
||||
"search_results": "Результаты поиска",
|
||||
"settings": "Настройки",
|
||||
"version_available_install": "Доступна версия {{version}}. Нажмите здесь для перезапуска и установки.",
|
||||
"version_available_install": "Доступна версия {{version}}. Нажмите здесь для установки.",
|
||||
"version_available_download": "Доступна версия {{version}}. Нажмите здесь для загрузки."
|
||||
},
|
||||
"bottom_panel": {
|
||||
@@ -44,13 +44,16 @@
|
||||
"downloading_metadata": "Загрузка метаданных {{title}}…",
|
||||
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…",
|
||||
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)"
|
||||
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "Установка завершена",
|
||||
"installation_complete_message": "Библиотеки успешно установлены"
|
||||
},
|
||||
"catalogue": {
|
||||
"search": "Фильтр…",
|
||||
"developers": "Разработчики",
|
||||
"genres": "Жанры",
|
||||
"tags": "Маркеры",
|
||||
"tags": "Теги",
|
||||
"publishers": "Издательства",
|
||||
"download_sources": "Источники загрузки",
|
||||
"result_count": "{{resultCount}} результатов",
|
||||
@@ -183,15 +186,18 @@
|
||||
"custom_backup_location_set": "Установлено настраиваемое местоположение резервной копии",
|
||||
"no_directory_selected": "Не выбран каталог",
|
||||
"no_write_permission": "Невозможно загрузить в эту директорию. Нажмите здесь, чтобы узнать больше.",
|
||||
"reset_achievements": "Сброс достижений",
|
||||
"reset_achievements_description": "Это сбросит все достижения для {{game}}",
|
||||
"reset_achievements_title": "Вы уверены?",
|
||||
"reset_achievements_success": "Достижения успешно сброшены",
|
||||
"reset_achievements_error": "Не удалось сбросить достижения",
|
||||
"download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.",
|
||||
"download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.",
|
||||
"download_error_not_cached_in_real_debrid": "Эта загрузка недоступна на Real-Debrid, а опрос статуса загрузки с Real-Debrid пока недоступен.",
|
||||
"download_error_not_cached_in_torbox": "Эта загрузка недоступна на Torbox, и опросить статус загрузки с Torbox пока невозможно.",
|
||||
"download_error_not_cached_in_real_debrid": "Эта загрузка недоступна на Real-Debrid, и получение статуса загрузки с Real-Debrid пока недоступно.",
|
||||
"download_error_not_cached_in_torbox": "Эта загрузка недоступна на Torbox, и получить статус загрузки с Torbox пока невозможно.",
|
||||
"game_added_to_favorites": "Игра добавлена в избранное",
|
||||
"game_removed_from_favorites": "Игра удалена из избранного"
|
||||
"game_removed_from_favorites": "Игра удалена из избранного",
|
||||
"automatically_extract_downloaded_files": "Автоматическая распаковка загруженных файлов"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Активировать Hydra",
|
||||
@@ -228,7 +234,9 @@
|
||||
"seeding": "Раздача",
|
||||
"stop_seeding": "Остановить раздачу",
|
||||
"resume_seeding": "Продолжить раздачу",
|
||||
"options": "Управлять"
|
||||
"options": "Управлять",
|
||||
"extract": "Распаковать файлы",
|
||||
"extracting": "Распаковка файлов…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Путь загрузок",
|
||||
@@ -267,15 +275,15 @@
|
||||
"download_source_up_to_date": "Обновлён",
|
||||
"download_source_errored": "Ошибка",
|
||||
"sync_download_sources": "Обновить источники",
|
||||
"removed_download_source": "Источник загрузок удален",
|
||||
"removed_download_source": "Источник удален",
|
||||
"cancel_button_confirmation_delete_all_sources": "Нет",
|
||||
"confirm_button_confirmation_delete_all_sources": "Да, удалить все",
|
||||
"description_confirmation_delete_all_sources": "Вы удалите все источники загрузки",
|
||||
"title_confirmation_delete_all_sources": "Удалить все источники загрузки",
|
||||
"removed_download_sources": "Шрифты удалены",
|
||||
"button_delete_all_sources": "Удалить все источники загрузки",
|
||||
"added_download_source": "Источник загрузок добавлен",
|
||||
"download_sources_synced": "Все источники загрузок синхронизированы",
|
||||
"description_confirmation_delete_all_sources": "Вы удалите все источники",
|
||||
"title_confirmation_delete_all_sources": "Удалить все источники",
|
||||
"removed_download_sources": "Источники удалены",
|
||||
"button_delete_all_sources": "Удалить все источники",
|
||||
"added_download_source": "Источник добавлен",
|
||||
"download_sources_synced": "Все источники обновлены",
|
||||
"insert_valid_json_url": "Вставьте действительный URL JSON-файла",
|
||||
"found_download_option_zero": "Не найдено вариантов загрузки",
|
||||
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
|
||||
@@ -283,7 +291,7 @@
|
||||
"import": "Импортировать",
|
||||
"blocked_users": "Заблокированные пользователи",
|
||||
"friends_only": "Только для друзей",
|
||||
"must_be_valid_url": "Источник должен быть действительным URL-адресом.",
|
||||
"must_be_valid_url": "У источника должен быть правильный URL",
|
||||
"privacy": "Конфиденциальность",
|
||||
"private": "Частный",
|
||||
"profile_visibility": "Видимость профиля",
|
||||
@@ -334,13 +342,20 @@
|
||||
"torbox_description": "TorBox - это ваш премиум-сервис, конкурирующий даже с лучшими серверами на рынке.",
|
||||
"torbox_account_linked": "Аккаунт TorBox привязан",
|
||||
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
|
||||
"create_real_debrid_account": "Нажмите здесь, если у вас еще нет аккаунта Real-Debrid",
|
||||
"create_torbox_account": "Нажмите здесь, если у вас еще нет учетной записи TorBox",
|
||||
"name_min_length": "Название темы должно содержать не менее 3 символов",
|
||||
"import_theme": "Импортировать тему",
|
||||
"import_theme_description": "Вы импортируете {{theme}} из магазина тем",
|
||||
"error_importing_theme": "Ошибка при импорте темы",
|
||||
"theme_imported": "Тема успешно импортирована",
|
||||
"enable_friend_request_notifications": "При получении запроса на добавление в друзья",
|
||||
"enable_auto_install": "Загружать обновления автоматически"
|
||||
"enable_auto_install": "Загружать обновления автоматически",
|
||||
"common_redist": "Библиотеки",
|
||||
"common_redist_description": "Для запуска некоторых игр требуются библиотеки. Во избежание проблем рекомендуется установить их.",
|
||||
"install_common_redist": "Установить",
|
||||
"installing_common_redist": "Установка…",
|
||||
"show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Загрузка завершена",
|
||||
@@ -353,7 +368,9 @@
|
||||
"notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}",
|
||||
"notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}",
|
||||
"new_friend_request_title": "Новый запрос на добавление в друзья",
|
||||
"new_friend_request_description": "Вы получили новый запрос на добавление в друзья"
|
||||
"new_friend_request_description": "Вы получили новый запрос на добавление в друзья",
|
||||
"extraction_complete": "Распаковка завершена",
|
||||
"game_extracted": "{{title}} успешно распакован"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Открыть Hydra",
|
||||
@@ -446,11 +463,13 @@
|
||||
"uploading_banner": "Загрузка баннера...",
|
||||
"background_image_updated": "Фоновое изображение обновлено",
|
||||
"stats": "Статистика",
|
||||
"achievements": "Достижения",
|
||||
"games": "Игры",
|
||||
"top_percentile": "Топ {{percentile}}%",
|
||||
"ranking_updated_weekly": "Рейтинг обновляется еженедельно",
|
||||
"playing": "Играет в {{game}}",
|
||||
"achievements_unlocked": "Достижения разблокированы",
|
||||
"earned_points": "Заработано очков:",
|
||||
"show_achievements_on_profile": "Покажите свои достижения в профиле",
|
||||
"show_points_on_profile": "Показывать заработанные очки в своем профиле"
|
||||
},
|
||||
@@ -480,6 +499,7 @@
|
||||
"animated_profile_banner": "Анимированный баннер профиля",
|
||||
"hydra_cloud": "Hydra Cloud",
|
||||
"hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!",
|
||||
"learn_more": "Подробнее"
|
||||
"learn_more": "Подробнее",
|
||||
"debrid_description": "Скачивайте в 4 раза быстрее с Nimbus"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@
|
||||
"downloading_metadata": "{{title}} meta verileri indiriliyor…",
|
||||
"downloading": "{{title}} indiriliyor… ({{percentage}} tamamlandı) - Tamamlanma: {{eta}} - Hız: {{speed}}",
|
||||
"calculating_eta": "{{title}} indiriliyor… ({{percentage}} tamamlandı) - Kalan süre hesaplanıyor…",
|
||||
"checking_files": "{{title}} dosyaları kontrol ediliyor… ({{percentage}} tamamlandı)"
|
||||
"checking_files": "{{title}} dosyaları kontrol ediliyor… ({{percentage}} tamamlandı)",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "İndirme tamamlandı",
|
||||
"installation_complete_message": "Genel bağımlılıklar başarıyla yüklendi."
|
||||
},
|
||||
"catalogue": {
|
||||
"search": "Filtrele…",
|
||||
@@ -193,7 +196,8 @@
|
||||
"download_error_not_cached_in_real_debrid": "Bu indirme Real-Debrid üzerinde mevcut değil ve Real-Debrid'den indirme durumu henüz sorgulanamıyor.",
|
||||
"download_error_not_cached_in_torbox": "Bu indirme Torbox'ta mevcut değil ve Torbox'tan indirme durumu henüz sorgulanamıyor.",
|
||||
"game_removed_from_favorites": "Oyun favorilerden silindi",
|
||||
"game_added_to_favorites": "Oyun favorilere eklendi"
|
||||
"game_added_to_favorites": "Oyun favorilere eklendi",
|
||||
"automatically_extract_downloaded_files": "Yüklenmiş dosyaları otomatik olarak çıkart"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Hydra'yı Aktive Et",
|
||||
@@ -230,7 +234,9 @@
|
||||
"seeding": "Paylaşılıyor",
|
||||
"stop_seeding": "Paylaşımı durdur",
|
||||
"resume_seeding": "Paylaşımı sürdür",
|
||||
"options": "Yönet"
|
||||
"options": "Yönet",
|
||||
"extract": "Dosyaları çıkart",
|
||||
"extracting": "Dosyalar çıkartılıyor…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "İndirme yolu",
|
||||
@@ -334,13 +340,21 @@
|
||||
"appearance": "Görünüm",
|
||||
"enable_torbox": "Torbox'u etkinleştir",
|
||||
"torbox_description": "TorBox, piyasadaki en iyi sunucularla bile rekabet edebilen premium seedbox hizmetinizdir.",
|
||||
"torbox_account_linked": "TorBox hesabı bağlando",
|
||||
"real_debrid_account_linked": "Real-Debrid hesabı bağlando",
|
||||
"torbox_account_linked": "TorBox hesabı bağlandı",
|
||||
"create_real_debrid_account": "Henüz bir Real-Debrid hesabınız yoksa buraya tıklayın",
|
||||
"create_torbox_account": "Henüz bir TorBox hesabınız yoksa buraya tıklayın",
|
||||
"real_debrid_account_linked": "Real-Debrid hesabı bağlandı",
|
||||
"name_min_length": "Tema ismi en az 3 karakter uzunluğunda olmalıdır",
|
||||
"import_theme": "Temayı içe aktar",
|
||||
"import_theme_description": "{{theme}} teması, tema mağazasından içeri aktarılacak",
|
||||
"error_importing_theme": "Temayı içe aktarmada bir sorun oluştu",
|
||||
"theme_imported": "Tema başarıyla içe aktarıldı"
|
||||
"theme_imported": "Tema başarıyla içe aktarıldı",
|
||||
"enable_friend_request_notifications": "Bir arkadaşlık isteği alındığında",
|
||||
"enable_auto_install": "Güncellemeleri otomatik yükle",
|
||||
"common_redist": "Ortak bağımlılıklar",
|
||||
"common_redist_description": "Bazı oyunların çalışabilmesi için genel bağımlılıklar gereklidir. Sorun yaşamamak için bunların yüklenmesi önerilir.",
|
||||
"install_common_redist": "Yükle",
|
||||
"installing_common_redist": "Yükleniyor…"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "İndirme tamamlandı",
|
||||
@@ -351,7 +365,11 @@
|
||||
"new_update_available": "{{version}} sürümü mevcut",
|
||||
"restart_to_install_update": "Güncellemeyi yüklemek için Hydra'yı yeniden başlatın",
|
||||
"notification_achievement_unlocked_title": "{{game}} için başarım kilidi açıldı",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} ve diğer {{count}} başarım açıldı"
|
||||
"notification_achievement_unlocked_body": "{{achievement}} ve diğer {{count}} başarım açıldı",
|
||||
"new_friend_request_description": "Yeni bir arkadaşlık isteğin var",
|
||||
"new_friend_request_title": "Yeni arkadaşlık isteği",
|
||||
"extraction_complete": "Çıkartma tamamlandı",
|
||||
"game_extracted": "{{title}} başarıyla çıkartıldı"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Hydra'yı Aç",
|
||||
|
||||
@@ -12,10 +12,9 @@ export const levelDatabasePath = path.join(
|
||||
`hydra-db${isStaging ? "-staging" : ""}`
|
||||
);
|
||||
|
||||
export const databaseDirectory = path.join(app.getPath("appData"), "hydra");
|
||||
export const databasePath = path.join(
|
||||
databaseDirectory,
|
||||
isStaging ? "hydra_test.db" : "hydra.db"
|
||||
export const commonRedistPath = path.join(
|
||||
app.getPath("userData"),
|
||||
"CommonRedist"
|
||||
);
|
||||
|
||||
export const logsPath = path.join(app.getPath("userData"), "logs");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CloudSync } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
import type { GameShop } from "@types";
|
||||
import { t } from "i18next";
|
||||
import { format } from "date-fns";
|
||||
import i18next, { t } from "i18next";
|
||||
import { formatDate } from "date-fns";
|
||||
|
||||
const uploadSaveGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -10,13 +10,15 @@ const uploadSaveGame = async (
|
||||
shop: GameShop,
|
||||
downloadOptionTitle: string | null
|
||||
) => {
|
||||
const { language } = i18next;
|
||||
|
||||
return CloudSync.uploadSaveGame(
|
||||
objectId,
|
||||
shop,
|
||||
downloadOptionTitle,
|
||||
t("backup_from", {
|
||||
ns: "game_details",
|
||||
date: format(new Date(), "dd/MM/yyyy"),
|
||||
date: formatDate(new Date(), language),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
13
src/main/events/download-sources/create-download-sources.ts
Normal file
13
src/main/events/download-sources/create-download-sources.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const createDownloadSources = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
urls: string[]
|
||||
) => {
|
||||
await HydraApi.post("/profile/download-sources", {
|
||||
urls,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("createDownloadSources", createDownloadSources);
|
||||
8
src/main/events/download-sources/get-download-sources.ts
Normal file
8
src/main/events/download-sources/get-download-sources.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.get("/profile/download-sources");
|
||||
};
|
||||
|
||||
registerEvent("getDownloadSources", getDownloadSources);
|
||||
18
src/main/events/download-sources/remove-download-source.ts
Normal file
18
src/main/events/download-sources/remove-download-source.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const removeDownloadSource = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
url?: string,
|
||||
removeAll = false
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
all: removeAll.toString(),
|
||||
});
|
||||
|
||||
if (url) params.set("url", url);
|
||||
|
||||
return HydraApi.delete(`/profile/download-sources?${params.toString()}`);
|
||||
};
|
||||
|
||||
registerEvent("removeDownloadSource", removeDownloadSource);
|
||||
@@ -20,6 +20,7 @@ import "./library/close-game";
|
||||
import "./library/delete-game-folder";
|
||||
import "./library/get-game-by-object-id";
|
||||
import "./library/get-library";
|
||||
import "./library/extract-game-download";
|
||||
import "./library/open-game";
|
||||
import "./library/open-game-executable-path";
|
||||
import "./library/open-game-installer";
|
||||
@@ -38,12 +39,15 @@ import "./misc/show-open-dialog";
|
||||
import "./misc/get-features";
|
||||
import "./misc/show-item-in-folder";
|
||||
import "./misc/get-badges";
|
||||
import "./misc/install-common-redist";
|
||||
import "./misc/can-install-common-redist";
|
||||
import "./torrenting/cancel-game-download";
|
||||
import "./torrenting/pause-game-download";
|
||||
import "./torrenting/resume-game-download";
|
||||
import "./torrenting/start-game-download";
|
||||
import "./torrenting/pause-game-seed";
|
||||
import "./torrenting/resume-game-seed";
|
||||
import "./torrenting/check-debrid-availability";
|
||||
import "./user-preferences/get-user-preferences";
|
||||
import "./user-preferences/update-user-preferences";
|
||||
import "./user-preferences/auto-launch";
|
||||
@@ -90,6 +94,9 @@ import "./themes/get-custom-theme-by-id";
|
||||
import "./themes/get-active-custom-theme";
|
||||
import "./themes/close-editor-window";
|
||||
import "./themes/toggle-custom-theme";
|
||||
import "./download-sources/create-download-sources";
|
||||
import "./download-sources/remove-download-source";
|
||||
import "./download-sources/get-download-sources";
|
||||
import { isPortableVersion } from "@main/helpers";
|
||||
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
|
||||
@@ -13,35 +13,42 @@ const deleteGameFolder = async (
|
||||
objectId: string
|
||||
): Promise<void> => {
|
||||
const downloadKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const download = await downloadsSublevel.get(downloadKey);
|
||||
|
||||
if (!download) return;
|
||||
if (!download?.folderName) return;
|
||||
|
||||
if (download.folderName) {
|
||||
const folderPath = path.join(
|
||||
download.downloadPath ?? (await getDownloadsPath()),
|
||||
download.folderName
|
||||
);
|
||||
const folderPath = path.join(
|
||||
download.downloadPath ?? (await getDownloadsPath()),
|
||||
download.folderName
|
||||
);
|
||||
|
||||
if (fs.existsSync(folderPath)) {
|
||||
const metaPath = `${folderPath}.meta`;
|
||||
|
||||
const deleteFile = async (filePath: string, isDirectory = false) => {
|
||||
if (fs.existsSync(filePath)) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fs.rm(
|
||||
folderPath,
|
||||
{ recursive: true, force: true, maxRetries: 5, retryDelay: 200 },
|
||||
filePath,
|
||||
{
|
||||
recursive: isDirectory,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 200,
|
||||
},
|
||||
(error) => {
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
reject();
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await deleteFile(folderPath, true);
|
||||
await deleteFile(metaPath);
|
||||
await downloadsSublevel.del(downloadKey);
|
||||
};
|
||||
|
||||
|
||||
46
src/main/events/library/extract-game-download.ts
Normal file
46
src/main/events/library/extract-game-download.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { GameShop } from "@types";
|
||||
import path from "node:path";
|
||||
import { GameFilesManager } from "@main/services";
|
||||
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared";
|
||||
|
||||
const extractGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
): Promise<boolean> => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const [download, game] = await Promise.all([
|
||||
downloadsSublevel.get(gameKey),
|
||||
gamesSublevel.get(gameKey),
|
||||
]);
|
||||
|
||||
if (!download || !game) return false;
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download,
|
||||
extracting: true,
|
||||
});
|
||||
|
||||
const gameFilesManager = new GameFilesManager(shop, objectId);
|
||||
|
||||
if (
|
||||
FILE_EXTENSIONS_TO_EXTRACT.some((ext) => download.folderName?.endsWith(ext))
|
||||
) {
|
||||
gameFilesManager.extractDownloadedFile();
|
||||
} else {
|
||||
gameFilesManager
|
||||
.extractFilesInDirectory(
|
||||
path.join(download.downloadPath, download.folderName!)
|
||||
)
|
||||
.then(() => {
|
||||
gameFilesManager.setExtractionComplete(false);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
registerEvent("extractGameDownload", extractGameDownload);
|
||||
@@ -21,6 +21,8 @@ const updateExecutablePath = async (
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
executablePath: parsedPath,
|
||||
automaticCloudSync:
|
||||
executablePath === null ? false : game.automaticCloudSync,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
7
src/main/events/misc/can-install-common-redist.ts
Normal file
7
src/main/events/misc/can-install-common-redist.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { CommonRedistManager } from "@main/services/common-redist-manager";
|
||||
|
||||
const canInstallCommonRedist = async (_event: Electron.IpcMainInvokeEvent) =>
|
||||
CommonRedistManager.canInstallCommonRedist();
|
||||
|
||||
registerEvent("canInstallCommonRedist", canInstallCommonRedist);
|
||||
10
src/main/events/misc/install-common-redist.ts
Normal file
10
src/main/events/misc/install-common-redist.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { CommonRedistManager } from "@main/services/common-redist-manager";
|
||||
|
||||
const installCommonRedist = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
if (await CommonRedistManager.canInstallCommonRedist()) {
|
||||
CommonRedistManager.installCommonRedist();
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("installCommonRedist", installCommonRedist);
|
||||
11
src/main/events/torrenting/check-debrid-availability.ts
Normal file
11
src/main/events/torrenting/check-debrid-availability.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { HydraDebridClient } from "@main/services/download/hydra-debrid";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const checkDebridAvailability = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
magnets: string[]
|
||||
) => {
|
||||
return HydraDebridClient.getAvailableMagnets(magnets);
|
||||
};
|
||||
|
||||
registerEvent("checkDebridAvailability", checkDebridAvailability);
|
||||
@@ -12,7 +12,15 @@ const startGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
payload: StartGameDownloadPayload
|
||||
) => {
|
||||
const { objectId, title, shop, downloadPath, downloader, uri } = payload;
|
||||
const {
|
||||
objectId,
|
||||
title,
|
||||
shop,
|
||||
downloadPath,
|
||||
downloader,
|
||||
uri,
|
||||
automaticallyExtract,
|
||||
} = payload;
|
||||
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
@@ -74,6 +82,8 @@ const startGameDownload = async (
|
||||
shouldSeed: false,
|
||||
timestamp: Date.now(),
|
||||
queued: true,
|
||||
extracting: false,
|
||||
automaticallyExtract,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -23,10 +23,6 @@ const updateUserPreferences = async (
|
||||
patchUserProfile({ language: preferences.language }).catch(() => {});
|
||||
}
|
||||
|
||||
if (!preferences.downloadsPath) {
|
||||
preferences.downloadsPath = null;
|
||||
}
|
||||
|
||||
await db.put<string, UserPreferences>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ import { electronApp, optimizer } from "@electron-toolkit/utils";
|
||||
import { logger, WindowManager } from "@main/services";
|
||||
import resources from "@locales";
|
||||
import { PythonRPC } from "./services/python-rpc";
|
||||
import { Aria2 } from "./services/aria2";
|
||||
import { db, levelKeys } from "./level";
|
||||
import { loadState } from "./main";
|
||||
|
||||
@@ -143,7 +142,6 @@ app.on("window-all-closed", () => {
|
||||
app.on("before-quit", () => {
|
||||
/* Disconnects libtorrent */
|
||||
PythonRPC.kill();
|
||||
Aria2.kill();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import knex from "knex";
|
||||
import { databasePath } from "./constants";
|
||||
import { app } from "electron";
|
||||
|
||||
export const knexClient = knex({
|
||||
debug: !app.isPackaged,
|
||||
client: "better-sqlite3",
|
||||
connection: {
|
||||
filename: databasePath,
|
||||
},
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
const config = {
|
||||
development: {
|
||||
migrations: {
|
||||
extension: "ts",
|
||||
stub: "migrations/migration.stub",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -13,6 +13,5 @@ export const levelKeys = {
|
||||
downloads: "downloads",
|
||||
userPreferences: "userPreferences",
|
||||
language: "language",
|
||||
sqliteMigrationDone: "sqliteMigrationDone",
|
||||
screenState: "screenState",
|
||||
};
|
||||
|
||||
174
src/main/main.ts
174
src/main/main.ts
@@ -1,36 +1,25 @@
|
||||
import { DownloadManager, logger, Ludusavi, startMainLoop } from "./services";
|
||||
import { DownloadManager, Ludusavi, startMainLoop } from "./services";
|
||||
import { RealDebridClient } from "./services/download/real-debrid";
|
||||
import { HydraApi } from "./services/hydra-api";
|
||||
import { uploadGamesBatch } from "./services/library-sync";
|
||||
import { Aria2 } from "./services/aria2";
|
||||
import { downloadsSublevel } from "./level/sublevels/downloads";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { Downloader } from "@shared";
|
||||
import {
|
||||
gameAchievementsSublevel,
|
||||
gamesSublevel,
|
||||
levelKeys,
|
||||
db,
|
||||
} from "./level";
|
||||
import { Auth, User, type UserPreferences } from "@types";
|
||||
import { knexClient } from "./knex-client";
|
||||
import { levelKeys, db } from "./level";
|
||||
import type { UserPreferences } from "@types";
|
||||
import { TorBoxClient } from "./services/download/torbox";
|
||||
import { CommonRedistManager } from "./services/common-redist-manager";
|
||||
|
||||
export const loadState = async () => {
|
||||
const userPreferences = await migrateFromSqlite().then(async () => {
|
||||
await db.put<string, boolean>(levelKeys.sqliteMigrationDone, true, {
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
valueEncoding: "json",
|
||||
});
|
||||
|
||||
return db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await import("./events");
|
||||
|
||||
Aria2.spawn();
|
||||
|
||||
if (userPreferences?.realDebridApiToken) {
|
||||
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
||||
}
|
||||
@@ -52,6 +41,15 @@ export const loadState = async () => {
|
||||
return sortBy(games, "timestamp", "DESC");
|
||||
});
|
||||
|
||||
downloads.forEach((download) => {
|
||||
if (download.extracting) {
|
||||
downloadsSublevel.put(levelKeys.game(download.shop, download.objectId), {
|
||||
...download,
|
||||
extracting: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const [nextItemOnQueue] = downloads.filter((game) => game.queued);
|
||||
|
||||
const downloadsToSeed = downloads.filter(
|
||||
@@ -65,138 +63,6 @@ export const loadState = async () => {
|
||||
await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed);
|
||||
|
||||
startMainLoop();
|
||||
};
|
||||
|
||||
const migrateFromSqlite = async () => {
|
||||
const sqliteMigrationDone = await db.get(levelKeys.sqliteMigrationDone);
|
||||
|
||||
if (sqliteMigrationDone) {
|
||||
return;
|
||||
}
|
||||
|
||||
const migrateGames = knexClient("game")
|
||||
.select("*")
|
||||
.then((games) => {
|
||||
return gamesSublevel.batch(
|
||||
games.map((game) => ({
|
||||
type: "put",
|
||||
key: levelKeys.game(game.shop, game.objectID),
|
||||
value: {
|
||||
objectId: game.objectID,
|
||||
shop: game.shop,
|
||||
title: game.title,
|
||||
iconUrl: game.iconUrl,
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
remoteId: game.remoteId,
|
||||
winePrefixPath: game.winePrefixPath,
|
||||
launchOptions: game.launchOptions,
|
||||
executablePath: game.executablePath,
|
||||
isDeleted: game.isDeleted === 1,
|
||||
},
|
||||
}))
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
logger.info("Games migrated successfully");
|
||||
});
|
||||
|
||||
const migrateUserPreferences = knexClient("user_preferences")
|
||||
.select("*")
|
||||
.then(async (userPreferences) => {
|
||||
if (userPreferences.length > 0) {
|
||||
const { realDebridApiToken, ...rest } = userPreferences[0];
|
||||
|
||||
await db.put<string, UserPreferences>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
...rest,
|
||||
realDebridApiToken,
|
||||
preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1,
|
||||
runAtStartup: rest.runAtStartup === 1,
|
||||
startMinimized: rest.startMinimized === 1,
|
||||
disableNsfwAlert: rest.disableNsfwAlert === 1,
|
||||
seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1,
|
||||
showHiddenAchievementsDescription:
|
||||
rest.showHiddenAchievementsDescription === 1,
|
||||
downloadNotificationsEnabled:
|
||||
rest.downloadNotificationsEnabled === 1,
|
||||
repackUpdatesNotificationsEnabled:
|
||||
rest.repackUpdatesNotificationsEnabled === 1,
|
||||
achievementNotificationsEnabled:
|
||||
rest.achievementNotificationsEnabled === 1,
|
||||
},
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
|
||||
if (rest.language) {
|
||||
await db.put<string, string>(levelKeys.language, rest.language, {
|
||||
valueEncoding: "utf-8",
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
logger.info("User preferences migrated successfully");
|
||||
});
|
||||
|
||||
const migrateAchievements = knexClient("game_achievement")
|
||||
.select("*")
|
||||
.then((achievements) => {
|
||||
return gameAchievementsSublevel.batch(
|
||||
achievements.map((achievement) => ({
|
||||
type: "put",
|
||||
key: levelKeys.game(achievement.shop, achievement.objectId),
|
||||
value: {
|
||||
achievements: JSON.parse(achievement.achievements),
|
||||
unlockedAchievements: JSON.parse(achievement.unlockedAchievements),
|
||||
},
|
||||
}))
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
logger.info("Achievements migrated successfully");
|
||||
});
|
||||
|
||||
const migrateUser = knexClient("user_auth")
|
||||
.select("*")
|
||||
.then(async (users) => {
|
||||
if (users.length > 0) {
|
||||
await db.put<string, User>(
|
||||
levelKeys.user,
|
||||
{
|
||||
id: users[0].userId,
|
||||
displayName: users[0].displayName,
|
||||
profileImageUrl: users[0].profileImageUrl,
|
||||
backgroundImageUrl: users[0].backgroundImageUrl,
|
||||
subscription: users[0].subscription,
|
||||
},
|
||||
{
|
||||
valueEncoding: "json",
|
||||
}
|
||||
);
|
||||
|
||||
await db.put<string, Auth>(
|
||||
levelKeys.auth,
|
||||
{
|
||||
accessToken: users[0].accessToken,
|
||||
refreshToken: users[0].refreshToken,
|
||||
tokenExpirationTimestamp: users[0].tokenExpirationTimestamp,
|
||||
},
|
||||
{
|
||||
valueEncoding: "json",
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
logger.info("User data migrated successfully");
|
||||
});
|
||||
|
||||
return Promise.allSettled([
|
||||
migrateGames,
|
||||
migrateUserPreferences,
|
||||
migrateAchievements,
|
||||
migrateUser,
|
||||
]);
|
||||
|
||||
CommonRedistManager.downloadCommonRedist();
|
||||
};
|
||||
|
||||
74
src/main/services/7zip.ts
Normal file
74
src/main/services/7zip.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { app } from "electron";
|
||||
import cp from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export const binaryName = {
|
||||
linux: "7zzs",
|
||||
darwin: "7zz",
|
||||
win32: "7z.exe",
|
||||
};
|
||||
|
||||
export class SevenZip {
|
||||
private static readonly binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, binaryName[process.platform])
|
||||
: path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"binaries",
|
||||
binaryName[process.platform]
|
||||
);
|
||||
|
||||
public static extractFile(
|
||||
{
|
||||
filePath,
|
||||
outputPath,
|
||||
cwd,
|
||||
passwords = [],
|
||||
}: {
|
||||
filePath: string;
|
||||
outputPath?: string;
|
||||
cwd?: string;
|
||||
passwords?: string[];
|
||||
},
|
||||
successCb: () => void,
|
||||
errorCb: () => void
|
||||
) {
|
||||
const tryPassword = (index = -1) => {
|
||||
const password = passwords[index] ?? "";
|
||||
logger.info(`Trying password ${password} on ${filePath}`);
|
||||
|
||||
const args = ["x", filePath, "-y", "-p" + password];
|
||||
|
||||
if (outputPath) {
|
||||
args.push("-o" + outputPath);
|
||||
}
|
||||
|
||||
const child = cp.execFile(this.binaryPath, args, {
|
||||
cwd,
|
||||
});
|
||||
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) {
|
||||
successCb();
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < passwords.length - 1) {
|
||||
logger.info(
|
||||
`Failed to extract file: ${filePath} with password: ${password}. Trying next password...`
|
||||
);
|
||||
|
||||
tryPassword(index + 1);
|
||||
} else {
|
||||
logger.info(`Failed to extract file: ${filePath}`);
|
||||
|
||||
errorCb();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tryPassword();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ export const getGameAchievementData = async (
|
||||
if (cachedAchievements && useCachedData)
|
||||
return cachedAchievements.achievements;
|
||||
|
||||
if (
|
||||
cachedAchievements &&
|
||||
Date.now() < (cachedAchievements.cacheExpiresTimestamp ?? 0)
|
||||
) {
|
||||
return cachedAchievements.achievements;
|
||||
}
|
||||
|
||||
const language = await db
|
||||
.get<string, string>(levelKeys.language, {
|
||||
valueEncoding: "utf-8",
|
||||
@@ -31,6 +38,7 @@ export const getGameAchievementData = async (
|
||||
await gameAchievementsSublevel.put(levelKeys.game(shop, objectId), {
|
||||
unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [],
|
||||
achievements,
|
||||
cacheExpiresTimestamp: Date.now() + 1000 * 60 * 30, // 30 minutes
|
||||
});
|
||||
|
||||
return achievements;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
Game,
|
||||
GameShop,
|
||||
UnlockedAchievement,
|
||||
UpdatedUnlockedAchievements,
|
||||
UserPreferences,
|
||||
} from "@types";
|
||||
import { WindowManager } from "../window-manager";
|
||||
@@ -26,6 +27,7 @@ const saveAchievementsOnLocal = async (
|
||||
await gameAchievementsSublevel.put(levelKey, {
|
||||
achievements: gameAchievement?.achievements ?? [],
|
||||
unlockedAchievements: unlockedAchievements,
|
||||
cacheExpiresTimestamp: gameAchievement?.cacheExpiresTimestamp,
|
||||
});
|
||||
|
||||
if (!sendUpdateEvent) return;
|
||||
@@ -114,7 +116,7 @@ export const mergeAchievements = async (
|
||||
}
|
||||
|
||||
if (game.remoteId) {
|
||||
await HydraApi.put(
|
||||
await HydraApi.put<UpdatedUnlockedAchievements | undefined>(
|
||||
"/profile/games/achievements",
|
||||
{
|
||||
id: game.remoteId,
|
||||
@@ -123,10 +125,19 @@ export const mergeAchievements = async (
|
||||
{ needsSubscription: !newAchievements.length }
|
||||
)
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
return saveAchievementsOnLocal(
|
||||
response.objectId,
|
||||
response.shop,
|
||||
response.achievements,
|
||||
publishNotification
|
||||
);
|
||||
}
|
||||
|
||||
return saveAchievementsOnLocal(
|
||||
response.objectId,
|
||||
response.shop,
|
||||
response.achievements,
|
||||
game.objectId,
|
||||
game.shop,
|
||||
mergedLocalAchievements,
|
||||
publishNotification
|
||||
);
|
||||
})
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import { app } from "electron";
|
||||
|
||||
export const startAria2 = () => {};
|
||||
|
||||
export class Aria2 {
|
||||
private static process: cp.ChildProcess | null = null;
|
||||
|
||||
public static spawn() {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2", "aria2c")
|
||||
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
||||
|
||||
this.process = cp.spawn(
|
||||
binaryPath,
|
||||
[
|
||||
"--enable-rpc",
|
||||
"--rpc-listen-all",
|
||||
"--file-allocation=none",
|
||||
"--allow-overwrite=true",
|
||||
],
|
||||
{ stdio: "inherit", windowsHide: true }
|
||||
);
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
this.process?.kill();
|
||||
}
|
||||
}
|
||||
109
src/main/services/common-redist-manager.ts
Normal file
109
src/main/services/common-redist-manager.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { commonRedistPath } from "@main/constants";
|
||||
import axios from "axios";
|
||||
import fs from "node:fs";
|
||||
import cp from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { logger } from "./logger";
|
||||
import { app } from "electron";
|
||||
import { WindowManager } from "./window-manager";
|
||||
|
||||
export class CommonRedistManager {
|
||||
private static readonly redistributables = [
|
||||
"dotNetFx40_Full_setup.exe",
|
||||
"dxwebsetup.exe",
|
||||
"oalinst.exe",
|
||||
"install.bat",
|
||||
"vcredist_2015-2019_x64.exe",
|
||||
"vcredist_2015-2019_x86.exe",
|
||||
"vcredist_x64.exe",
|
||||
"vcredist_x86.exe",
|
||||
"xnafx40_redist.msi",
|
||||
];
|
||||
private static readonly installationTimeout = 1000 * 60 * 5; // 5 minutes
|
||||
private static readonly installationLog = path.join(
|
||||
app.getPath("temp"),
|
||||
"common_redist_install.log"
|
||||
);
|
||||
|
||||
public static async installCommonRedist() {
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
abortController.abort();
|
||||
logger.error("Installation timed out");
|
||||
|
||||
WindowManager.mainWindow?.webContents.send("common-redist-progress", {
|
||||
log: "Installation timed out",
|
||||
complete: false,
|
||||
});
|
||||
}, this.installationTimeout);
|
||||
|
||||
const installationCompleteMessage = "Installation complete";
|
||||
|
||||
if (!fs.existsSync(this.installationLog)) {
|
||||
await fs.promises.writeFile(this.installationLog, "");
|
||||
}
|
||||
|
||||
fs.watch(this.installationLog, { signal: abortController.signal }, () => {
|
||||
fs.readFile(this.installationLog, "utf-8", (err, data) => {
|
||||
if (err) return logger.error("Error reading log file:", err);
|
||||
|
||||
const tail = data.split("\n").at(-2)?.trim();
|
||||
|
||||
if (tail?.includes(installationCompleteMessage)) {
|
||||
clearTimeout(timeout);
|
||||
if (!abortController.signal.aborted) {
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.webContents.send("common-redist-progress", {
|
||||
log: tail,
|
||||
complete: tail?.includes(installationCompleteMessage),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cp.exec(
|
||||
path.join(commonRedistPath, "install.bat"),
|
||||
{
|
||||
windowsHide: true,
|
||||
},
|
||||
(error) => {
|
||||
if (error) {
|
||||
logger.error("Failed to run install.bat", error);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public static async canInstallCommonRedist() {
|
||||
return this.redistributables.every((redist) => {
|
||||
const filePath = path.join(commonRedistPath, redist);
|
||||
|
||||
return fs.existsSync(filePath);
|
||||
});
|
||||
}
|
||||
|
||||
public static async downloadCommonRedist() {
|
||||
if (!fs.existsSync(commonRedistPath)) {
|
||||
await fs.promises.mkdir(commonRedistPath, { recursive: true });
|
||||
}
|
||||
|
||||
for (const redist of this.redistributables) {
|
||||
const filePath = path.join(commonRedistPath, redist);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
`https://github.com/hydralauncher/hydra-common-redist/raw/refs/heads/main/${redist}`,
|
||||
{
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
);
|
||||
|
||||
await fs.promises.writeFile(filePath, response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Downloader, DownloadError } from "@shared";
|
||||
import { Downloader, DownloadError, FILE_EXTENSIONS_TO_EXTRACT } from "@shared";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { publishDownloadCompleteNotification } from "../notifications";
|
||||
import type { Download, DownloadProgress, UserPreferences } from "@types";
|
||||
@@ -22,6 +22,8 @@ import { logger } from "../logger";
|
||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { TorBoxClient } from "./torbox";
|
||||
import { GameFilesManager } from "../game-files-manager";
|
||||
import { HydraDebridClient } from "./hydra-debrid";
|
||||
|
||||
export class DownloadManager {
|
||||
private static downloadingGameId: string | null = null;
|
||||
@@ -136,6 +138,8 @@ export class DownloadManager {
|
||||
);
|
||||
}
|
||||
|
||||
const shouldExtract = download.automaticallyExtract;
|
||||
|
||||
if (progress === 1 && download) {
|
||||
publishDownloadCompleteNotification(game);
|
||||
|
||||
@@ -143,23 +147,48 @@ export class DownloadManager {
|
||||
userPreferences?.seedAfterDownloadComplete &&
|
||||
download.downloader === Downloader.Torrent
|
||||
) {
|
||||
downloadsSublevel.put(gameId, {
|
||||
await downloadsSublevel.put(gameId, {
|
||||
...download,
|
||||
status: "seeding",
|
||||
shouldSeed: true,
|
||||
queued: false,
|
||||
extracting: shouldExtract,
|
||||
});
|
||||
} else {
|
||||
downloadsSublevel.put(gameId, {
|
||||
await downloadsSublevel.put(gameId, {
|
||||
...download,
|
||||
status: "complete",
|
||||
shouldSeed: false,
|
||||
queued: false,
|
||||
extracting: shouldExtract,
|
||||
});
|
||||
|
||||
this.cancelDownload(gameId);
|
||||
}
|
||||
|
||||
if (shouldExtract) {
|
||||
const gameFilesManager = new GameFilesManager(
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
|
||||
if (
|
||||
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
|
||||
download.folderName?.endsWith(ext)
|
||||
)
|
||||
) {
|
||||
gameFilesManager.extractDownloadedFile();
|
||||
} else {
|
||||
gameFilesManager
|
||||
.extractFilesInDirectory(
|
||||
path.join(download.downloadPath, download.folderName!)
|
||||
)
|
||||
.then(() => {
|
||||
gameFilesManager.setExtractionComplete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const downloads = await downloadsSublevel
|
||||
.values()
|
||||
.all()
|
||||
@@ -285,6 +314,8 @@ export class DownloadManager {
|
||||
url: downloadLink,
|
||||
save_path: download.downloadPath,
|
||||
header: `Cookie: accountToken=${token}`,
|
||||
allow_multiple_connections: true,
|
||||
connections_limit: 8,
|
||||
};
|
||||
}
|
||||
case Downloader.PixelDrain: {
|
||||
@@ -343,6 +374,7 @@ export class DownloadManager {
|
||||
game_id: downloadId,
|
||||
url: downloadUrl,
|
||||
save_path: download.downloadPath,
|
||||
allow_multiple_connections: true,
|
||||
};
|
||||
}
|
||||
case Downloader.TorBox: {
|
||||
@@ -355,6 +387,22 @@ export class DownloadManager {
|
||||
url,
|
||||
save_path: download.downloadPath,
|
||||
out: name,
|
||||
allow_multiple_connections: true,
|
||||
};
|
||||
}
|
||||
case Downloader.Hydra: {
|
||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(
|
||||
download.uri
|
||||
);
|
||||
|
||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedInHydra);
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
url: downloadUrl,
|
||||
save_path: download.downloadPath,
|
||||
allow_multiple_connections: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
27
src/main/services/download/hydra-debrid.ts
Normal file
27
src/main/services/download/hydra-debrid.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { HydraApi } from "../hydra-api";
|
||||
|
||||
export class HydraDebridClient {
|
||||
public static getAvailableMagnets(
|
||||
magnets: string[]
|
||||
): Promise<Record<string, boolean>> {
|
||||
return HydraApi.put(
|
||||
"/debrid/check-availability",
|
||||
{
|
||||
magnets,
|
||||
},
|
||||
{ needsAuth: false }
|
||||
);
|
||||
}
|
||||
|
||||
public static async getDownloadUrl(magnet: string) {
|
||||
try {
|
||||
const response = await HydraApi.post("/debrid/request-file", {
|
||||
magnet,
|
||||
});
|
||||
|
||||
return response.downloadUrl;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/main/services/game-files-manager.ts
Normal file
158
src/main/services/game-files-manager.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import type { GameShop } from "@types";
|
||||
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared";
|
||||
import { SevenZip } from "./7zip";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { publishExtractionCompleteNotification } from "./notifications";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export class GameFilesManager {
|
||||
constructor(
|
||||
private readonly shop: GameShop,
|
||||
private readonly objectId: string
|
||||
) {}
|
||||
|
||||
private async clearExtractionState() {
|
||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
||||
const download = await downloadsSublevel.get(gameKey);
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download!,
|
||||
extracting: false,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-extraction-complete",
|
||||
this.shop,
|
||||
this.objectId
|
||||
);
|
||||
}
|
||||
|
||||
async extractFilesInDirectory(directoryPath: string) {
|
||||
if (!fs.existsSync(directoryPath)) return;
|
||||
const files = await fs.promises.readdir(directoryPath);
|
||||
|
||||
const compressedFiles = files.filter((file) =>
|
||||
FILE_EXTENSIONS_TO_EXTRACT.some((ext) => file.endsWith(ext))
|
||||
);
|
||||
|
||||
const filesToExtract = compressedFiles.filter(
|
||||
(file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
filesToExtract.map((file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
SevenZip.extractFile(
|
||||
{
|
||||
filePath: path.join(directoryPath, file),
|
||||
cwd: directoryPath,
|
||||
passwords: ["online-fix.me", "steamrip.com"],
|
||||
},
|
||||
() => {
|
||||
resolve(true);
|
||||
},
|
||||
() => {
|
||||
reject(new Error(`Failed to extract file: ${file}`));
|
||||
this.clearExtractionState();
|
||||
}
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
compressedFiles.forEach((file) => {
|
||||
const extractionPath = path.join(directoryPath, file);
|
||||
|
||||
if (fs.existsSync(extractionPath)) {
|
||||
fs.unlink(extractionPath, (err) => {
|
||||
if (err) {
|
||||
logger.error(`Failed to delete file: ${file}`, err);
|
||||
|
||||
this.clearExtractionState();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setExtractionComplete(publishNotification = true) {
|
||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
||||
|
||||
const [download, game] = await Promise.all([
|
||||
downloadsSublevel.get(gameKey),
|
||||
gamesSublevel.get(gameKey),
|
||||
]);
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download!,
|
||||
extracting: false,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-extraction-complete",
|
||||
this.shop,
|
||||
this.objectId
|
||||
);
|
||||
|
||||
if (publishNotification) {
|
||||
publishExtractionCompleteNotification(game!);
|
||||
}
|
||||
}
|
||||
|
||||
async extractDownloadedFile() {
|
||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
||||
|
||||
const [download, game] = await Promise.all([
|
||||
downloadsSublevel.get(gameKey),
|
||||
gamesSublevel.get(gameKey),
|
||||
]);
|
||||
|
||||
if (!download || !game) return false;
|
||||
|
||||
const filePath = path.join(download.downloadPath, download.folderName!);
|
||||
|
||||
const extractionPath = path.join(
|
||||
download.downloadPath,
|
||||
path.parse(download.folderName!).name
|
||||
);
|
||||
|
||||
SevenZip.extractFile(
|
||||
{
|
||||
filePath,
|
||||
outputPath: extractionPath,
|
||||
passwords: ["online-fix.me", "steamrip.com"],
|
||||
},
|
||||
async () => {
|
||||
await this.extractFilesInDirectory(extractionPath);
|
||||
|
||||
if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) {
|
||||
fs.unlink(filePath, (err) => {
|
||||
if (err) {
|
||||
logger.error(
|
||||
`Failed to delete file: ${download.folderName}`,
|
||||
err
|
||||
);
|
||||
|
||||
this.clearExtractionState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download!,
|
||||
folderName: path.parse(download.folderName!).name,
|
||||
});
|
||||
|
||||
this.setExtractionComplete();
|
||||
},
|
||||
() => {
|
||||
this.clearExtractionState();
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,6 @@ export * from "./main-loop";
|
||||
export * from "./hydra-api";
|
||||
export * from "./ludusavi";
|
||||
export * from "./cloud-sync";
|
||||
export * from "./7zip";
|
||||
export * from "./game-files-manager";
|
||||
export * from "./common-redist-manager";
|
||||
|
||||
@@ -128,6 +128,17 @@ export const publishCombinedNewAchievementNotification = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const publishExtractionCompleteNotification = async (game: Game) => {
|
||||
new Notification({
|
||||
title: t("extraction_complete", { ns: "notifications" }),
|
||||
body: t("game_extracted", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
}),
|
||||
icon: trayIcon,
|
||||
}).show();
|
||||
};
|
||||
|
||||
export const publishNewAchievementNotification = async (info: {
|
||||
achievements: { displayName: string; iconUrl: string }[];
|
||||
unlockedAchievementCount: number;
|
||||
|
||||
@@ -6,9 +6,9 @@ import axios from "axios";
|
||||
import { exec } from "child_process";
|
||||
import { ProcessPayload } from "./download/types";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import { t } from "i18next";
|
||||
import i18next, { t } from "i18next";
|
||||
import { CloudSync } from "./cloud-sync";
|
||||
import { format } from "date-fns";
|
||||
import { formatDate } from "date-fns";
|
||||
|
||||
const commands = {
|
||||
findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`,
|
||||
@@ -229,6 +229,8 @@ function onOpenGame(game: Game) {
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(game, 0, new Date()).catch(() => {});
|
||||
|
||||
const { language } = i18next;
|
||||
|
||||
if (game.automaticCloudSync) {
|
||||
CloudSync.uploadSaveGame(
|
||||
game.objectId,
|
||||
@@ -236,7 +238,7 @@ function onOpenGame(game: Game) {
|
||||
null,
|
||||
t("automatic_backup_from", {
|
||||
ns: "game_details",
|
||||
date: format(new Date(), "dd/MM/yyyy"),
|
||||
date: formatDate(new Date(), language),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -296,6 +298,8 @@ const onCloseGame = (game: Game) => {
|
||||
)!;
|
||||
gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId));
|
||||
|
||||
const { language } = i18next;
|
||||
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(
|
||||
game,
|
||||
@@ -310,7 +314,7 @@ const onCloseGame = (game: Game) => {
|
||||
null,
|
||||
t("automatic_backup_from", {
|
||||
ns: "game_details",
|
||||
date: format(new Date(), "dd/MM/yyyy"),
|
||||
date: formatDate(new Date(), language),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
win32: "hydra-python-rpc.exe",
|
||||
};
|
||||
|
||||
const rustBinaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "hydra-httpdl",
|
||||
linux: "hydra-httpdl",
|
||||
win32: "hydra-httpdl.exe",
|
||||
};
|
||||
|
||||
export class PythonRPC {
|
||||
public static readonly BITTORRENT_PORT = "5881";
|
||||
public static readonly RPC_PORT = "8084";
|
||||
@@ -52,6 +58,20 @@ export class PythonRPC {
|
||||
this.RPC_PASSWORD,
|
||||
initialDownload ? JSON.stringify(initialDownload) : "",
|
||||
initialSeeding ? JSON.stringify(initialSeeding) : "",
|
||||
app.isPackaged
|
||||
? path.join(
|
||||
process.resourcesPath,
|
||||
rustBinaryNameByPlatform[process.platform]!
|
||||
)
|
||||
: path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"rust_rpc",
|
||||
"target",
|
||||
"debug",
|
||||
rustBinaryNameByPlatform[process.platform]!
|
||||
),
|
||||
];
|
||||
|
||||
if (app.isPackaged) {
|
||||
|
||||
@@ -55,6 +55,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.on("on-seeding-status", listener);
|
||||
return () => ipcRenderer.removeListener("on-seeding-status", listener);
|
||||
},
|
||||
checkDebridAvailability: (magnets: string[]) =>
|
||||
ipcRenderer.invoke("checkDebridAvailability", magnets),
|
||||
|
||||
/* Catalogue */
|
||||
searchGames: (payload: CatalogueSearchPayload, take: number, skip: number) =>
|
||||
@@ -100,6 +102,11 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
/* Download sources */
|
||||
putDownloadSource: (objectIds: string[]) =>
|
||||
ipcRenderer.invoke("putDownloadSource", objectIds),
|
||||
createDownloadSources: (urls: string[]) =>
|
||||
ipcRenderer.invoke("createDownloadSources", urls),
|
||||
removeDownloadSource: (url: string, removeAll?: boolean) =>
|
||||
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
|
||||
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
|
||||
|
||||
/* Library */
|
||||
toggleAutomaticCloudSync: (
|
||||
@@ -173,6 +180,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("getGameByObjectId", shop, objectId),
|
||||
resetGameAchievements: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("resetGameAchievements", shop, objectId),
|
||||
extractGameDownload: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("extractGameDownload", shop, objectId),
|
||||
onGamesRunning: (
|
||||
cb: (
|
||||
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
||||
@@ -195,6 +204,15 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-achievement-unlocked", listener);
|
||||
},
|
||||
onExtractionComplete: (cb: (shop: GameShop, objectId: string) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => cb(shop, objectId);
|
||||
ipcRenderer.on("on-extraction-complete", listener);
|
||||
return () => ipcRenderer.removeListener("on-extraction-complete", listener);
|
||||
},
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) =>
|
||||
@@ -279,6 +297,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("showItemInFolder", path),
|
||||
getFeatures: () => ipcRenderer.invoke("getFeatures"),
|
||||
getBadges: () => ipcRenderer.invoke("getBadges"),
|
||||
canInstallCommonRedist: () => ipcRenderer.invoke("canInstallCommonRedist"),
|
||||
installCommonRedist: () => ipcRenderer.invoke("installCommonRedist"),
|
||||
platform: process.platform,
|
||||
|
||||
/* Auto update */
|
||||
@@ -294,6 +314,16 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.removeListener("autoUpdaterEvent", listener);
|
||||
};
|
||||
},
|
||||
onCommonRedistProgress: (
|
||||
cb: (value: { log: string; complete: boolean }) => void
|
||||
) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
value: { log: string; complete: boolean }
|
||||
) => cb(value);
|
||||
ipcRenderer.on("common-redist-progress", listener);
|
||||
return () => ipcRenderer.removeListener("common-redist-progress", listener);
|
||||
},
|
||||
checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"),
|
||||
restartAndInstallUpdate: () => ipcRenderer.invoke("restartAndInstallUpdate"),
|
||||
|
||||
|
||||
@@ -137,6 +137,15 @@ export function App() {
|
||||
}, [fetchUserDetails, updateUserDetails, dispatch]);
|
||||
|
||||
const onSignIn = useCallback(() => {
|
||||
window.electron.getDownloadSources().then((sources) => {
|
||||
sources.forEach((source) => {
|
||||
downloadSourcesWorker.postMessage([
|
||||
"IMPORT_DOWNLOAD_SOURCE",
|
||||
source.url,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) {
|
||||
updateUserDetails(response);
|
||||
@@ -224,18 +233,28 @@ export function App() {
|
||||
|
||||
const downloadSources = await downloadSourcesTable.toArray();
|
||||
|
||||
downloadSources
|
||||
.filter((source) => !source.fingerprint)
|
||||
.forEach(async (downloadSource) => {
|
||||
const { fingerprint } = await window.electron.putDownloadSource(
|
||||
downloadSource.objectIds
|
||||
);
|
||||
await Promise.all(
|
||||
downloadSources
|
||||
.filter((source) => !source.fingerprint)
|
||||
.map(async (downloadSource) => {
|
||||
const { fingerprint } = await window.electron.putDownloadSource(
|
||||
downloadSource.objectIds
|
||||
);
|
||||
|
||||
downloadSourcesTable.update(downloadSource.id, { fingerprint });
|
||||
});
|
||||
return downloadSourcesTable.update(downloadSource.id, {
|
||||
fingerprint,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
channel.close();
|
||||
};
|
||||
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
|
||||
return () => {
|
||||
channel.close();
|
||||
};
|
||||
}, [updateRepacks]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
24
src/renderer/src/assets/meteor.svg
Normal file
24
src/renderer/src/assets/meteor.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Meteor">
|
||||
<g id="Vector">
|
||||
<path d="M10.6242 13.0003C10.6242 13.6184 10.4409 14.2226 10.0975 14.7365C9.75415 15.2504 9.26609 15.6509 8.69507 15.8875C8.12405 16.124 7.49572 16.1859 6.88953 16.0653C6.28334 15.9447 5.72652 15.6471 5.28948 15.2101C4.85244 14.773 4.55481 14.2162 4.43423 13.61C4.31366 13.0038 4.37554 12.3755 4.61206 11.8045C4.84859 11.2334 5.24913 10.7454 5.76303 10.402C6.27693 10.0586 6.88112 9.87535 7.49919 9.87535C8.32799 9.87535 9.12285 10.2046 9.7089 10.7906C10.2949 11.3767 10.6242 12.1715 10.6242 13.0003ZM16.432 10.0582L12.682 13.8082C12.5647 13.9254 12.4988 14.0845 12.4988 14.2503C12.4988 14.4162 12.5647 14.5753 12.682 14.6925C12.7993 14.8098 12.9583 14.8757 13.1242 14.8757C13.29 14.8757 13.4491 14.8098 13.5664 14.6925L17.3164 10.9425C17.3744 10.8845 17.4205 10.8155 17.4519 10.7397C17.4834 10.6638 17.4995 10.5825 17.4995 10.5003C17.4995 10.4182 17.4834 10.3369 17.4519 10.261C17.4205 10.1852 17.3744 10.1162 17.3164 10.0582C17.2583 10.0001 17.1894 9.95403 17.1135 9.9226C17.0376 9.89118 16.9563 9.875 16.8742 9.875C16.7921 9.875 16.7107 9.89118 16.6349 9.9226C16.559 9.95403 16.4901 10.0001 16.432 10.0582ZM14.8164 9.06754C14.8744 9.00947 14.9205 8.94053 14.9519 8.86466C14.9834 8.78879 14.9995 8.70747 14.9995 8.62535C14.9995 8.54323 14.9834 8.46191 14.9519 8.38604C14.9205 8.31017 14.8744 8.24123 14.8164 8.18316C14.7583 8.12509 14.6894 8.07903 14.6135 8.0476C14.5376 8.01617 14.4563 8 14.3742 8C14.2921 8 14.2107 8.01617 14.1349 8.0476C14.059 8.07903 13.9901 8.12509 13.932 8.18316L12.057 10.0582C11.9397 10.1754 11.8738 10.3345 11.8738 10.5003C11.8738 10.6662 11.9397 10.8253 12.057 10.9425C12.1743 11.0598 12.3333 11.1257 12.4992 11.1257C12.665 11.1257 12.8241 11.0598 12.9414 10.9425L14.8164 9.06754ZM17.9414 5.05816C17.8833 5.00005 17.8144 4.95395 17.7385 4.9225C17.6627 4.89105 17.5813 4.87486 17.4992 4.87486C17.4171 4.87486 17.3357 4.89105 17.2599 4.9225C17.184 4.95395 17.115 5.00005 17.057 5.05816L15.807 6.30816C15.6897 6.42544 15.6238 6.5845 15.6238 6.75035C15.6238 6.9162 15.6897 7.07526 15.807 7.19254C15.9243 7.30981 16.0833 7.37569 16.2492 7.3757C16.415 7.3757 16.5741 7.30981 16.6914 7.19254L17.9414 5.94254C17.9995 5.88449 18.0456 5.81556 18.077 5.73969C18.1085 5.66381 18.1247 5.58248 18.1247 5.50035C18.1247 5.41821 18.1085 5.33688 18.077 5.26101C18.0456 5.18514 17.9995 5.11621 17.9414 5.05816ZM9.557 8.44254C9.61505 8.50065 9.68398 8.54674 9.75985 8.5782C9.83572 8.60965 9.91705 8.62584 9.99919 8.62584C10.0813 8.62584 10.1627 8.60965 10.2385 8.5782C10.3144 8.54674 10.3833 8.50065 10.4414 8.44254L16.0664 2.81754C16.1244 2.75947 16.1705 2.69053 16.2019 2.61466C16.2334 2.53879 16.2495 2.45747 16.2495 2.37535C16.2495 2.29323 16.2334 2.21191 16.2019 2.13604C16.1705 2.06017 16.1244 1.99123 16.0664 1.93316C16.0083 1.87509 15.9394 1.82903 15.8635 1.7976C15.7876 1.76618 15.7063 1.75 15.6242 1.75C15.5421 1.75 15.4607 1.76618 15.3849 1.7976C15.309 1.82903 15.2401 1.87509 15.182 1.93316L9.557 7.55816C9.49889 7.61621 9.45279 7.68514 9.42134 7.76101C9.38989 7.83688 9.3737 7.91821 9.3737 8.00035C9.3737 8.08248 9.38989 8.16381 9.42134 8.23969C9.45279 8.31556 9.49889 8.38449 9.557 8.44254ZM10.5929 16.0941C9.77242 16.9146 8.65957 17.3756 7.49919 17.3756C6.33881 17.3756 5.22595 16.9146 4.40544 16.0941C3.58492 15.2736 3.12396 14.1607 3.12396 13.0003C3.12396 11.84 3.58492 10.7271 4.40544 9.9066L10.8703 3.44253C10.9284 3.38447 10.9744 3.31553 11.0058 3.23966C11.0373 3.16379 11.0534 3.08247 11.0534 3.00035C11.0534 2.91823 11.0373 2.83691 11.0058 2.76104C10.9744 2.68517 10.9284 2.61623 10.8703 2.55816C10.8122 2.50009 10.7433 2.45403 10.6674 2.4226C10.5915 2.39118 10.5102 2.375 10.4281 2.375C10.346 2.375 10.2647 2.39118 10.1888 2.4226C10.1129 2.45403 10.044 2.50009 9.98591 2.55816L3.52184 9.023C2.99253 9.54377 2.57156 10.1642 2.28322 10.8484C1.99488 11.5327 1.84487 12.2673 1.84184 13.0098C1.83882 13.7524 1.98284 14.4882 2.2656 15.1747C2.54836 15.8613 2.96427 16.4852 3.48932 17.0102C4.01438 17.5353 4.63819 17.9512 5.32479 18.2339C6.01138 18.5167 6.74717 18.6607 7.4897 18.6577C8.23223 18.6547 8.96682 18.5047 9.65109 18.2163C10.3354 17.928 10.9558 17.507 11.4765 16.9777C11.5888 16.8595 11.6505 16.7022 11.6484 16.5392C11.6463 16.3762 11.5806 16.2205 11.4654 16.1053C11.3501 15.99 11.1944 15.9243 11.0314 15.9223C10.8684 15.9202 10.7111 15.9818 10.5929 16.0941Z" fill="black"/>
|
||||
<path d="M10.6242 13.0003C10.6242 13.6184 10.4409 14.2226 10.0975 14.7365C9.75415 15.2504 9.26609 15.6509 8.69507 15.8875C8.12405 16.124 7.49572 16.1859 6.88953 16.0653C6.28334 15.9447 5.72652 15.6471 5.28948 15.2101C4.85244 14.773 4.55481 14.2162 4.43423 13.61C4.31366 13.0038 4.37554 12.3755 4.61206 11.8045C4.84859 11.2334 5.24913 10.7454 5.76303 10.402C6.27693 10.0586 6.88112 9.87535 7.49919 9.87535C8.32799 9.87535 9.12285 10.2046 9.7089 10.7906C10.2949 11.3767 10.6242 12.1715 10.6242 13.0003ZM16.432 10.0582L12.682 13.8082C12.5647 13.9254 12.4988 14.0845 12.4988 14.2503C12.4988 14.4162 12.5647 14.5753 12.682 14.6925C12.7993 14.8098 12.9583 14.8757 13.1242 14.8757C13.29 14.8757 13.4491 14.8098 13.5664 14.6925L17.3164 10.9425C17.3744 10.8845 17.4205 10.8155 17.4519 10.7397C17.4834 10.6638 17.4995 10.5825 17.4995 10.5003C17.4995 10.4182 17.4834 10.3369 17.4519 10.261C17.4205 10.1852 17.3744 10.1162 17.3164 10.0582C17.2583 10.0001 17.1894 9.95403 17.1135 9.9226C17.0376 9.89118 16.9563 9.875 16.8742 9.875C16.7921 9.875 16.7107 9.89118 16.6349 9.9226C16.559 9.95403 16.4901 10.0001 16.432 10.0582ZM14.8164 9.06754C14.8744 9.00947 14.9205 8.94053 14.9519 8.86466C14.9834 8.78879 14.9995 8.70747 14.9995 8.62535C14.9995 8.54323 14.9834 8.46191 14.9519 8.38604C14.9205 8.31017 14.8744 8.24123 14.8164 8.18316C14.7583 8.12509 14.6894 8.07903 14.6135 8.0476C14.5376 8.01617 14.4563 8 14.3742 8C14.2921 8 14.2107 8.01617 14.1349 8.0476C14.059 8.07903 13.9901 8.12509 13.932 8.18316L12.057 10.0582C11.9397 10.1754 11.8738 10.3345 11.8738 10.5003C11.8738 10.6662 11.9397 10.8253 12.057 10.9425C12.1743 11.0598 12.3333 11.1257 12.4992 11.1257C12.665 11.1257 12.8241 11.0598 12.9414 10.9425L14.8164 9.06754ZM17.9414 5.05816C17.8833 5.00005 17.8144 4.95395 17.7385 4.9225C17.6627 4.89105 17.5813 4.87486 17.4992 4.87486C17.4171 4.87486 17.3357 4.89105 17.2599 4.9225C17.184 4.95395 17.115 5.00005 17.057 5.05816L15.807 6.30816C15.6897 6.42544 15.6238 6.5845 15.6238 6.75035C15.6238 6.9162 15.6897 7.07526 15.807 7.19254C15.9243 7.30981 16.0833 7.37569 16.2492 7.3757C16.415 7.3757 16.5741 7.30981 16.6914 7.19254L17.9414 5.94254C17.9995 5.88449 18.0456 5.81556 18.077 5.73969C18.1085 5.66381 18.1247 5.58248 18.1247 5.50035C18.1247 5.41821 18.1085 5.33688 18.077 5.26101C18.0456 5.18514 17.9995 5.11621 17.9414 5.05816ZM9.557 8.44254C9.61505 8.50065 9.68398 8.54674 9.75985 8.5782C9.83572 8.60965 9.91705 8.62584 9.99919 8.62584C10.0813 8.62584 10.1627 8.60965 10.2385 8.5782C10.3144 8.54674 10.3833 8.50065 10.4414 8.44254L16.0664 2.81754C16.1244 2.75947 16.1705 2.69053 16.2019 2.61466C16.2334 2.53879 16.2495 2.45747 16.2495 2.37535C16.2495 2.29323 16.2334 2.21191 16.2019 2.13604C16.1705 2.06017 16.1244 1.99123 16.0664 1.93316C16.0083 1.87509 15.9394 1.82903 15.8635 1.7976C15.7876 1.76618 15.7063 1.75 15.6242 1.75C15.5421 1.75 15.4607 1.76618 15.3849 1.7976C15.309 1.82903 15.2401 1.87509 15.182 1.93316L9.557 7.55816C9.49889 7.61621 9.45279 7.68514 9.42134 7.76101C9.38989 7.83688 9.3737 7.91821 9.3737 8.00035C9.3737 8.08248 9.38989 8.16381 9.42134 8.23969C9.45279 8.31556 9.49889 8.38449 9.557 8.44254ZM10.5929 16.0941C9.77242 16.9146 8.65957 17.3756 7.49919 17.3756C6.33881 17.3756 5.22595 16.9146 4.40544 16.0941C3.58492 15.2736 3.12396 14.1607 3.12396 13.0003C3.12396 11.84 3.58492 10.7271 4.40544 9.9066L10.8703 3.44253C10.9284 3.38447 10.9744 3.31553 11.0058 3.23966C11.0373 3.16379 11.0534 3.08247 11.0534 3.00035C11.0534 2.91823 11.0373 2.83691 11.0058 2.76104C10.9744 2.68517 10.9284 2.61623 10.8703 2.55816C10.8122 2.50009 10.7433 2.45403 10.6674 2.4226C10.5915 2.39118 10.5102 2.375 10.4281 2.375C10.346 2.375 10.2647 2.39118 10.1888 2.4226C10.1129 2.45403 10.044 2.50009 9.98591 2.55816L3.52184 9.023C2.99253 9.54377 2.57156 10.1642 2.28322 10.8484C1.99488 11.5327 1.84487 12.2673 1.84184 13.0098C1.83882 13.7524 1.98284 14.4882 2.2656 15.1747C2.54836 15.8613 2.96427 16.4852 3.48932 17.0102C4.01438 17.5353 4.63819 17.9512 5.32479 18.2339C6.01138 18.5167 6.74717 18.6607 7.4897 18.6577C8.23223 18.6547 8.96682 18.5047 9.65109 18.2163C10.3354 17.928 10.9558 17.507 11.4765 16.9777C11.5888 16.8595 11.6505 16.7022 11.6484 16.5392C11.6463 16.3762 11.5806 16.2205 11.4654 16.1053C11.3501 15.99 11.1944 15.9243 11.0314 15.9223C10.8684 15.9202 10.7111 15.9818 10.5929 16.0941Z" fill="url(#paint0_linear_2850_16638)"/>
|
||||
<path d="M10.6242 13.0003C10.6242 13.6184 10.4409 14.2226 10.0975 14.7365C9.75415 15.2504 9.26609 15.6509 8.69507 15.8875C8.12405 16.124 7.49572 16.1859 6.88953 16.0653C6.28334 15.9447 5.72652 15.6471 5.28948 15.2101C4.85244 14.773 4.55481 14.2162 4.43423 13.61C4.31366 13.0038 4.37554 12.3755 4.61206 11.8045C4.84859 11.2334 5.24913 10.7454 5.76303 10.402C6.27693 10.0586 6.88112 9.87535 7.49919 9.87535C8.32799 9.87535 9.12285 10.2046 9.7089 10.7906C10.2949 11.3767 10.6242 12.1715 10.6242 13.0003ZM16.432 10.0582L12.682 13.8082C12.5647 13.9254 12.4988 14.0845 12.4988 14.2503C12.4988 14.4162 12.5647 14.5753 12.682 14.6925C12.7993 14.8098 12.9583 14.8757 13.1242 14.8757C13.29 14.8757 13.4491 14.8098 13.5664 14.6925L17.3164 10.9425C17.3744 10.8845 17.4205 10.8155 17.4519 10.7397C17.4834 10.6638 17.4995 10.5825 17.4995 10.5003C17.4995 10.4182 17.4834 10.3369 17.4519 10.261C17.4205 10.1852 17.3744 10.1162 17.3164 10.0582C17.2583 10.0001 17.1894 9.95403 17.1135 9.9226C17.0376 9.89118 16.9563 9.875 16.8742 9.875C16.7921 9.875 16.7107 9.89118 16.6349 9.9226C16.559 9.95403 16.4901 10.0001 16.432 10.0582ZM14.8164 9.06754C14.8744 9.00947 14.9205 8.94053 14.9519 8.86466C14.9834 8.78879 14.9995 8.70747 14.9995 8.62535C14.9995 8.54323 14.9834 8.46191 14.9519 8.38604C14.9205 8.31017 14.8744 8.24123 14.8164 8.18316C14.7583 8.12509 14.6894 8.07903 14.6135 8.0476C14.5376 8.01617 14.4563 8 14.3742 8C14.2921 8 14.2107 8.01617 14.1349 8.0476C14.059 8.07903 13.9901 8.12509 13.932 8.18316L12.057 10.0582C11.9397 10.1754 11.8738 10.3345 11.8738 10.5003C11.8738 10.6662 11.9397 10.8253 12.057 10.9425C12.1743 11.0598 12.3333 11.1257 12.4992 11.1257C12.665 11.1257 12.8241 11.0598 12.9414 10.9425L14.8164 9.06754ZM17.9414 5.05816C17.8833 5.00005 17.8144 4.95395 17.7385 4.9225C17.6627 4.89105 17.5813 4.87486 17.4992 4.87486C17.4171 4.87486 17.3357 4.89105 17.2599 4.9225C17.184 4.95395 17.115 5.00005 17.057 5.05816L15.807 6.30816C15.6897 6.42544 15.6238 6.5845 15.6238 6.75035C15.6238 6.9162 15.6897 7.07526 15.807 7.19254C15.9243 7.30981 16.0833 7.37569 16.2492 7.3757C16.415 7.3757 16.5741 7.30981 16.6914 7.19254L17.9414 5.94254C17.9995 5.88449 18.0456 5.81556 18.077 5.73969C18.1085 5.66381 18.1247 5.58248 18.1247 5.50035C18.1247 5.41821 18.1085 5.33688 18.077 5.26101C18.0456 5.18514 17.9995 5.11621 17.9414 5.05816ZM9.557 8.44254C9.61505 8.50065 9.68398 8.54674 9.75985 8.5782C9.83572 8.60965 9.91705 8.62584 9.99919 8.62584C10.0813 8.62584 10.1627 8.60965 10.2385 8.5782C10.3144 8.54674 10.3833 8.50065 10.4414 8.44254L16.0664 2.81754C16.1244 2.75947 16.1705 2.69053 16.2019 2.61466C16.2334 2.53879 16.2495 2.45747 16.2495 2.37535C16.2495 2.29323 16.2334 2.21191 16.2019 2.13604C16.1705 2.06017 16.1244 1.99123 16.0664 1.93316C16.0083 1.87509 15.9394 1.82903 15.8635 1.7976C15.7876 1.76618 15.7063 1.75 15.6242 1.75C15.5421 1.75 15.4607 1.76618 15.3849 1.7976C15.309 1.82903 15.2401 1.87509 15.182 1.93316L9.557 7.55816C9.49889 7.61621 9.45279 7.68514 9.42134 7.76101C9.38989 7.83688 9.3737 7.91821 9.3737 8.00035C9.3737 8.08248 9.38989 8.16381 9.42134 8.23969C9.45279 8.31556 9.49889 8.38449 9.557 8.44254ZM10.5929 16.0941C9.77242 16.9146 8.65957 17.3756 7.49919 17.3756C6.33881 17.3756 5.22595 16.9146 4.40544 16.0941C3.58492 15.2736 3.12396 14.1607 3.12396 13.0003C3.12396 11.84 3.58492 10.7271 4.40544 9.9066L10.8703 3.44253C10.9284 3.38447 10.9744 3.31553 11.0058 3.23966C11.0373 3.16379 11.0534 3.08247 11.0534 3.00035C11.0534 2.91823 11.0373 2.83691 11.0058 2.76104C10.9744 2.68517 10.9284 2.61623 10.8703 2.55816C10.8122 2.50009 10.7433 2.45403 10.6674 2.4226C10.5915 2.39118 10.5102 2.375 10.4281 2.375C10.346 2.375 10.2647 2.39118 10.1888 2.4226C10.1129 2.45403 10.044 2.50009 9.98591 2.55816L3.52184 9.023C2.99253 9.54377 2.57156 10.1642 2.28322 10.8484C1.99488 11.5327 1.84487 12.2673 1.84184 13.0098C1.83882 13.7524 1.98284 14.4882 2.2656 15.1747C2.54836 15.8613 2.96427 16.4852 3.48932 17.0102C4.01438 17.5353 4.63819 17.9512 5.32479 18.2339C6.01138 18.5167 6.74717 18.6607 7.4897 18.6577C8.23223 18.6547 8.96682 18.5047 9.65109 18.2163C10.3354 17.928 10.9558 17.507 11.4765 16.9777C11.5888 16.8595 11.6505 16.7022 11.6484 16.5392C11.6463 16.3762 11.5806 16.2205 11.4654 16.1053C11.3501 15.99 11.1944 15.9243 11.0314 15.9223C10.8684 15.9202 10.7111 15.9818 10.5929 16.0941Z" stroke="url(#paint1_linear_2850_16638)" stroke-width="0.3"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2850_16638" x1="1.95109" y1="1.75" x2="21.5698" y2="11.5208" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0CF1CA"/>
|
||||
<stop offset="0.264423" stop-color="#0BD2B0"/>
|
||||
<stop offset="0.307692" stop-color="#0CF1CA"/>
|
||||
<stop offset="0.427885" stop-color="#0CF1CA"/>
|
||||
<stop offset="0.466346" stop-color="#0FAF94"/>
|
||||
<stop offset="0.591346" stop-color="#0CA288"/>
|
||||
<stop offset="1" stop-color="#086253"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2850_16638" x1="1.8418" y1="2.25694" x2="21.3121" y2="11.25" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -1,7 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useDownload, useLibrary, useUserDetails } from "@renderer/hooks";
|
||||
import {
|
||||
useDownload,
|
||||
useLibrary,
|
||||
useToast,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import "./bottom-panel.scss";
|
||||
|
||||
@@ -17,20 +22,52 @@ export function BottomPanel() {
|
||||
|
||||
const { library } = useLibrary();
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
|
||||
|
||||
const [version, setVersion] = useState("");
|
||||
const [sessionHash, setSessionHash] = useState<null | string>("");
|
||||
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getVersion().then((result) => setVersion(result));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = window.electron.onCommonRedistProgress(
|
||||
({ log, complete }) => {
|
||||
if (log === "Installation timed out" || complete) {
|
||||
setCommonRedistStatus(null);
|
||||
|
||||
if (complete) {
|
||||
showSuccessToast(
|
||||
t("installation_complete"),
|
||||
t("installation_complete_message")
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonRedistStatus(log);
|
||||
}
|
||||
);
|
||||
|
||||
return () => unlisten();
|
||||
}, [t, showSuccessToast]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getSessionHash().then((result) => setSessionHash(result));
|
||||
}, [userDetails?.id]);
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (commonRedistStatus) {
|
||||
return t("installing_common_redist", { log: commonRedistStatus });
|
||||
}
|
||||
|
||||
const game = lastPacket
|
||||
? library.find((game) => game.id === lastPacket?.gameId)
|
||||
: undefined;
|
||||
@@ -64,7 +101,15 @@ export function BottomPanel() {
|
||||
}
|
||||
|
||||
return t("no_downloads_in_progress");
|
||||
}, [t, library, lastPacket, progress, eta, downloadSpeed]);
|
||||
}, [
|
||||
t,
|
||||
library,
|
||||
lastPacket,
|
||||
progress,
|
||||
eta,
|
||||
downloadSpeed,
|
||||
commonRedistStatus,
|
||||
]);
|
||||
|
||||
return (
|
||||
<footer className="bottom-panel">
|
||||
|
||||
11
src/renderer/src/components/debrid-badge/debrid-badge.scss
Normal file
11
src/renderer/src/components/debrid-badge/debrid-badge.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.debrid-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(12, 241, 202, 0.3);
|
||||
background: rgba(12, 241, 202, 0.05);
|
||||
color: #0cf1ca;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
18
src/renderer/src/components/debrid-badge/debrid-badge.tsx
Normal file
18
src/renderer/src/components/debrid-badge/debrid-badge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Meteor from "@renderer/assets/meteor.svg?react";
|
||||
import "./debrid-badge.scss";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface DebridBadgeProps {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export function DebridBadge({ collapsed }: Readonly<DebridBadgeProps>) {
|
||||
const { t } = useTranslation("hydra_cloud");
|
||||
|
||||
return (
|
||||
<div className="debrid-badge">
|
||||
<Meteor />
|
||||
{!collapsed && t("debrid_description")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,3 +14,4 @@ export * from "./toast/toast";
|
||||
export * from "./badge/badge";
|
||||
export * from "./confirmation-modal/confirmation-modal";
|
||||
export * from "./suspense-wrapper/suspense-wrapper";
|
||||
export * from "./debrid-badge/debrid-badge";
|
||||
|
||||
@@ -11,6 +11,7 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.Datanodes]: "Datanodes",
|
||||
[Downloader.Mediafire]: "Mediafire",
|
||||
[Downloader.TorBox]: "TorBox",
|
||||
[Downloader.Hydra]: "Nimbus",
|
||||
};
|
||||
|
||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
@@ -86,7 +86,10 @@ export function GameDetailsContextProvider({
|
||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
const repacks = getRepacksForObjectId(objectId);
|
||||
|
||||
const repacks = useMemo(() => {
|
||||
return getRepacksForObjectId(objectId);
|
||||
}, [getRepacksForObjectId, objectId]);
|
||||
|
||||
const { i18n } = useTranslation("game_details");
|
||||
|
||||
|
||||
21
src/renderer/src/declaration.d.ts
vendored
21
src/renderer/src/declaration.d.ts
vendored
@@ -59,6 +59,9 @@ declare global {
|
||||
cb: (value: SeedingStatus[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onHardDelete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
checkDebridAvailability: (
|
||||
magnets: string[]
|
||||
) => Promise<Record<string, boolean>>;
|
||||
|
||||
/* Catalogue */
|
||||
searchGames: (
|
||||
@@ -149,6 +152,8 @@ declare global {
|
||||
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
resetGameAchievements: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
/* User preferences */
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
updateUserPreferences: (
|
||||
preferences: Partial<UserPreferences>
|
||||
@@ -157,14 +162,21 @@ declare global {
|
||||
enabled: boolean;
|
||||
minimized: boolean;
|
||||
}) => Promise<void>;
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
|
||||
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
onExtractionComplete: (
|
||||
cb: (shop: GameShop, objectId: string) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Download sources */
|
||||
putDownloadSource: (
|
||||
objectIds: string[]
|
||||
) => Promise<{ fingerprint: string }>;
|
||||
createDownloadSources: (urls: string[]) => Promise<void>;
|
||||
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
|
||||
getDownloadSources: () => Promise<
|
||||
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[]
|
||||
>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<disk.DiskUsage>;
|
||||
@@ -225,6 +237,11 @@ declare global {
|
||||
showItemInFolder: (path: string) => Promise<void>;
|
||||
getFeatures: () => Promise<string[]>;
|
||||
getBadges: () => Promise<Badge[]>;
|
||||
canInstallCommonRedist: () => Promise<boolean>;
|
||||
installCommonRedist: () => Promise<void>;
|
||||
onCommonRedistProgress: (
|
||||
cb: (value: { log: string; complete: boolean }) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
platform: NodeJS.Platform;
|
||||
|
||||
/* Auto update */
|
||||
|
||||
@@ -10,20 +10,11 @@ export interface HowLongToBeatEntry {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CatalogueCache {
|
||||
id?: number;
|
||||
category: string;
|
||||
games: { objectId: string; shop: GameShop }[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export const db = new Dexie("Hydra");
|
||||
|
||||
db.version(8).stores({
|
||||
db.version(9).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
|
||||
downloadSources: `++id, url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
|
||||
downloadSources: `++id, &url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
|
||||
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { formatDate, getDateLocale } from "@shared";
|
||||
import { format, formatDistance, subMilliseconds } from "date-fns";
|
||||
import type { FormatDistanceOptions } from "date-fns";
|
||||
import {
|
||||
ptBR,
|
||||
enUS,
|
||||
es,
|
||||
fr,
|
||||
pl,
|
||||
hu,
|
||||
tr,
|
||||
ru,
|
||||
it,
|
||||
be,
|
||||
zhCN,
|
||||
da,
|
||||
} from "date-fns/locale";
|
||||
import { enUS } from "date-fns/locale";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useDate() {
|
||||
@@ -21,22 +9,6 @@ export function useDate() {
|
||||
|
||||
const { language } = i18n;
|
||||
|
||||
const getDateLocale = () => {
|
||||
if (language.startsWith("pt")) return ptBR;
|
||||
if (language.startsWith("es")) return es;
|
||||
if (language.startsWith("fr")) return fr;
|
||||
if (language.startsWith("hu")) return hu;
|
||||
if (language.startsWith("pl")) return pl;
|
||||
if (language.startsWith("tr")) return tr;
|
||||
if (language.startsWith("ru")) return ru;
|
||||
if (language.startsWith("it")) return it;
|
||||
if (language.startsWith("be")) return be;
|
||||
if (language.startsWith("zh")) return zhCN;
|
||||
if (language.startsWith("da")) return da;
|
||||
|
||||
return enUS;
|
||||
};
|
||||
|
||||
return {
|
||||
formatDistance: (
|
||||
date: string | number | Date,
|
||||
@@ -46,7 +18,7 @@ export function useDate() {
|
||||
try {
|
||||
return formatDistance(date, baseDate, {
|
||||
...options,
|
||||
locale: getDateLocale(),
|
||||
locale: getDateLocale(language),
|
||||
});
|
||||
} catch (err) {
|
||||
return "";
|
||||
@@ -61,7 +33,7 @@ export function useDate() {
|
||||
try {
|
||||
return formatDistance(subMilliseconds(new Date(), millis), baseDate, {
|
||||
...options,
|
||||
locale: getDateLocale(),
|
||||
locale: getDateLocale(language),
|
||||
});
|
||||
} catch (err) {
|
||||
return "";
|
||||
@@ -69,18 +41,13 @@ export function useDate() {
|
||||
},
|
||||
|
||||
formatDateTime: (date: number | Date | string): string => {
|
||||
const locale = getDateLocale();
|
||||
const locale = getDateLocale(language);
|
||||
return format(
|
||||
date,
|
||||
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy HH:mm"
|
||||
);
|
||||
},
|
||||
|
||||
formatDate: (date: number | Date | string): string => {
|
||||
if (isNaN(new Date(date).getDate())) return "N/A";
|
||||
|
||||
const locale = getDateLocale();
|
||||
return format(date, locale == enUS ? "MM/dd/yyyy" : "dd/MM/yyyy");
|
||||
},
|
||||
formatDate: (date: number | Date | string) => formatDate(date, language),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ import type {
|
||||
StartGameDownloadPayload,
|
||||
} from "@types";
|
||||
import { useDate } from "./use-date";
|
||||
import { formatBytes } from "@shared";
|
||||
import { formatBytes, formatBytesToMbps } from "@shared";
|
||||
|
||||
export function useDownload() {
|
||||
const { updateLibrary } = useLibrary();
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
const userPrefs = useAppSelector((state) => state.userPreferences.value);
|
||||
|
||||
const { lastPacket, gamesWithDeletionInProgress } = useAppSelector(
|
||||
(state) => state.download
|
||||
);
|
||||
@@ -99,8 +101,14 @@ export function useDownload() {
|
||||
return gamesWithDeletionInProgress.includes(objectId);
|
||||
};
|
||||
|
||||
const formatDownloadSpeed = (downloadSpeed: number): string => {
|
||||
return userPrefs?.showDownloadSpeedInMegabytes
|
||||
? `${formatBytes(downloadSpeed)}/s`
|
||||
: formatBytesToMbps(downloadSpeed);
|
||||
};
|
||||
|
||||
return {
|
||||
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
|
||||
downloadSpeed: formatDownloadSpeed(lastPacket?.downloadSpeed ?? 0),
|
||||
progress: formatDownloadProgress(lastPacket?.progress ?? 0),
|
||||
lastPacket,
|
||||
eta: calculateETA(),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
enum Feature {
|
||||
CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
|
||||
Torbox = "TORBOX",
|
||||
Nimbus = "NIMBUS",
|
||||
NimbusPreview = "NIMBUS_PREVIEW",
|
||||
}
|
||||
|
||||
export function useFeature() {
|
||||
@@ -15,14 +17,17 @@ export function useFeature() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isFeatureEnabled = (feature: Feature) => {
|
||||
if (!features) {
|
||||
const features = JSON.parse(localStorage.getItem("features") ?? "[]");
|
||||
return features.includes(feature);
|
||||
}
|
||||
const isFeatureEnabled = useCallback(
|
||||
(feature: Feature) => {
|
||||
if (!features) {
|
||||
const features = JSON.parse(localStorage.getItem("features") ?? "[]");
|
||||
return features.includes(feature);
|
||||
}
|
||||
|
||||
return features.includes(feature);
|
||||
};
|
||||
return features.includes(feature);
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
||||
return {
|
||||
isFeatureEnabled,
|
||||
|
||||
@@ -18,14 +18,11 @@ export function Pagination({
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
// Number of visible pages
|
||||
const visiblePages = 3;
|
||||
|
||||
// Calculate the start and end of the visible range
|
||||
let startPage = Math.max(1, page - 1); // Shift range slightly back
|
||||
let startPage = Math.max(1, page - 1);
|
||||
let endPage = startPage + visiblePages - 1;
|
||||
|
||||
// Adjust the range if we're near the start or end
|
||||
if (endPage > totalPages) {
|
||||
endPage = totalPages;
|
||||
startPage = Math.max(1, endPage - visiblePages + 1);
|
||||
@@ -33,7 +30,6 @@ export function Pagination({
|
||||
|
||||
return (
|
||||
<div className="pagination">
|
||||
{/* Previous Button */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
@@ -45,7 +41,6 @@ export function Pagination({
|
||||
|
||||
{page > 2 && (
|
||||
<>
|
||||
{/* initial page */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(1)}
|
||||
@@ -55,14 +50,12 @@ export function Pagination({
|
||||
{1}
|
||||
</Button>
|
||||
|
||||
{/* ellipsis */}
|
||||
<div className="pagination__ellipsis">
|
||||
<span className="pagination__ellipsis-text">...</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Page Buttons */}
|
||||
{Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, i) => startPage + i
|
||||
@@ -79,12 +72,10 @@ export function Pagination({
|
||||
|
||||
{page < totalPages - 1 && (
|
||||
<>
|
||||
{/* ellipsis */}
|
||||
<div className="pagination__ellipsis">
|
||||
<span className="pagination__ellipsis-text">...</span>
|
||||
</div>
|
||||
|
||||
{/* last page */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
@@ -96,7 +87,6 @@ export function Pagination({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Next Button */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
|
||||
import { Downloader, formatBytes, steamUrlBuilder } from "@shared";
|
||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||
import { useAppSelector, useDownload } from "@renderer/hooks";
|
||||
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
|
||||
|
||||
import "./download-group.scss";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
ColumnsIcon,
|
||||
DownloadIcon,
|
||||
FileDirectoryIcon,
|
||||
LinkIcon,
|
||||
PlayIcon,
|
||||
QuestionIcon,
|
||||
@@ -56,6 +57,8 @@ export function DownloadGroup({
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
const {
|
||||
lastPacket,
|
||||
progress,
|
||||
@@ -89,6 +92,14 @@ export function DownloadGroup({
|
||||
return map;
|
||||
}, [seedingStatus]);
|
||||
|
||||
const extractGameDownload = useCallback(
|
||||
async (shop: GameShop, objectId: string) => {
|
||||
await window.electron.extractGameDownload(shop, objectId);
|
||||
updateLibrary();
|
||||
},
|
||||
[updateLibrary]
|
||||
);
|
||||
|
||||
const getGameInfo = (game: LibraryGame) => {
|
||||
const download = game.download!;
|
||||
|
||||
@@ -96,6 +107,10 @@ export function DownloadGroup({
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
const seedingStatus = seedingMap.get(game.id);
|
||||
|
||||
if (download.extracting) {
|
||||
return <p>{t("extracting")}</p>;
|
||||
}
|
||||
|
||||
if (isGameDeleting(game.id)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
@@ -197,6 +212,14 @@ export function DownloadGroup({
|
||||
},
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
label: t("extract"),
|
||||
disabled: game.download.extracting,
|
||||
icon: <FileDirectoryIcon />,
|
||||
onClick: () => {
|
||||
extractGameDownload(game.shop, game.objectId);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("stop_seeding"),
|
||||
disabled: deleting,
|
||||
@@ -351,6 +374,21 @@ export function DownloadGroup({
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{game.download?.downloader === Downloader.Hydra && (
|
||||
<div
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #01483C 0%, #0CF1CA 50%, #01483C 100%)",
|
||||
boxShadow: "0px 0px 8px 0px rgba(12, 241, 202, 0.15)",
|
||||
width: "100%",
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
height: 2,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -38,7 +38,13 @@ export default function Downloads() {
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
|
||||
}, []);
|
||||
|
||||
const unsubscribe = window.electron.onExtractionComplete(() => {
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [updateLibrary]);
|
||||
|
||||
const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
|
||||
window.electron.openGameInstaller(shop, objectId).then((isBinaryInPath) => {
|
||||
@@ -67,7 +73,7 @@ export default function Downloads() {
|
||||
if (!next.download) return prev;
|
||||
|
||||
/* Is downloading */
|
||||
if (lastPacket?.gameId === next.id)
|
||||
if (lastPacket?.gameId === next.id || next.download.extracting)
|
||||
return { ...prev, downloading: [...prev.downloading, next] };
|
||||
|
||||
/* Is either queued or paused */
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
|
||||
import "./cloud-sync-modal.scss";
|
||||
import { formatBytes } from "@shared";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ClockIcon,
|
||||
DeviceDesktopIcon,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
import { useAppSelector, useDate, useToast } from "@renderer/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AxiosProgressEvent } from "axios";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
@@ -29,6 +28,8 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { formatDate, formatDateTime } = useDate();
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
backupPreview,
|
||||
@@ -205,7 +206,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
<h3>
|
||||
{artifact.label ??
|
||||
t("backup_from", {
|
||||
date: format(artifact.createdAt, "dd/MM/yyyy"),
|
||||
date: formatDate(artifact.createdAt),
|
||||
})}
|
||||
</h3>
|
||||
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
|
||||
@@ -223,7 +224,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
|
||||
<span className="cloud-sync-modal__artifact-meta">
|
||||
<ClockIcon size={14} />
|
||||
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
|
||||
{formatDateTime(artifact.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ export default function GameDetails() {
|
||||
const handleStartDownload = async (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
downloadPath: string,
|
||||
automaticallyExtract: boolean
|
||||
) => {
|
||||
const response = await startDownload({
|
||||
repackId: repack.id,
|
||||
@@ -108,6 +109,7 @@ export default function GameDetails() {
|
||||
shop,
|
||||
downloadPath,
|
||||
uri: selectRepackUri(repack, downloader),
|
||||
automaticallyExtract: automaticallyExtract,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button, Link, Modal, TextField } from "@renderer/components";
|
||||
import {
|
||||
Button,
|
||||
CheckboxField,
|
||||
Link,
|
||||
Modal,
|
||||
TextField,
|
||||
} from "@renderer/components";
|
||||
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
|
||||
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
|
||||
import type { GameRepack } from "@types";
|
||||
@@ -14,7 +20,8 @@ export interface DownloadSettingsModalProps {
|
||||
startDownload: (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
downloadPath: string,
|
||||
automaticallyExtract: boolean
|
||||
) => Promise<{ ok: boolean; error?: string }>;
|
||||
repack: GameRepack | null;
|
||||
}
|
||||
@@ -32,6 +39,8 @@ export function DownloadSettingsModal({
|
||||
const [diskFreeSpace, setDiskFreeSpace] = useState<number | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState("");
|
||||
const [downloadStarting, setDownloadStarting] = useState(false);
|
||||
const [automaticExtractionEnabled, setAutomaticExtractionEnabled] =
|
||||
useState(true);
|
||||
const [selectedDownloader, setSelectedDownloader] =
|
||||
useState<Downloader | null>(null);
|
||||
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
|
||||
@@ -72,6 +81,25 @@ export function DownloadSettingsModal({
|
||||
return getDownloadersForUris(repack?.uris ?? []);
|
||||
}, [repack?.uris]);
|
||||
|
||||
const getDefaultDownloader = useCallback(
|
||||
(availableDownloaders: Downloader[]) => {
|
||||
if (availableDownloaders.includes(Downloader.Hydra)) {
|
||||
return Downloader.Hydra;
|
||||
}
|
||||
|
||||
if (availableDownloaders.includes(Downloader.TorBox)) {
|
||||
return Downloader.TorBox;
|
||||
}
|
||||
|
||||
if (availableDownloaders.includes(Downloader.RealDebrid)) {
|
||||
return Downloader.RealDebrid;
|
||||
}
|
||||
|
||||
return availableDownloaders[0];
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences?.downloadsPath) {
|
||||
setSelectedPath(userPreferences.downloadsPath);
|
||||
@@ -86,16 +114,16 @@ export function DownloadSettingsModal({
|
||||
return userPreferences?.realDebridApiToken;
|
||||
if (downloader === Downloader.TorBox)
|
||||
return userPreferences?.torBoxApiToken;
|
||||
if (downloader === Downloader.Hydra)
|
||||
return isFeatureEnabled(Feature.Nimbus);
|
||||
return true;
|
||||
});
|
||||
|
||||
/* Gives preference to TorBox */
|
||||
const selectedDownloader = filteredDownloaders.includes(Downloader.TorBox)
|
||||
? Downloader.TorBox
|
||||
: filteredDownloaders[0];
|
||||
|
||||
setSelectedDownloader(selectedDownloader ?? null);
|
||||
setSelectedDownloader(getDefaultDownloader(filteredDownloaders));
|
||||
}, [
|
||||
Feature,
|
||||
isFeatureEnabled,
|
||||
getDefaultDownloader,
|
||||
userPreferences?.downloadsPath,
|
||||
downloaders,
|
||||
userPreferences?.realDebridApiToken,
|
||||
@@ -122,7 +150,8 @@ export function DownloadSettingsModal({
|
||||
const response = await startDownload(
|
||||
repack,
|
||||
selectedDownloader!,
|
||||
selectedPath
|
||||
selectedPath,
|
||||
automaticExtractionEnabled
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
@@ -160,7 +189,9 @@ export function DownloadSettingsModal({
|
||||
(downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken);
|
||||
!userPreferences?.torBoxApiToken) ||
|
||||
(downloader === Downloader.Hydra &&
|
||||
!isFeatureEnabled(Feature.Nimbus));
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -217,6 +248,14 @@ export function DownloadSettingsModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
label={t("automatically_extract_downloaded_files")}
|
||||
checked={automaticExtractionEnabled}
|
||||
onChange={() =>
|
||||
setAutomaticExtractionEnabled(!automaticExtractionEnabled)
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleStartClick}
|
||||
disabled={
|
||||
|
||||
@@ -134,6 +134,7 @@ export function GameOptionsModal({
|
||||
|
||||
const handleClearExecutablePath = async () => {
|
||||
await window.electron.updateExecutablePath(game.shop, game.objectId, null);
|
||||
|
||||
updateGame();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Badge, Button, Modal, TextField } from "@renderer/components";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DebridBadge,
|
||||
Modal,
|
||||
TextField,
|
||||
} from "@renderer/components";
|
||||
import type { GameRepack } from "@types";
|
||||
|
||||
import { DownloadSettingsModal } from "./download-settings-modal";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { Downloader } from "@shared";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { useDate } from "@renderer/hooks";
|
||||
import { useDate, useFeature } from "@renderer/hooks";
|
||||
import "./repacks-modal.scss";
|
||||
|
||||
export interface RepacksModalProps {
|
||||
@@ -16,7 +22,8 @@ export interface RepacksModalProps {
|
||||
startDownload: (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
downloadPath: string,
|
||||
automaticallyExtract: boolean
|
||||
) => Promise<{ ok: boolean; error?: string }>;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -30,15 +37,57 @@ export function RepacksModal({
|
||||
const [repack, setRepack] = useState<GameRepack | null>(null);
|
||||
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
|
||||
|
||||
const [hashesInDebrid, setHashesInDebrid] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const { repacks, game } = useContext(gameDetailsContext);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { formatDate } = useDate();
|
||||
|
||||
const getHashFromMagnet = (magnet: string) => {
|
||||
if (!magnet || typeof magnet !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashRegex = /xt=urn:btih:([a-zA-Z0-9]+)/i;
|
||||
const match = magnet.match(hashRegex);
|
||||
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
};
|
||||
|
||||
const { isFeatureEnabled, Feature } = useFeature();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFeatureEnabled(Feature.NimbusPreview)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const magnets = repacks.flatMap((repack) =>
|
||||
repack.uris.filter((uri) => uri.startsWith("magnet:"))
|
||||
);
|
||||
|
||||
window.electron.checkDebridAvailability(magnets).then((availableHashes) => {
|
||||
setHashesInDebrid(availableHashes);
|
||||
});
|
||||
}, [repacks, isFeatureEnabled, Feature]);
|
||||
|
||||
const sortedRepacks = useMemo(() => {
|
||||
return orderBy(repacks, (repack) => repack.uploadDate, "desc");
|
||||
}, [repacks]);
|
||||
return orderBy(
|
||||
repacks,
|
||||
[
|
||||
(repack) => {
|
||||
const magnet = repack.uris.find((uri) => uri.startsWith("magnet:"));
|
||||
const hash = magnet ? getHashFromMagnet(magnet) : null;
|
||||
return hash ? (hashesInDebrid[hash] ?? false) : false;
|
||||
},
|
||||
(repack) => repack.uploadDate,
|
||||
],
|
||||
["desc", "desc"]
|
||||
);
|
||||
}, [repacks, hashesInDebrid]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredRepacks(sortedRepacks);
|
||||
@@ -109,6 +158,10 @@ export function RepacksModal({
|
||||
{repack.fileSize} - {repack.repacker} -{" "}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
|
||||
</p>
|
||||
|
||||
{hashesInDebrid[getHashFromMagnet(repack.uris[0]) ?? ""] && (
|
||||
<DebridBadge />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,7 +25,7 @@ import "./sidebar.scss";
|
||||
const achievementsPlaceholder: UserAchievement[] = [
|
||||
{
|
||||
displayName: "Timber!!",
|
||||
name: "",
|
||||
name: "1",
|
||||
hidden: false,
|
||||
description: "Chop down your first tree.",
|
||||
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0fbb33098c9da39d1d4771d8209afface9c46e81.jpg",
|
||||
@@ -36,7 +36,7 @@ const achievementsPlaceholder: UserAchievement[] = [
|
||||
},
|
||||
{
|
||||
displayName: "Supreme Helper Minion!",
|
||||
name: "",
|
||||
name: "2",
|
||||
hidden: false,
|
||||
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0a6ff6a36670c96ceb4d30cf6fd69d2fdf55f38e.jpg",
|
||||
icongray:
|
||||
@@ -46,7 +46,7 @@ const achievementsPlaceholder: UserAchievement[] = [
|
||||
},
|
||||
{
|
||||
displayName: "Feast of Midas",
|
||||
name: "",
|
||||
name: "3",
|
||||
hidden: false,
|
||||
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/2d10311274fe7c92ab25cc29afdca86b019ad472.jpg",
|
||||
icongray:
|
||||
@@ -122,8 +122,8 @@ export function Sidebar() {
|
||||
<h3>{t("sign_in_to_see_achievements")}</h3>
|
||||
</div>
|
||||
<ul className="list achievements-placeholder__blur">
|
||||
{achievementsPlaceholder.map((achievement, index) => (
|
||||
<li key={index}>
|
||||
{achievementsPlaceholder.map((achievement) => (
|
||||
<li key={achievement.name}>
|
||||
<div className="list__item">
|
||||
<img
|
||||
className={`list__item-image achievements-placeholder__blur ${
|
||||
|
||||
@@ -119,7 +119,8 @@ export function AddDownloadSourceModal({
|
||||
|
||||
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
|
||||
|
||||
channel.onmessage = async () => {
|
||||
channel.onmessage = () => {
|
||||
window.electron.createDownloadSources([url]);
|
||||
setIsLoading(false);
|
||||
|
||||
putDownloadSource();
|
||||
|
||||
@@ -23,6 +23,7 @@ export function SettingsBehavior() {
|
||||
enableAutoInstall: false,
|
||||
seedAfterDownloadComplete: false,
|
||||
showHiddenAchievementsDescription: false,
|
||||
showDownloadSpeedInMegabytes: false,
|
||||
});
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -40,6 +41,8 @@ export function SettingsBehavior() {
|
||||
userPreferences.seedAfterDownloadComplete ?? false,
|
||||
showHiddenAchievementsDescription:
|
||||
userPreferences.showHiddenAchievementsDescription ?? false,
|
||||
showDownloadSpeedInMegabytes:
|
||||
userPreferences.showDownloadSpeedInMegabytes ?? false,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
@@ -139,6 +142,16 @@ export function SettingsBehavior() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("show_download_speed_in_megabytes")}
|
||||
checked={form.showDownloadSpeedInMegabytes}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
showDownloadSpeedInMegabytes: !form.showDownloadSpeedInMegabytes,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,14 +70,20 @@ export function SettingsDownloadSources() {
|
||||
if (sourceUrl) setShowAddDownloadSourceModal(true);
|
||||
}, [sourceUrl]);
|
||||
|
||||
const handleRemoveSource = (id: number) => {
|
||||
const handleRemoveSource = (downloadSource: DownloadSource) => {
|
||||
setIsRemovingDownloadSource(true);
|
||||
const channel = new BroadcastChannel(`download_sources:delete:${id}`);
|
||||
const channel = new BroadcastChannel(
|
||||
`download_sources:delete:${downloadSource.id}`
|
||||
);
|
||||
|
||||
downloadSourcesWorker.postMessage(["DELETE_DOWNLOAD_SOURCE", id]);
|
||||
downloadSourcesWorker.postMessage([
|
||||
"DELETE_DOWNLOAD_SOURCE",
|
||||
downloadSource.id,
|
||||
]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
showSuccessToast(t("removed_download_source"));
|
||||
window.electron.removeDownloadSource(downloadSource.url);
|
||||
|
||||
getDownloadSources();
|
||||
setIsRemovingDownloadSource(false);
|
||||
@@ -96,7 +102,7 @@ export function SettingsDownloadSources() {
|
||||
|
||||
channel.onmessage = () => {
|
||||
showSuccessToast(t("removed_download_sources"));
|
||||
|
||||
window.electron.removeDownloadSource("", true);
|
||||
getDownloadSources();
|
||||
setIsRemovingDownloadSource(false);
|
||||
setShowConfirmationDeleteAllSourcesModal(false);
|
||||
@@ -253,7 +259,7 @@ export function SettingsDownloadSources() {
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRemoveSource(downloadSource.id)}
|
||||
onClick={() => handleRemoveSource(downloadSource)}
|
||||
disabled={isRemovingDownloadSource}
|
||||
>
|
||||
<NoEntryIcon />
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit * 2;
|
||||
|
||||
&__notifications-title {
|
||||
&__section-title {
|
||||
margin-top: calc(globals.$spacing-unit * 2);
|
||||
margin-bottom: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__common-redist-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import languageResources from "@locales";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import "./settings-general.scss";
|
||||
import { DesktopDownloadIcon } from "@primer/octicons-react";
|
||||
import { logger } from "@renderer/logger";
|
||||
|
||||
interface LanguageOption {
|
||||
option: string;
|
||||
@@ -27,6 +29,9 @@ export function SettingsGeneral() {
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false);
|
||||
const [installingCommonRedist, setInstallingCommonRedist] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
downloadsPath: "",
|
||||
downloadNotificationsEnabled: false,
|
||||
@@ -47,6 +52,16 @@ export function SettingsGeneral() {
|
||||
setDefaultDownloadsPath(path);
|
||||
});
|
||||
|
||||
window.electron.canInstallCommonRedist().then((canInstall) => {
|
||||
setCanInstallCommonRedist(canInstall);
|
||||
});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
window.electron.canInstallCommonRedist().then((canInstall) => {
|
||||
setCanInstallCommonRedist(canInstall);
|
||||
});
|
||||
}, 1000 * 5);
|
||||
|
||||
setLanguageOptions(
|
||||
orderBy(
|
||||
Object.entries(languageResources).map(([language, value]) => {
|
||||
@@ -59,6 +74,10 @@ export function SettingsGeneral() {
|
||||
"asc"
|
||||
)
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,7 +109,9 @@ export function SettingsGeneral() {
|
||||
}
|
||||
}, [userPreferences, defaultDownloadsPath]);
|
||||
|
||||
const handleLanguageChange = (event) => {
|
||||
const handleLanguageChange = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
const value = event.target.value;
|
||||
|
||||
handleChange({ language: value });
|
||||
@@ -114,6 +135,28 @@ export function SettingsGeneral() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = window.electron.onCommonRedistProgress(
|
||||
({ log, complete }) => {
|
||||
if (log === "Installation timed out" || complete) {
|
||||
setInstallingCommonRedist(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => unlisten();
|
||||
}, []);
|
||||
|
||||
const handleInstallCommonRedist = async () => {
|
||||
setInstallingCommonRedist(true);
|
||||
try {
|
||||
await window.electron.installCommonRedist();
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
setInstallingCommonRedist(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-general">
|
||||
<TextField
|
||||
@@ -139,9 +182,7 @@ export function SettingsGeneral() {
|
||||
}))}
|
||||
/>
|
||||
|
||||
<p className="settings-general__notifications-title">
|
||||
{t("notifications")}
|
||||
</p>
|
||||
<h2 className="settings-general__section-title">{t("notifications")}</h2>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_download_notifications")}
|
||||
@@ -185,6 +226,23 @@ export function SettingsGeneral() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<h2 className="settings-general__section-title">{t("common_redist")}</h2>
|
||||
|
||||
<p className="settings-general__common-redist-description">
|
||||
{t("common_redist_description")}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleInstallCommonRedist}
|
||||
className="settings-general__common-redist-button"
|
||||
disabled={!canInstallCommonRedist || installingCommonRedist}
|
||||
>
|
||||
<DesktopDownloadIcon />
|
||||
{installingCommonRedist
|
||||
? t("installing_common_redist")
|
||||
: t("install_common_redist")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum Downloader {
|
||||
Datanodes,
|
||||
Mediafire,
|
||||
TorBox,
|
||||
Hydra,
|
||||
}
|
||||
|
||||
export enum DownloadSourceStatus {
|
||||
@@ -56,4 +57,7 @@ export enum DownloadError {
|
||||
NotCachedInTorbox = "download_error_not_cached_in_torbox",
|
||||
GofileQuotaExceeded = "download_error_gofile_quota_exceeded",
|
||||
RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized",
|
||||
NotCachedInHydra = "download_error_not_cached_in_hydra",
|
||||
}
|
||||
|
||||
export const FILE_EXTENSIONS_TO_EXTRACT = [".rar", ".zip", ".7z"];
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import {
|
||||
ptBR,
|
||||
enUS,
|
||||
es,
|
||||
fr,
|
||||
pl,
|
||||
hu,
|
||||
tr,
|
||||
ru,
|
||||
it,
|
||||
be,
|
||||
zhCN,
|
||||
da,
|
||||
} from "date-fns/locale";
|
||||
|
||||
import { charMap } from "./char-map";
|
||||
import { Downloader } from "./constants";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export * from "./constants";
|
||||
|
||||
@@ -33,6 +49,12 @@ export const formatBytes = (bytes: number): string => {
|
||||
return `${Math.trunc(formatedByte * 10) / 10} ${FORMAT[base]}`;
|
||||
};
|
||||
|
||||
export const formatBytesToMbps = (bytesPerSecond: number): string => {
|
||||
const bitsPerSecond = bytesPerSecond * 8;
|
||||
const mbps = bitsPerSecond / (1024 * 1024);
|
||||
return `${Math.trunc(mbps * 10) / 10} Mbps`;
|
||||
};
|
||||
|
||||
export const pipe =
|
||||
<T>(...fns: ((arg: T) => any)[]) =>
|
||||
(arg: T) =>
|
||||
@@ -95,7 +117,12 @@ export const getDownloadersForUri = (uri: string) => {
|
||||
return [Downloader.RealDebrid];
|
||||
|
||||
if (uri.startsWith("magnet:")) {
|
||||
return [Downloader.Torrent, Downloader.TorBox, Downloader.RealDebrid];
|
||||
return [
|
||||
Downloader.Torrent,
|
||||
Downloader.Hydra,
|
||||
Downloader.TorBox,
|
||||
Downloader.RealDebrid,
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -124,3 +151,29 @@ export const steamUrlBuilder = {
|
||||
icon: (objectId: string, clientIcon: string) =>
|
||||
`https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectId}/${clientIcon}.ico`,
|
||||
};
|
||||
|
||||
export const getDateLocale = (language: string) => {
|
||||
if (language.startsWith("pt")) return ptBR;
|
||||
if (language.startsWith("es")) return es;
|
||||
if (language.startsWith("fr")) return fr;
|
||||
if (language.startsWith("hu")) return hu;
|
||||
if (language.startsWith("pl")) return pl;
|
||||
if (language.startsWith("tr")) return tr;
|
||||
if (language.startsWith("ru")) return ru;
|
||||
if (language.startsWith("it")) return it;
|
||||
if (language.startsWith("be")) return be;
|
||||
if (language.startsWith("zh")) return zhCN;
|
||||
if (language.startsWith("da")) return da;
|
||||
|
||||
return enUS;
|
||||
};
|
||||
|
||||
export const formatDate = (
|
||||
date: number | Date | string,
|
||||
language: string
|
||||
): string => {
|
||||
if (isNaN(new Date(date).getDate())) return "N/A";
|
||||
|
||||
const locale = getDateLocale(language);
|
||||
return format(date, locale == enUS ? "MM/dd/yyyy" : "dd/MM/yyyy");
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@ export type DownloadStatus =
|
||||
| "error"
|
||||
| "complete"
|
||||
| "seeding"
|
||||
| "removed";
|
||||
| "removed"
|
||||
| "extracting";
|
||||
|
||||
export interface DownloadProgress {
|
||||
downloadSpeed: number;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user