mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-18 16:53:57 +00:00
Compare commits
7 Commits
v3.4.4
...
feat/httpd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91bb371e0b | ||
|
|
e507676088 | ||
|
|
f3c7010930 | ||
|
|
66d40c566b | ||
|
|
2452a3a51a | ||
|
|
4520f6bb20 | ||
|
|
d7b5bb5940 |
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -33,9 +33,9 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Additional information and data
|
label: Additional information and data
|
||||||
description: |
|
description: |
|
||||||
Add screenshots and upload your all logs file here.
|
Add screenshots and upload your logs file here.
|
||||||
Logs location on Windows: "%appdata%/hydralauncher/logs"
|
Logs location on Windows: "%appdata%/hydra"
|
||||||
Logs location on Linux: "~/.config/hydralauncher/logs"
|
Logs location on Linux: "~/.config/hydra/"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
|
|||||||
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -25,13 +25,23 @@ jobs:
|
|||||||
node-version: 20.18.0
|
node-version: 20.18.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn --frozen-lockfile
|
run: yarn
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
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
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
node-version: 20.18.0
|
node-version: 20.18.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn --frozen-lockfile
|
run: yarn
|
||||||
|
|
||||||
- name: Validate current commit (last commit) with commitlint
|
- name: Validate current commit (last commit) with commitlint
|
||||||
run: npx commitlint --last --verbose
|
run: npx commitlint --last --verbose
|
||||||
|
|||||||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@@ -26,13 +26,23 @@ jobs:
|
|||||||
node-version: 20.18.0
|
node-version: 20.18.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn --frozen-lockfile
|
run: yarn
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
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
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
@@ -88,18 +98,6 @@ jobs:
|
|||||||
dist/*.blockmap
|
dist/*.blockmap
|
||||||
dist/*.pacman
|
dist/*.pacman
|
||||||
|
|
||||||
- name: Upload build
|
|
||||||
env:
|
|
||||||
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
|
||||||
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
|
|
||||||
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
|
||||||
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
|
||||||
S3_BUILDS_BUCKET_NAME: ${{ secrets.S3_BUILDS_BUCKET_NAME }}
|
|
||||||
BUILDS_URL: ${{ secrets.BUILDS_URL }}
|
|
||||||
BUILD_WEBHOOK_URL: ${{ secrets.BUILD_WEBHOOK_URL }}
|
|
||||||
GITHUB_ACTOR: ${{ github.actor }}
|
|
||||||
run: node scripts/upload-build.cjs
|
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,4 +16,5 @@ hydra-python-rpc/
|
|||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
|
|
||||||
aria2/
|
|
||||||
|
target/
|
||||||
|
|||||||
0
binaries/7zz
Executable file → Normal file
0
binaries/7zz
Executable file → Normal file
0
binaries/7zzs
Executable file → Normal file
0
binaries/7zzs
Executable file → Normal file
BIN
binaries/hydra-httpdl.exe
Normal file
BIN
binaries/hydra-httpdl.exe
Normal file
Binary file not shown.
@@ -3,7 +3,6 @@ productName: Hydra
|
|||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
extraResources:
|
extraResources:
|
||||||
- aria2
|
|
||||||
- ludusavi
|
- ludusavi
|
||||||
- hydra-python-rpc
|
- hydra-python-rpc
|
||||||
- seeds
|
- seeds
|
||||||
@@ -23,6 +22,7 @@ win:
|
|||||||
extraResources:
|
extraResources:
|
||||||
- from: binaries/7z.exe
|
- from: binaries/7z.exe
|
||||||
- from: binaries/7z.dll
|
- from: binaries/7z.dll
|
||||||
|
- from: rust_rpc/target/release/hydra-httpdl.exe
|
||||||
target:
|
target:
|
||||||
- nsis
|
- nsis
|
||||||
- portable
|
- portable
|
||||||
@@ -40,6 +40,7 @@ mac:
|
|||||||
entitlementsInherit: build/entitlements.mac.plist
|
entitlementsInherit: build/entitlements.mac.plist
|
||||||
extraResources:
|
extraResources:
|
||||||
- from: binaries/7zz
|
- from: binaries/7zz
|
||||||
|
- from: rust_rpc/target/release/hydra-httpdl
|
||||||
extendInfo:
|
extendInfo:
|
||||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||||
@@ -51,6 +52,7 @@ dmg:
|
|||||||
linux:
|
linux:
|
||||||
extraResources:
|
extraResources:
|
||||||
- from: binaries/7zzs
|
- from: binaries/7zzs
|
||||||
|
- from: rust_rpc/target/release/hydra-httpdl
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- AppImage
|
||||||
- snap
|
- snap
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hydralauncher",
|
"name": "hydralauncher",
|
||||||
"version": "3.4.4",
|
"version": "3.4.2",
|
||||||
"description": "Hydra",
|
"description": "Hydra",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "Los Broxas",
|
"author": "Los Broxas",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
"start": "electron-vite preview",
|
"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",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
|
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
"color.js": "^1.2.0",
|
"color.js": "^1.2.0",
|
||||||
"create-desktop-shortcuts": "^1.11.1",
|
"create-desktop-shortcuts": "^1.11.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dexie": "^4.0.10",
|
"dexie": "^4.0.10",
|
||||||
"diskusage": "^1.2.0",
|
"diskusage": "^1.2.0",
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
"i18next-browser-languagedetector": "^7.2.1",
|
"i18next-browser-languagedetector": "^7.2.1",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"kill-port": "^2.0.1",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"parse-torrent": "^11.0.17",
|
"parse-torrent": "^11.0.17",
|
||||||
"piscina": "^4.7.0",
|
"piscina": "^4.7.0",
|
||||||
|
|||||||
@@ -1,48 +1,94 @@
|
|||||||
import aria2p
|
import os
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
class HttpDownloader:
|
class HttpDownloader:
|
||||||
def __init__(self):
|
def __init__(self, hydra_httpdl_bin: str):
|
||||||
self.download = None
|
self.hydra_exe = hydra_httpdl_bin
|
||||||
self.aria2 = aria2p.API(
|
self.process = None
|
||||||
aria2p.Client(
|
self.last_status = None
|
||||||
host="http://localhost",
|
|
||||||
port=6800,
|
|
||||||
secret=""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def start_download(self, url: str, save_path: str, header: str, out: str = None):
|
def start_download(self, url: str, save_path: str, header: str = None, allow_multiple_connections: bool = False, connections_limit: int = 1):
|
||||||
if self.download:
|
cmd = [self.hydra_exe]
|
||||||
self.aria2.resume([self.download])
|
|
||||||
|
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:
|
else:
|
||||||
downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out})
|
cmd.extend(["--connections", "1"])
|
||||||
|
|
||||||
self.download = downloads[0]
|
print(f"running hydra-httpdl: {' '.join(cmd)}")
|
||||||
|
|
||||||
def pause_download(self):
|
try:
|
||||||
if self.download:
|
self.process = subprocess.Popen(
|
||||||
self.aria2.pause([self.download])
|
cmd,
|
||||||
|
cwd=save_path,
|
||||||
def cancel_download(self):
|
stdout=subprocess.PIPE,
|
||||||
if self.download:
|
stderr=subprocess.PIPE,
|
||||||
self.aria2.remove([self.download])
|
universal_newlines=True
|
||||||
self.download = None
|
)
|
||||||
|
except Exception as e:
|
||||||
def get_download_status(self):
|
print(f"error running hydra-httpdl: {e}")
|
||||||
if self.download == None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
download = self.aria2.get_download(self.download.gid)
|
|
||||||
|
|
||||||
response = {
|
def get_download_status(self):
|
||||||
'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
|
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]
|
rpc_password = sys.argv[3]
|
||||||
start_download_payload = sys.argv[4]
|
start_download_payload = sys.argv[4]
|
||||||
start_seeding_payload = sys.argv[5]
|
start_seeding_payload = sys.argv[5]
|
||||||
|
hydra_httpdl_bin = sys.argv[6]
|
||||||
|
|
||||||
downloads = {}
|
downloads = {}
|
||||||
# This can be streamed down from Node
|
# This can be streamed down from Node
|
||||||
@@ -32,10 +33,10 @@ if start_download_payload:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error starting torrent download", e)
|
print("Error starting torrent download", e)
|
||||||
else:
|
else:
|
||||||
http_downloader = HttpDownloader()
|
http_downloader = HttpDownloader(hydra_httpdl_bin)
|
||||||
downloads[initial_download['game_id']] = http_downloader
|
downloads[initial_download['game_id']] = http_downloader
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
print("Error starting http download", e)
|
print("Error starting http download", e)
|
||||||
|
|
||||||
@@ -147,11 +148,11 @@ def action():
|
|||||||
torrent_downloader.start_download(url, data['save_path'])
|
torrent_downloader.start_download(url, data['save_path'])
|
||||||
else:
|
else:
|
||||||
if existing_downloader and isinstance(existing_downloader, HttpDownloader):
|
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:
|
else:
|
||||||
http_downloader = HttpDownloader()
|
http_downloader = HttpDownloader(hydra_httpdl_bin)
|
||||||
downloads[game_id] = http_downloader
|
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
|
downloading_game_id = game_id
|
||||||
|
|
||||||
@@ -182,3 +183,4 @@ def action():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=int(http_port))
|
app.run(host="0.0.0.0", port=int(http_port))
|
||||||
|
|
||||||
@@ -5,4 +5,3 @@ pywin32; sys_platform == 'win32'
|
|||||||
psutil
|
psutil
|
||||||
Pillow
|
Pillow
|
||||||
flask
|
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(())
|
||||||
|
}
|
||||||
@@ -1,18 +1,14 @@
|
|||||||
const { default: axios } = require("axios");
|
const { default: axios } = require("axios");
|
||||||
const tar = require("tar");
|
|
||||||
const util = require("node:util");
|
const util = require("node:util");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { spawnSync } = require("node:child_process");
|
|
||||||
|
|
||||||
const exec = util.promisify(require("node:child_process").exec);
|
const exec = util.promisify(require("node:child_process").exec);
|
||||||
|
|
||||||
const ludusaviVersion = "0.29.0";
|
|
||||||
|
|
||||||
const fileName = {
|
const fileName = {
|
||||||
win32: `ludusavi-v${ludusaviVersion}-win64.zip`,
|
win32: "ludusavi-v0.25.0-win64.zip",
|
||||||
linux: `ludusavi-v${ludusaviVersion}-linux.tar.gz`,
|
linux: "ludusavi-v0.25.0-linux.zip",
|
||||||
darwin: `ludusavi-v${ludusaviVersion}-mac.tar.gz`,
|
darwin: "ludusavi-v0.25.0-mac.zip",
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadLudusavi = async () => {
|
const downloadLudusavi = async () => {
|
||||||
@@ -22,7 +18,7 @@ const downloadLudusavi = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const file = fileName[process.platform];
|
const file = fileName[process.platform];
|
||||||
const downloadUrl = `https://github.com/mtkennerly/ludusavi/releases/download/v${ludusaviVersion}/${file}`;
|
const downloadUrl = `https://github.com/mtkennerly/ludusavi/releases/download/v0.25.0/${file}`;
|
||||||
|
|
||||||
console.log(`Downloading ${file}...`);
|
console.log(`Downloading ${file}...`);
|
||||||
|
|
||||||
@@ -34,18 +30,10 @@ const downloadLudusavi = async () => {
|
|||||||
console.log(`Downloaded ${file}, extracting...`);
|
console.log(`Downloaded ${file}, extracting...`);
|
||||||
|
|
||||||
const pwd = process.cwd();
|
const pwd = process.cwd();
|
||||||
|
|
||||||
const targetPath = path.join(pwd, "ludusavi");
|
const targetPath = path.join(pwd, "ludusavi");
|
||||||
|
|
||||||
await fs.promises.mkdir(targetPath, { recursive: true });
|
await exec(`npx extract-zip ${file} ${targetPath}`);
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
await exec(`npx extract-zip ${file} ${targetPath}`);
|
|
||||||
} else {
|
|
||||||
await tar.x({
|
|
||||||
file: file,
|
|
||||||
cwd: targetPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
fs.chmodSync(path.join(targetPath, "ludusavi"), 0o755);
|
fs.chmodSync(path.join(targetPath, "ludusavi"), 0o755);
|
||||||
@@ -58,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();
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -188,8 +188,8 @@
|
|||||||
"reset_achievements_error": "فشل في إعادة تعيين الإنجازات",
|
"reset_achievements_error": "فشل في إعادة تعيين الإنجازات",
|
||||||
"download_error_gofile_quota_exceeded": "لقد تجاوزت الحصة الشهرية لـ Gofile. يرجى الانتظار حتى إعادة تعيين الحصة.",
|
"download_error_gofile_quota_exceeded": "لقد تجاوزت الحصة الشهرية لـ Gofile. يرجى الانتظار حتى إعادة تعيين الحصة.",
|
||||||
"download_error_real_debrid_account_not_authorized": "حساب Real-Debrid الخاص بك غير مصرح له بإجراء تنزيلات جديدة. يرجى مراجعة إعدادات الحساب والمحاولة مرة أخرى.",
|
"download_error_real_debrid_account_not_authorized": "حساب Real-Debrid الخاص بك غير مصرح له بإجراء تنزيلات جديدة. يرجى مراجعة إعدادات الحساب والمحاولة مرة أخرى.",
|
||||||
"download_error_not_cached_on_real_debrid": "هذا التنزيل غير متوفر على Real-Debrid وجلب حالة التنزيل من Real-Debrid غير متاح حاليًا.",
|
"download_error_not_cached_in_real_debrid": "هذا التنزيل غير متوفر على Real-Debrid وجلب حالة التنزيل من Real-Debrid غير متاح حاليًا.",
|
||||||
"download_error_not_cached_on_torbox": "هذا التنزيل غير متوفر على TorBox وجلب حالة التنزيل من TorBox غير متاح حاليًا.",
|
"download_error_not_cached_in_torbox": "هذا التنزيل غير متوفر على Torbox وجلب حالة التنزيل من Torbox غير متاح حاليًا.",
|
||||||
"game_removed_from_favorites": "تمت إزالة اللعبة من المفضلة",
|
"game_removed_from_favorites": "تمت إزالة اللعبة من المفضلة",
|
||||||
"game_added_to_favorites": "تمت إضافة اللعبة إلى المفضلة"
|
"game_added_to_favorites": "تمت إضافة اللعبة إلى المفضلة"
|
||||||
},
|
},
|
||||||
@@ -330,7 +330,7 @@
|
|||||||
"delete_theme_description": "سيؤدي هذا إلى حذف السمة {{theme}}",
|
"delete_theme_description": "سيؤدي هذا إلى حذف السمة {{theme}}",
|
||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
"appearance": "المظهر",
|
"appearance": "المظهر",
|
||||||
"enable_torbox": "تفعيل TorBox",
|
"enable_torbox": "تفعيل Torbox",
|
||||||
"torbox_description": "TorBox هي خدمة seedbox متميزة تنافس أفضل الخوادم في السوق.",
|
"torbox_description": "TorBox هي خدمة seedbox متميزة تنافس أفضل الخوادم في السوق.",
|
||||||
"torbox_account_linked": "تم ربط حساب TorBox",
|
"torbox_account_linked": "تم ربط حساب TorBox",
|
||||||
"real_debrid_account_linked": "تم ربط حساب Real-Debrid",
|
"real_debrid_account_linked": "تم ربط حساب Real-Debrid",
|
||||||
|
|||||||
@@ -193,8 +193,8 @@
|
|||||||
"reset_achievements_error": "Nepodařilo se resetovat achievementy",
|
"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_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_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_on_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_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_on_torbox": "Toto stahování není dostupné na TorBox a získávání informací o stahování z TorBox 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_removed_from_favorites": "Hra odebrána z oblíbených",
|
||||||
"game_added_to_favorites": "Hra přidána do oblíbených",
|
"game_added_to_favorites": "Hra přidána do oblíbených",
|
||||||
"automatically_extract_downloaded_files": "Automaticky rozbalit stažené soubory"
|
"automatically_extract_downloaded_files": "Automaticky rozbalit stažené soubory"
|
||||||
|
|||||||
@@ -193,9 +193,8 @@
|
|||||||
"reset_achievements_error": "Failed to reset achievements",
|
"reset_achievements_error": "Failed to reset achievements",
|
||||||
"download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.",
|
"download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.",
|
||||||
"download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.",
|
"download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.",
|
||||||
"download_error_not_cached_on_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_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_on_torbox": "This download is not available on TorBox and polling download status from TorBox 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.",
|
||||||
"download_error_not_cached_on_hydra": "This download is not available on Nimbus.",
|
|
||||||
"game_removed_from_favorites": "Game removed from favorites",
|
"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"
|
"automatically_extract_downloaded_files": "Automatically extract downloaded files"
|
||||||
@@ -339,7 +338,7 @@
|
|||||||
"delete_theme_description": "This will delete the theme {{theme}}",
|
"delete_theme_description": "This will delete the theme {{theme}}",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"enable_torbox": "Enable TorBox",
|
"enable_torbox": "Enable Torbox",
|
||||||
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
|
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
|
||||||
"torbox_account_linked": "TorBox account linked",
|
"torbox_account_linked": "TorBox account linked",
|
||||||
"create_real_debrid_account": "Click here if you don't have a Real-Debrid account yet",
|
"create_real_debrid_account": "Click here if you don't have a Real-Debrid account yet",
|
||||||
|
|||||||
@@ -44,9 +44,6 @@
|
|||||||
"downloading_metadata": "Descargando metadatos de {{title}}…",
|
"downloading_metadata": "Descargando metadatos de {{title}}…",
|
||||||
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}",
|
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}",
|
||||||
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…",
|
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…",
|
||||||
"installation_complete": "Instalación completada",
|
|
||||||
"installation_complete_message": "Common redistributables instalados exitosamente",
|
|
||||||
"installing_common_redist": "{{log}}…",
|
|
||||||
"checking_files": "Verificando archivos de {{title}}… ({{percentage}} completado)"
|
"checking_files": "Verificando archivos de {{title}}… ({{percentage}} completado)"
|
||||||
},
|
},
|
||||||
"catalogue": {
|
"catalogue": {
|
||||||
@@ -62,8 +59,6 @@
|
|||||||
},
|
},
|
||||||
"game_details": {
|
"game_details": {
|
||||||
"open_download_options": "Ver opciones de descargas",
|
"open_download_options": "Ver opciones de descargas",
|
||||||
"automatically_extract_downloaded_files": "Extraer automáticamente archivos descargados",
|
|
||||||
"download_error_not_cached_on_hydra": "Esta descarga no está disponible en Nimbus.",
|
|
||||||
"download_options_zero": "No hay opciones de descargas disponibles",
|
"download_options_zero": "No hay opciones de descargas disponibles",
|
||||||
"download_options_one": "{{count}} opción de descarga",
|
"download_options_one": "{{count}} opción de descarga",
|
||||||
"download_options_other": "{{count}} opciones de descargas",
|
"download_options_other": "{{count}} opciones de descargas",
|
||||||
@@ -195,8 +190,8 @@
|
|||||||
"reset_achievements_error": "Se produjo un error al reiniciar los logros",
|
"reset_achievements_error": "Se produjo un error al reiniciar los logros",
|
||||||
"download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de Gofile. Por favor espera a que se reinicie la cuota.",
|
"download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de Gofile. Por favor espera a que se reinicie la cuota.",
|
||||||
"download_error_real_debrid_account_not_authorized": "Tu cuenta de Real-Debrid no está autorizada para nueva descargas. Por favor, revisa los ajustes de tu cuenta e intenta de nuevo.",
|
"download_error_real_debrid_account_not_authorized": "Tu cuenta de Real-Debrid no está autorizada para nueva descargas. Por favor, revisa los ajustes de tu cuenta e intenta de nuevo.",
|
||||||
"download_error_not_cached_on_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.",
|
"download_error_not_cached_in_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.",
|
||||||
"download_error_not_cached_on_torbox": "Esta descarga no está disponible en TorBox y el estado de descarga del sondeo aún no está disponible.",
|
"download_error_not_cached_in_torbox": "Esta descarga no está disponible en Torbox y el estado de descarga del sondeo aún no está disponible.",
|
||||||
"game_added_to_favorites": "Juego añadido a favoritos",
|
"game_added_to_favorites": "Juego añadido a favoritos",
|
||||||
"game_removed_from_favorites": "Juego removido de favoritos"
|
"game_removed_from_favorites": "Juego removido de favoritos"
|
||||||
},
|
},
|
||||||
@@ -235,19 +230,10 @@
|
|||||||
"seeding": "Seeding",
|
"seeding": "Seeding",
|
||||||
"stop_seeding": "Detener seeding",
|
"stop_seeding": "Detener seeding",
|
||||||
"resume_seeding": "Continuar seeding",
|
"resume_seeding": "Continuar seeding",
|
||||||
"extract": "Extraer archivos",
|
|
||||||
"extracting": "Extrayendo archivos…",
|
|
||||||
"options": "Gestionar"
|
"options": "Gestionar"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Ruta de descarga",
|
"downloads_path": "Ruta de descarga",
|
||||||
"common_redist": "Common redistributables",
|
|
||||||
"common_redist_description": "Las Common redistributables son requeridos para ejecutar algunos juegos. Es recomendado instalarlos para evitar problemas.",
|
|
||||||
"create_real_debrid_account": "Presiona acá si no tienes una cuenta de Real-Debrid aún",
|
|
||||||
"create_torbox_account": "Presiona acá si no tienes una cuenta de TorBox aún",
|
|
||||||
"install_common_redist": "Instalar",
|
|
||||||
"installing_common_redist": "Instalando…",
|
|
||||||
"show_download_speed_in_megabytes": "Mostrar velocidad de descargar en megabytes por segundo",
|
|
||||||
"change": "Cambiar",
|
"change": "Cambiar",
|
||||||
"notifications": "Notificaciones",
|
"notifications": "Notificaciones",
|
||||||
"enable_download_notifications": "Cuando se completa una descarga",
|
"enable_download_notifications": "Cuando se completa una descarga",
|
||||||
@@ -340,7 +326,7 @@
|
|||||||
"editor_tab_code": "Código",
|
"editor_tab_code": "Código",
|
||||||
"editor_tab_info": "Info",
|
"editor_tab_info": "Info",
|
||||||
"editor_tab_save": "Guardar",
|
"editor_tab_save": "Guardar",
|
||||||
"enable_torbox": "Habilitar TorBox",
|
"enable_torbox": "Habilitar Torbox",
|
||||||
"error_importing_theme": "Error al importar el tema",
|
"error_importing_theme": "Error al importar el tema",
|
||||||
"import_theme": "Importar tema",
|
"import_theme": "Importar tema",
|
||||||
"import_theme_description": "Vas a importar el tema {{theme}} desde la tienda de temas",
|
"import_theme_description": "Vas a importar el tema {{theme}} desde la tienda de temas",
|
||||||
@@ -360,8 +346,6 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Descarga completada",
|
"download_complete": "Descarga completada",
|
||||||
"extraction_complete": "Extracción completada",
|
|
||||||
"game_extracted": "{{title}} extraído exitosamente",
|
|
||||||
"game_ready_to_install": "{{title}} está listo para instalarse",
|
"game_ready_to_install": "{{title}} está listo para instalarse",
|
||||||
"repack_list_updated": "Lista de repacks actualizadas",
|
"repack_list_updated": "Lista de repacks actualizadas",
|
||||||
"repack_count_one": "{{count}} repack ha sido añadido",
|
"repack_count_one": "{{count}} repack ha sido añadido",
|
||||||
@@ -491,7 +475,6 @@
|
|||||||
},
|
},
|
||||||
"hydra_cloud": {
|
"hydra_cloud": {
|
||||||
"subscription_tour_title": "Suscripción Hydra Cloud",
|
"subscription_tour_title": "Suscripción Hydra Cloud",
|
||||||
"debrid_description": "Descargas hasta x4 más rápidas con Nimbus",
|
|
||||||
"subscribe_now": "Suscribirse ahora",
|
"subscribe_now": "Suscribirse ahora",
|
||||||
"cloud_saving": "Guardado en la nube",
|
"cloud_saving": "Guardado en la nube",
|
||||||
"cloud_achievements": "Guarda tus logros en la nube",
|
"cloud_achievements": "Guarda tus logros en la nube",
|
||||||
|
|||||||
@@ -182,9 +182,8 @@
|
|||||||
"no_write_permission": "Não é possível baixar nesse diretório. Clique aqui para saber mais.",
|
"no_write_permission": "Não é possível baixar nesse diretório. Clique aqui para saber mais.",
|
||||||
"download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde a cota resetar.",
|
"download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde a cota resetar.",
|
||||||
"download_error_real_debrid_account_not_authorized": "Sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique sua assinatura e tente novamente.",
|
"download_error_real_debrid_account_not_authorized": "Sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique sua assinatura e tente novamente.",
|
||||||
"download_error_not_cached_on_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_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_on_torbox": "Este download não está disponível no TorBox 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.",
|
||||||
"download_error_not_cached_on_hydra": "Este download não está disponível no Nimbus.",
|
|
||||||
"game_removed_from_favorites": "Jogo removido dos favoritos",
|
"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"
|
"automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados"
|
||||||
@@ -326,7 +325,7 @@
|
|||||||
"delete_theme_description": "Isso irá deletar o tema {{theme}}",
|
"delete_theme_description": "Isso irá deletar o tema {{theme}}",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"appearance": "Aparência",
|
"appearance": "Aparência",
|
||||||
"enable_torbox": "Habilitar TorBox",
|
"enable_torbox": "Habilitar Torbox",
|
||||||
"torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
|
"torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
|
||||||
"torbox_account_linked": "Conta do TorBox vinculada",
|
"torbox_account_linked": "Conta do TorBox vinculada",
|
||||||
"create_real_debrid_account": "Clique aqui se você ainda não tem uma conta do Real-Debrid",
|
"create_real_debrid_account": "Clique aqui se você ainda não tem uma conta do Real-Debrid",
|
||||||
|
|||||||
@@ -175,8 +175,8 @@
|
|||||||
"no_write_permission": "Não é possível descarregar neste diretório. Clique aqui para saber mais.",
|
"no_write_permission": "Não é possível descarregar neste diretório. Clique aqui para saber mais.",
|
||||||
"download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde o reset da cota.",
|
"download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde o reset da cota.",
|
||||||
"download_error_real_debrid_account_not_authorized": "A sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique a sua assinatura e tente novamente.",
|
"download_error_real_debrid_account_not_authorized": "A sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique a sua assinatura e tente novamente.",
|
||||||
"download_error_not_cached_on_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_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_on_torbox": "Este download não está disponível no TorBox 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_removed_from_favorites": "Jogo removido dos favoritos",
|
||||||
"game_added_to_favorites": "Jogo adicionado aos favoritos"
|
"game_added_to_favorites": "Jogo adicionado aos favoritos"
|
||||||
},
|
},
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
"delete_theme_description": "Isto irá apagar o tema {{theme}}",
|
"delete_theme_description": "Isto irá apagar o tema {{theme}}",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"appearance": "Aparência",
|
"appearance": "Aparência",
|
||||||
"enable_torbox": "Ativar TorBox",
|
"enable_torbox": "Ativar Torbox",
|
||||||
"torbox_description": "TorBox é um serviço de seedbox premium sendo um dos melhores servidores do mercado.",
|
"torbox_description": "TorBox é um serviço de seedbox premium sendo um dos melhores servidores do mercado.",
|
||||||
"torbox_account_linked": "Conta do TorBox associada",
|
"torbox_account_linked": "Conta do TorBox associada",
|
||||||
"real_debrid_account_linked": "Conta Real-Debrid associada",
|
"real_debrid_account_linked": "Conta Real-Debrid associada",
|
||||||
|
|||||||
@@ -193,8 +193,8 @@
|
|||||||
"reset_achievements_error": "Не удалось сбросить достижения",
|
"reset_achievements_error": "Не удалось сбросить достижения",
|
||||||
"download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.",
|
"download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.",
|
||||||
"download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.",
|
"download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.",
|
||||||
"download_error_not_cached_on_real_debrid": "Эта загрузка недоступна на Real-Debrid, и получение статуса загрузки с Real-Debrid пока недоступно.",
|
"download_error_not_cached_in_real_debrid": "Эта загрузка недоступна на Real-Debrid, и получение статуса загрузки с Real-Debrid пока недоступно.",
|
||||||
"download_error_not_cached_on_torbox": "Эта загрузка недоступна на TorBox, и получить статус загрузки с TorBox пока невозможно.",
|
"download_error_not_cached_in_torbox": "Эта загрузка недоступна на Torbox, и получить статус загрузки с Torbox пока невозможно.",
|
||||||
"game_added_to_favorites": "Игра добавлена в избранное",
|
"game_added_to_favorites": "Игра добавлена в избранное",
|
||||||
"game_removed_from_favorites": "Игра удалена из избранного",
|
"game_removed_from_favorites": "Игра удалена из избранного",
|
||||||
"automatically_extract_downloaded_files": "Автоматическая распаковка загруженных файлов"
|
"automatically_extract_downloaded_files": "Автоматическая распаковка загруженных файлов"
|
||||||
@@ -338,7 +338,7 @@
|
|||||||
"delete_theme_description": "Это приведет к удалению темы {{theme}}",
|
"delete_theme_description": "Это приведет к удалению темы {{theme}}",
|
||||||
"cancel": "Отменить",
|
"cancel": "Отменить",
|
||||||
"appearance": "Внешний вид",
|
"appearance": "Внешний вид",
|
||||||
"enable_torbox": "Включить TorBox",
|
"enable_torbox": "Включить Torbox",
|
||||||
"torbox_description": "TorBox - это ваш премиум-сервис, конкурирующий даже с лучшими серверами на рынке.",
|
"torbox_description": "TorBox - это ваш премиум-сервис, конкурирующий даже с лучшими серверами на рынке.",
|
||||||
"torbox_account_linked": "Аккаунт TorBox привязан",
|
"torbox_account_linked": "Аккаунт TorBox привязан",
|
||||||
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
|
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
|
||||||
|
|||||||
@@ -193,8 +193,8 @@
|
|||||||
"reset_achievements_error": "Başarımlar sıfırlanamadı",
|
"reset_achievements_error": "Başarımlar sıfırlanamadı",
|
||||||
"download_error_gofile_quota_exceeded": "Gofile aylık kotanızı doldurdunuz. Kotanın yenilenmesini bekleyin.",
|
"download_error_gofile_quota_exceeded": "Gofile aylık kotanızı doldurdunuz. Kotanın yenilenmesini bekleyin.",
|
||||||
"download_error_real_debrid_account_not_authorized": "Real-Debrid hesabınız yeni indirme işlemleri yapmak için yetkilendirilmemiş. Lütfen hesap ayarlarınızı kontrol edip tekrar deneyin.",
|
"download_error_real_debrid_account_not_authorized": "Real-Debrid hesabınız yeni indirme işlemleri yapmak için yetkilendirilmemiş. Lütfen hesap ayarlarınızı kontrol edip tekrar deneyin.",
|
||||||
"download_error_not_cached_on_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_real_debrid": "Bu indirme Real-Debrid üzerinde mevcut değil ve Real-Debrid'den indirme durumu henüz sorgulanamıyor.",
|
||||||
"download_error_not_cached_on_torbox": "Bu indirme TorBox'ta mevcut değil ve TorBox'tan 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_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"
|
"automatically_extract_downloaded_files": "Yüklenmiş dosyaları otomatik olarak çıkart"
|
||||||
@@ -338,7 +338,7 @@
|
|||||||
"delete_theme_description": "Bu {{theme}} temasını silecektir",
|
"delete_theme_description": "Bu {{theme}} temasını silecektir",
|
||||||
"cancel": "İptal",
|
"cancel": "İptal",
|
||||||
"appearance": "Görünüm",
|
"appearance": "Görünüm",
|
||||||
"enable_torbox": "TorBox'u etkinleştir",
|
"enable_torbox": "Torbox'u etkinleştir",
|
||||||
"torbox_description": "TorBox, piyasadaki en iyi sunucularla bile rekabet edebilen premium seedbox hizmetinizdir.",
|
"torbox_description": "TorBox, piyasadaki en iyi sunucularla bile rekabet edebilen premium seedbox hizmetinizdir.",
|
||||||
"torbox_account_linked": "TorBox hesabı bağlandı",
|
"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_real_debrid_account": "Henüz bir Real-Debrid hesabınız yoksa buraya tıklayın",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { CloudSync } from "@main/services";
|
import { CloudSync } from "@main/services";
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
|
import i18next, { t } from "i18next";
|
||||||
|
import { formatDate } from "date-fns";
|
||||||
|
|
||||||
const uploadSaveGame = async (
|
const uploadSaveGame = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
@@ -8,11 +10,16 @@ const uploadSaveGame = async (
|
|||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
downloadOptionTitle: string | null
|
downloadOptionTitle: string | null
|
||||||
) => {
|
) => {
|
||||||
|
const { language } = i18next;
|
||||||
|
|
||||||
return CloudSync.uploadSaveGame(
|
return CloudSync.uploadSaveGame(
|
||||||
objectId,
|
objectId,
|
||||||
shop,
|
shop,
|
||||||
downloadOptionTitle,
|
downloadOptionTitle,
|
||||||
CloudSync.getBackupLabel(false)
|
t("backup_from", {
|
||||||
|
ns: "game_details",
|
||||||
|
date: formatDate(new Date(), language),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import updater from "electron-updater";
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import url from "node:url";
|
import url from "node:url";
|
||||||
|
import kill from "kill-port";
|
||||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
||||||
import { logger, WindowManager } from "@main/services";
|
import { logger, WindowManager } from "@main/services";
|
||||||
import resources from "@locales";
|
import resources from "@locales";
|
||||||
@@ -57,7 +58,7 @@ app.whenReady().then(async () => {
|
|||||||
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
|
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
await loadState();
|
await kill(PythonRPC.RPC_PORT).finally(() => loadState());
|
||||||
|
|
||||||
const language = await db.get<string, string>(levelKeys.language, {
|
const language = await db.get<string, string>(levelKeys.language, {
|
||||||
valueEncoding: "utf-8",
|
valueEncoding: "utf-8",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Aria2, DownloadManager, Ludusavi, startMainLoop } from "./services";
|
import { DownloadManager, Ludusavi, startMainLoop } from "./services";
|
||||||
import { RealDebridClient } from "./services/download/real-debrid";
|
import { RealDebridClient } from "./services/download/real-debrid";
|
||||||
import { HydraApi } from "./services/hydra-api";
|
import { HydraApi } from "./services/hydra-api";
|
||||||
import { uploadGamesBatch } from "./services/library-sync";
|
import { uploadGamesBatch } from "./services/library-sync";
|
||||||
@@ -20,8 +20,6 @@ export const loadState = async () => {
|
|||||||
|
|
||||||
await import("./events");
|
await import("./events");
|
||||||
|
|
||||||
Aria2.spawn();
|
|
||||||
|
|
||||||
if (userPreferences?.realDebridApiToken) {
|
if (userPreferences?.realDebridApiToken) {
|
||||||
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
||||||
}
|
}
|
||||||
@@ -32,7 +30,7 @@ export const loadState = async () => {
|
|||||||
|
|
||||||
Ludusavi.addManifestToLudusaviConfig();
|
Ludusavi.addManifestToLudusaviConfig();
|
||||||
|
|
||||||
await HydraApi.setupApi().then(() => {
|
HydraApi.setupApi().then(() => {
|
||||||
uploadGamesBatch();
|
uploadGamesBatch();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import cp from "node:child_process";
|
|
||||||
import { app } from "electron";
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,28 +13,9 @@ import { logger } from "./logger";
|
|||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Ludusavi } from "./ludusavi";
|
import { Ludusavi } from "./ludusavi";
|
||||||
import { formatDate, SubscriptionRequiredError } from "@shared";
|
import { SubscriptionRequiredError } from "@shared";
|
||||||
import i18next, { t } from "i18next";
|
|
||||||
|
|
||||||
export class CloudSync {
|
export class CloudSync {
|
||||||
public static getBackupLabel(automatic: boolean) {
|
|
||||||
const language = i18next.language;
|
|
||||||
|
|
||||||
const date = formatDate(new Date(), language);
|
|
||||||
|
|
||||||
if (automatic) {
|
|
||||||
return t("automatic_backup_from", {
|
|
||||||
ns: "game_details",
|
|
||||||
date,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return t("backup_from", {
|
|
||||||
ns: "game_details",
|
|
||||||
date,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async bundleBackup(
|
private static async bundleBackup(
|
||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
objectId: string,
|
objectId: string,
|
||||||
@@ -44,11 +25,7 @@ export class CloudSync {
|
|||||||
|
|
||||||
// Remove existing backup
|
// Remove existing backup
|
||||||
if (fs.existsSync(backupPath)) {
|
if (fs.existsSync(backupPath)) {
|
||||||
try {
|
fs.rmSync(backupPath, { recursive: true });
|
||||||
await fs.promises.rm(backupPath, { recursive: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to remove backup path", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix);
|
await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix);
|
||||||
@@ -124,10 +101,11 @@ export class CloudSync {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
fs.rm(bundleLocation, (err) => {
|
||||||
await fs.promises.unlink(bundleLocation);
|
if (err) {
|
||||||
} catch (error) {
|
logger.error("Failed to remove tar file", err);
|
||||||
logger.error("Failed to remove tar file", error);
|
throw err;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ export class DownloadManager {
|
|||||||
case Downloader.RealDebrid: {
|
case Downloader.RealDebrid: {
|
||||||
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
||||||
|
|
||||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedInRealDebrid);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: "start",
|
action: "start",
|
||||||
@@ -395,7 +395,7 @@ export class DownloadManager {
|
|||||||
download.uri
|
download.uri
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedInHydra);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: "start",
|
action: "start",
|
||||||
|
|||||||
@@ -11,4 +11,3 @@ export * from "./cloud-sync";
|
|||||||
export * from "./7zip";
|
export * from "./7zip";
|
||||||
export * from "./game-files-manager";
|
export * from "./game-files-manager";
|
||||||
export * from "./common-redist-manager";
|
export * from "./common-redist-manager";
|
||||||
export * from "./aria2";
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import axios from "axios";
|
|||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { ProcessPayload } from "./download/types";
|
import { ProcessPayload } from "./download/types";
|
||||||
import { gamesSublevel, levelKeys } from "@main/level";
|
import { gamesSublevel, levelKeys } from "@main/level";
|
||||||
|
import i18next, { t } from "i18next";
|
||||||
import { CloudSync } from "./cloud-sync";
|
import { CloudSync } from "./cloud-sync";
|
||||||
|
import { formatDate } from "date-fns";
|
||||||
|
|
||||||
const commands = {
|
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 ""}'`,
|
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 ""}'`,
|
||||||
@@ -227,12 +229,17 @@ function onOpenGame(game: Game) {
|
|||||||
if (game.remoteId) {
|
if (game.remoteId) {
|
||||||
updateGamePlaytime(game, 0, new Date()).catch(() => {});
|
updateGamePlaytime(game, 0, new Date()).catch(() => {});
|
||||||
|
|
||||||
|
const { language } = i18next;
|
||||||
|
|
||||||
if (game.automaticCloudSync) {
|
if (game.automaticCloudSync) {
|
||||||
CloudSync.uploadSaveGame(
|
CloudSync.uploadSaveGame(
|
||||||
game.objectId,
|
game.objectId,
|
||||||
game.shop,
|
game.shop,
|
||||||
null,
|
null,
|
||||||
CloudSync.getBackupLabel(true)
|
t("automatic_backup_from", {
|
||||||
|
ns: "game_details",
|
||||||
|
date: formatDate(new Date(), language),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -291,6 +298,8 @@ const onCloseGame = (game: Game) => {
|
|||||||
)!;
|
)!;
|
||||||
gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId));
|
gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId));
|
||||||
|
|
||||||
|
const { language } = i18next;
|
||||||
|
|
||||||
if (game.remoteId) {
|
if (game.remoteId) {
|
||||||
updateGamePlaytime(
|
updateGamePlaytime(
|
||||||
game,
|
game,
|
||||||
@@ -303,7 +312,10 @@ const onCloseGame = (game: Game) => {
|
|||||||
game.objectId,
|
game.objectId,
|
||||||
game.shop,
|
game.shop,
|
||||||
null,
|
null,
|
||||||
CloudSync.getBackupLabel(true)
|
t("automatic_backup_from", {
|
||||||
|
ns: "game_details",
|
||||||
|
date: formatDate(new Date(), language),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
BIN
src/renderer/src/assets/icons/torbox.webp
Normal file
BIN
src/renderer/src/assets/icons/torbox.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import { formatDate, getDateLocale } from "@shared";
|
import { formatDate, getDateLocale } from "@shared";
|
||||||
import { format, formatDistance, subMilliseconds } from "date-fns";
|
import { format, formatDistance, subMilliseconds } from "date-fns";
|
||||||
import type { FormatDistanceOptions } from "date-fns";
|
import type { FormatDistanceOptions } from "date-fns";
|
||||||
|
import { enUS } from "date-fns/locale";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function useDate() {
|
export function useDate() {
|
||||||
@@ -40,10 +41,10 @@ export function useDate() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
formatDateTime: (date: number | Date | string): string => {
|
formatDateTime: (date: number | Date | string): string => {
|
||||||
|
const locale = getDateLocale(language);
|
||||||
return format(
|
return format(
|
||||||
date,
|
date,
|
||||||
language == "en" ? "MM-dd-yyyy - hh:mm a" : "dd/MM/yyyy HH:mm",
|
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy HH:mm"
|
||||||
{ locale: getDateLocale(language) }
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react";
|
|||||||
|
|
||||||
enum Feature {
|
enum Feature {
|
||||||
CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
|
CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
|
||||||
TorBox = "TORBOX",
|
Torbox = "TORBOX",
|
||||||
Nimbus = "NIMBUS",
|
Nimbus = "NIMBUS",
|
||||||
NimbusPreview = "NIMBUS_PREVIEW",
|
NimbusPreview = "NIMBUS_PREVIEW",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,11 +73,8 @@
|
|||||||
min-height: 140px;
|
min-height: 140px;
|
||||||
max-height: 140px;
|
max-height: 140px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&--hydra {
|
|
||||||
box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cover {
|
&__cover {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
@@ -148,14 +145,4 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
min-height: unset;
|
min-height: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__hydra-gradient {
|
|
||||||
background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%);
|
|
||||||
box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15);
|
|
||||||
width: 100%;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
height: 2px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import cn from "classnames";
|
|
||||||
|
|
||||||
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
|
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
|
||||||
|
|
||||||
@@ -33,6 +32,8 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "@primer/octicons-react";
|
} from "@primer/octicons-react";
|
||||||
|
|
||||||
|
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
|
||||||
|
|
||||||
export interface DownloadGroupProps {
|
export interface DownloadGroupProps {
|
||||||
library: LibraryGame[];
|
library: LibraryGame[];
|
||||||
title: string;
|
title: string;
|
||||||
@@ -309,13 +310,7 @@ export function DownloadGroup({
|
|||||||
<ul className="download-group__downloads">
|
<ul className="download-group__downloads">
|
||||||
{library.map((game) => {
|
{library.map((game) => {
|
||||||
return (
|
return (
|
||||||
<li
|
<li key={game.id} className="download-group__item">
|
||||||
key={game.id}
|
|
||||||
className={cn("download-group__item", {
|
|
||||||
"download-group__item--hydra":
|
|
||||||
game.download?.downloader === Downloader.Hydra,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="download-group__cover">
|
<div className="download-group__cover">
|
||||||
<div className="download-group__cover-backdrop">
|
<div className="download-group__cover-backdrop">
|
||||||
<img
|
<img
|
||||||
@@ -325,7 +320,20 @@ export function DownloadGroup({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="download-group__cover-content">
|
<div className="download-group__cover-content">
|
||||||
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
|
{game.download?.downloader === Downloader.TorBox ? (
|
||||||
|
<Badge>
|
||||||
|
<img
|
||||||
|
src={torBoxLogo}
|
||||||
|
alt="TorBox"
|
||||||
|
style={{ width: 13 }}
|
||||||
|
/>
|
||||||
|
<span>TorBox</span>
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge>
|
||||||
|
{DOWNLOADER_NAME[game.download!.downloader]}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,7 +376,18 @@ export function DownloadGroup({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{game.download?.downloader === Downloader.Hydra && (
|
{game.download?.downloader === Downloader.Hydra && (
|
||||||
<div className="download-group__hydra-gradient" />
|
<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>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,14 +87,14 @@ export function DownloadSettingsModal({
|
|||||||
return Downloader.Hydra;
|
return Downloader.Hydra;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (availableDownloaders.includes(Downloader.RealDebrid)) {
|
|
||||||
return Downloader.RealDebrid;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableDownloaders.includes(Downloader.TorBox)) {
|
if (availableDownloaders.includes(Downloader.TorBox)) {
|
||||||
return Downloader.TorBox;
|
return Downloader.TorBox;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (availableDownloaders.includes(Downloader.RealDebrid)) {
|
||||||
|
return Downloader.RealDebrid;
|
||||||
|
}
|
||||||
|
|
||||||
return availableDownloaders[0];
|
return availableDownloaders[0];
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const TORBOX_URL = torBoxReferralCode
|
|||||||
: "https://torbox.app";
|
: "https://torbox.app";
|
||||||
const TORBOX_API_TOKEN_URL = "https://torbox.app/settings";
|
const TORBOX_API_TOKEN_URL = "https://torbox.app/settings";
|
||||||
|
|
||||||
export function SettingsTorBox() {
|
export function SettingsTorbox() {
|
||||||
const userPreferences = useAppSelector(
|
const userPreferences = useAppSelector(
|
||||||
(state) => state.userPreferences.value
|
(state) => state.userPreferences.value
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||||
import { SettingsGeneral } from "./settings-general";
|
import { SettingsGeneral } from "./settings-general";
|
||||||
import { SettingsBehavior } from "./settings-behavior";
|
import { SettingsBehavior } from "./settings-behavior";
|
||||||
|
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
|
||||||
import { SettingsDownloadSources } from "./settings-download-sources";
|
import { SettingsDownloadSources } from "./settings-download-sources";
|
||||||
import {
|
import {
|
||||||
SettingsContextConsumer,
|
SettingsContextConsumer,
|
||||||
@@ -13,7 +14,7 @@ import { useFeature, useUserDetails } from "@renderer/hooks";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import "./settings.scss";
|
import "./settings.scss";
|
||||||
import { SettingsAppearance } from "./aparence/settings-appearance";
|
import { SettingsAppearance } from "./aparence/settings-appearance";
|
||||||
import { SettingsTorBox } from "./settings-torbox";
|
import { SettingsTorbox } from "./settings-torbox";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@@ -22,7 +23,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
const { isFeatureEnabled, Feature } = useFeature();
|
const { isFeatureEnabled, Feature } = useFeature();
|
||||||
|
|
||||||
const isTorBoxEnabled = isFeatureEnabled(Feature.TorBox);
|
const isTorboxEnabled = isFeatureEnabled(Feature.Torbox);
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const categories = [
|
const categories = [
|
||||||
@@ -33,10 +34,19 @@ export default function Settings() {
|
|||||||
tabLabel: t("appearance"),
|
tabLabel: t("appearance"),
|
||||||
contentTitle: t("appearance"),
|
contentTitle: t("appearance"),
|
||||||
},
|
},
|
||||||
...(isTorBoxEnabled
|
...(isTorboxEnabled
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
tabLabel: "TorBox",
|
tabLabel: (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={torBoxLogo}
|
||||||
|
alt="TorBox"
|
||||||
|
style={{ width: 13, height: 13 }}
|
||||||
|
/>{" "}
|
||||||
|
Torbox
|
||||||
|
</>
|
||||||
|
),
|
||||||
contentTitle: "TorBox",
|
contentTitle: "TorBox",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -50,7 +60,7 @@ export default function Settings() {
|
|||||||
{ tabLabel: t("account"), contentTitle: t("account") },
|
{ tabLabel: t("account"), contentTitle: t("account") },
|
||||||
];
|
];
|
||||||
return categories;
|
return categories;
|
||||||
}, [userDetails, t, isTorBoxEnabled]);
|
}, [userDetails, t, isTorboxEnabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContextProvider>
|
<SettingsContextProvider>
|
||||||
@@ -74,7 +84,7 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentCategoryIndex === 4) {
|
if (currentCategoryIndex === 4) {
|
||||||
return <SettingsTorBox />;
|
return <SettingsTorbox />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentCategoryIndex === 5) {
|
if (currentCategoryIndex === 5) {
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ export enum AuthPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum DownloadError {
|
export enum DownloadError {
|
||||||
NotCachedOnRealDebrid = "download_error_not_cached_on_real_debrid",
|
NotCachedInRealDebrid = "download_error_not_cached_in_real_debrid",
|
||||||
NotCachedOnTorBox = "download_error_not_cached_on_torbox",
|
NotCachedInTorbox = "download_error_not_cached_in_torbox",
|
||||||
GofileQuotaExceeded = "download_error_gofile_quota_exceeded",
|
GofileQuotaExceeded = "download_error_gofile_quota_exceeded",
|
||||||
RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized",
|
RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized",
|
||||||
NotCachedOnHydra = "download_error_not_cached_on_hydra",
|
NotCachedInHydra = "download_error_not_cached_in_hydra",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FILE_EXTENSIONS_TO_EXTRACT = [".rar", ".zip", ".7z"];
|
export const FILE_EXTENSIONS_TO_EXTRACT = [".rar", ".zip", ".7z"];
|
||||||
|
|||||||
@@ -173,5 +173,7 @@ export const formatDate = (
|
|||||||
language: string
|
language: string
|
||||||
): string => {
|
): string => {
|
||||||
if (isNaN(new Date(date).getDate())) return "N/A";
|
if (isNaN(new Date(date).getDate())) return "N/A";
|
||||||
return format(date, language == "en" ? "MM-dd-yyyy" : "dd/MM/yyyy");
|
|
||||||
|
const locale = getDateLocale(language);
|
||||||
|
return format(date, locale == enUS ? "MM/dd/yyyy" : "dd/MM/yyyy");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface DownloadProgress {
|
|||||||
download: Download;
|
download: Download;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TorBox */
|
/* Torbox */
|
||||||
export interface TorBoxUser {
|
export interface TorBoxUser {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
30
yarn.lock
30
yarn.lock
@@ -1460,9 +1460,9 @@
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
global-agent "^3.0.0"
|
global-agent "^3.0.0"
|
||||||
|
|
||||||
"@electron/node-gyp@https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2":
|
"@electron/node-gyp@git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2":
|
||||||
version "10.2.0-electron.1"
|
version "10.2.0-electron.1"
|
||||||
resolved "https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2"
|
resolved "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2"
|
||||||
dependencies:
|
dependencies:
|
||||||
env-paths "^2.2.0"
|
env-paths "^2.2.0"
|
||||||
exponential-backoff "^3.1.1"
|
exponential-backoff "^3.1.1"
|
||||||
@@ -4501,10 +4501,10 @@ crc@^3.8.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
buffer "^5.1.0"
|
buffer "^5.1.0"
|
||||||
|
|
||||||
create-desktop-shortcuts@^1.11.1:
|
create-desktop-shortcuts@^1.11.0:
|
||||||
version "1.11.1"
|
version "1.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/create-desktop-shortcuts/-/create-desktop-shortcuts-1.11.1.tgz#59f9dced7931bda551c0717791a909419472c809"
|
resolved "https://registry.yarnpkg.com/create-desktop-shortcuts/-/create-desktop-shortcuts-1.11.0.tgz#8eed89329e9bce70dece46d02a80573fe1f2536d"
|
||||||
integrity sha512-EiHvxrMXXEp4xDD3Nvu1FKLueL9+aBWFYtuTlstYZLIg9H45SoYciizteNB+hvQAls3cRSpoXCM7c4q+lcJpyw==
|
integrity sha512-nmVtPVqNyMuAyMpDnd7l++hb2laqCWZXnHQaFhqGT1YEi2Ve3unu6QyuyIpGxAwIscNHcG1Ehnl+lFw6ygB2nQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
which "2.0.2"
|
which "2.0.2"
|
||||||
|
|
||||||
@@ -5814,6 +5814,11 @@ get-symbol-description@^1.1.0:
|
|||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
get-intrinsic "^1.2.6"
|
get-intrinsic "^1.2.6"
|
||||||
|
|
||||||
|
get-them-args@1.3.2:
|
||||||
|
version "1.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/get-them-args/-/get-them-args-1.3.2.tgz#74a20ba8a4abece5ae199ad03f2bcc68fdfc9ba5"
|
||||||
|
integrity sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw==
|
||||||
|
|
||||||
git-raw-commits@^4.0.0:
|
git-raw-commits@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/git-raw-commits/-/git-raw-commits-4.0.0.tgz#b212fd2bff9726d27c1283a1157e829490593285"
|
resolved "https://registry.yarnpkg.com/git-raw-commits/-/git-raw-commits-4.0.0.tgz#b212fd2bff9726d27c1283a1157e829490593285"
|
||||||
@@ -6821,6 +6826,14 @@ keyv@^4.0.0, keyv@^4.5.3:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer "3.0.1"
|
json-buffer "3.0.1"
|
||||||
|
|
||||||
|
kill-port@^2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/kill-port/-/kill-port-2.0.1.tgz#e5e18e2706b13d54320938be42cb7d40609b15cf"
|
||||||
|
integrity sha512-e0SVOV5jFo0mx8r7bS29maVWp17qGqLBZ5ricNSajON6//kmb7qqqNnml4twNE8Dtj97UQD+gNFOaipS/q1zzQ==
|
||||||
|
dependencies:
|
||||||
|
get-them-args "1.3.2"
|
||||||
|
shell-exec "1.0.2"
|
||||||
|
|
||||||
language-subtag-registry@^0.3.20:
|
language-subtag-registry@^0.3.20:
|
||||||
version "0.3.23"
|
version "0.3.23"
|
||||||
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7"
|
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7"
|
||||||
@@ -8446,6 +8459,11 @@ shebang-regex@^3.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
|
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
|
||||||
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
||||||
|
|
||||||
|
shell-exec@1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/shell-exec/-/shell-exec-1.0.2.tgz#2e9361b0fde1d73f476c4b6671fa17785f696756"
|
||||||
|
integrity sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==
|
||||||
|
|
||||||
side-channel-list@^1.0.0:
|
side-channel-list@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
|
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
|
||||||
|
|||||||
Reference in New Issue
Block a user