mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-15 08:23:02 -03:00
chore: removing rust rpc
This commit is contained in:
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -25,23 +25,13 @@ jobs:
|
|||||||
node-version: 20.18.0
|
node-version: 20.18.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
run: yarn --frozen-lockfile
|
||||||
|
|
||||||
- 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
|
run: yarn --frozen-lockfile
|
||||||
|
|
||||||
- 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
|
||||||
|
|||||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -26,23 +26,13 @@ jobs:
|
|||||||
node-version: 20.18.0
|
node-version: 20.18.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
run: yarn --frozen-lockfile
|
||||||
|
|
||||||
- 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
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,5 +16,4 @@ hydra-python-rpc/
|
|||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
|
|
||||||
|
aria2/
|
||||||
target/
|
|
||||||
|
|||||||
0
binaries/7zz
Normal file → Executable file
0
binaries/7zz
Normal file → Executable file
0
binaries/7zzs
Normal file → Executable file
0
binaries/7zzs
Normal file → Executable file
Binary file not shown.
@@ -3,6 +3,7 @@ productName: Hydra
|
|||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
extraResources:
|
extraResources:
|
||||||
|
- aria2
|
||||||
- ludusavi
|
- ludusavi
|
||||||
- hydra-python-rpc
|
- hydra-python-rpc
|
||||||
- seeds
|
- seeds
|
||||||
@@ -22,7 +23,6 @@ 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,7 +40,6 @@ 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.
|
||||||
@@ -52,7 +51,6 @@ 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.2",
|
"version": "3.4.3",
|
||||||
"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": "cargo build --manifest-path=rust_rpc/Cargo.toml && electron-vite dev",
|
"dev": "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",
|
||||||
|
|||||||
@@ -1,94 +1,48 @@
|
|||||||
import os
|
import aria2p
|
||||||
import subprocess
|
|
||||||
import json
|
|
||||||
|
|
||||||
class HttpDownloader:
|
class HttpDownloader:
|
||||||
def __init__(self, hydra_httpdl_bin: str):
|
def __init__(self):
|
||||||
self.hydra_exe = hydra_httpdl_bin
|
self.download = None
|
||||||
self.process = None
|
self.aria2 = aria2p.API(
|
||||||
self.last_status = None
|
aria2p.Client(
|
||||||
|
host="http://localhost",
|
||||||
def start_download(self, url: str, save_path: str, header: str = None, allow_multiple_connections: bool = False, connections_limit: int = 1):
|
port=6800,
|
||||||
cmd = [self.hydra_exe]
|
secret=""
|
||||||
|
)
|
||||||
cmd.append(url)
|
|
||||||
|
|
||||||
cmd.extend([
|
|
||||||
"--chunk-size", "10",
|
|
||||||
"--buffer-size", "16",
|
|
||||||
"--force-download",
|
|
||||||
"--log",
|
|
||||||
"--silent"
|
|
||||||
])
|
|
||||||
|
|
||||||
if header:
|
|
||||||
cmd.extend(["--header", header])
|
|
||||||
|
|
||||||
if allow_multiple_connections:
|
|
||||||
cmd.extend(["--connections", str(connections_limit)])
|
|
||||||
else:
|
|
||||||
cmd.extend(["--connections", "1"])
|
|
||||||
|
|
||||||
print(f"running hydra-httpdl: {' '.join(cmd)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
cwd=save_path,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
universal_newlines=True
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
print(f"error running hydra-httpdl: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
def start_download(self, url: str, save_path: str, header: str, out: str = None):
|
||||||
def get_download_status(self):
|
if self.download:
|
||||||
|
self.aria2.resume([self.download])
|
||||||
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:
|
else:
|
||||||
return None
|
downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out})
|
||||||
|
|
||||||
response = {
|
self.download = downloads[0]
|
||||||
"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):
|
def pause_download(self):
|
||||||
self.stop_download()
|
if self.download:
|
||||||
|
self.aria2.pause([self.download])
|
||||||
|
|
||||||
def cancel_download(self):
|
def cancel_download(self):
|
||||||
self.stop_download()
|
if self.download:
|
||||||
|
self.aria2.remove([self.download])
|
||||||
|
self.download = None
|
||||||
|
|
||||||
|
def get_download_status(self):
|
||||||
|
if self.download == None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
download = self.aria2.get_download(self.download.gid)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
'folderName': download.name,
|
||||||
|
'fileSize': download.total_length,
|
||||||
|
'progress': download.completed_length / download.total_length if download.total_length else 0,
|
||||||
|
'downloadSpeed': download.download_speed,
|
||||||
|
'numPeers': 0,
|
||||||
|
'numSeeds': 0,
|
||||||
|
'status': download.status,
|
||||||
|
'bytesDownloaded': download.completed_length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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
|
||||||
@@ -33,10 +32,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(hydra_httpdl_bin)
|
http_downloader = HttpDownloader()
|
||||||
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('allow_multiple_connections', False), initial_download.get('connections_limit', 24))
|
http_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get('out'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error starting http download", e)
|
print("Error starting http download", e)
|
||||||
|
|
||||||
@@ -148,11 +147,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('allow_multiple_connections', False), data.get('connections_limit', 24))
|
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
||||||
else:
|
else:
|
||||||
http_downloader = HttpDownloader(hydra_httpdl_bin)
|
http_downloader = HttpDownloader()
|
||||||
downloads[game_id] = http_downloader
|
downloads[game_id] = http_downloader
|
||||||
http_downloader.start_download(url, data['save_path'], data.get('header'), data.get('allow_multiple_connections', False), data.get('connections_limit', 24))
|
http_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
||||||
|
|
||||||
downloading_game_id = game_id
|
downloading_game_id = game_id
|
||||||
|
|
||||||
@@ -183,4 +182,3 @@ 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,3 +5,4 @@ pywin32; sys_platform == 'win32'
|
|||||||
psutil
|
psutil
|
||||||
Pillow
|
Pillow
|
||||||
flask
|
flask
|
||||||
|
aria2p
|
||||||
|
|||||||
2040
rust_rpc/Cargo.lock
generated
2040
rust_rpc/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,966 +0,0 @@
|
|||||||
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.inc(downloaded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 temp_path = format!("{}.tmp", self.file_path);
|
|
||||||
let source = File::open(&self.file_path)?;
|
|
||||||
let dest = File::create(&temp_path)?;
|
|
||||||
|
|
||||||
let mut reader = BufReader::with_capacity(FINALIZE_BUFFER_SIZE, source);
|
|
||||||
let mut writer = BufWriter::with_capacity(FINALIZE_BUFFER_SIZE, dest);
|
|
||||||
|
|
||||||
reader.seek(SeekFrom::Start(HEADER_SIZE as u64))?;
|
|
||||||
|
|
||||||
std::io::copy(&mut reader, &mut writer)?;
|
|
||||||
writer.flush()?;
|
|
||||||
drop(writer);
|
|
||||||
|
|
||||||
match std::fs::rename(&temp_path, &self.file_path) {
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(_) => {
|
|
||||||
let _ = std::fs::remove_file(&self.file_path);
|
|
||||||
std::fs::rename(&temp_path, &self.file_path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
let args = CliArgs::parse();
|
|
||||||
|
|
||||||
let config = DownloadConfig {
|
|
||||||
url: args.url.clone(),
|
|
||||||
output_path: args.output,
|
|
||||||
num_connections: args.connections,
|
|
||||||
chunk_size: args.chunk_size * 1024 * 1024,
|
|
||||||
buffer_size: args.buffer_size * 1024 * 1024,
|
|
||||||
verbose: args.verbose,
|
|
||||||
silent: args.silent,
|
|
||||||
log: args.log,
|
|
||||||
force_new: args.force_new,
|
|
||||||
resume_only: args.resume_only,
|
|
||||||
headers: args.header,
|
|
||||||
force_download: args.force_download,
|
|
||||||
};
|
|
||||||
|
|
||||||
if config.force_new && config.resume_only {
|
|
||||||
eprintln!("Error: --force-new and --resume-only options cannot be used together");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let downloader = Downloader {
|
|
||||||
client: Client::new(),
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
|
|
||||||
if downloader.config.should_log() {
|
|
||||||
println!(
|
|
||||||
"Starting download with {} connections, chunk size: {}MB, buffer: {}MB",
|
|
||||||
downloader.config.num_connections, args.chunk_size, args.buffer_size
|
|
||||||
);
|
|
||||||
println!("URL: {}", args.url);
|
|
||||||
|
|
||||||
if downloader.config.force_new {
|
|
||||||
println!("Forcing new download, ignoring existing files");
|
|
||||||
} else if downloader.config.resume_only {
|
|
||||||
println!("Only resuming existing download");
|
|
||||||
} else {
|
|
||||||
println!("Resuming download if possible");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloader.download().await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ const { default: axios } = require("axios");
|
|||||||
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);
|
||||||
|
|
||||||
@@ -46,11 +47,79 @@ 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@
|
|||||||
"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_in_real_debrid": "هذا التنزيل غير متوفر على Real-Debrid وجلب حالة التنزيل من Real-Debrid غير متاح حاليًا.",
|
"download_error_not_cached_in_real_debrid": "هذا التنزيل غير متوفر على Real-Debrid وجلب حالة التنزيل من Real-Debrid غير متاح حاليًا.",
|
||||||
"download_error_not_cached_in_torbox": "هذا التنزيل غير متوفر على Torbox وجلب حالة التنزيل من Torbox غير متاح حاليًا.",
|
"download_error_not_cached_in_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",
|
||||||
|
|||||||
@@ -194,7 +194,7 @@
|
|||||||
"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_in_real_debrid": "Toto stahování není dostupné na Real-Debrid a získávání informací o stahování z Real-Debrid není zatím dostupné.",
|
"download_error_not_cached_in_real_debrid": "Toto stahování není dostupné na Real-Debrid a získávání informací o stahování z Real-Debrid není zatím dostupné.",
|
||||||
"download_error_not_cached_in_torbox": "Toto stahování není dostupné na Torbox a získávání informací o stahování z Torbox není zatím dostupné.",
|
"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"
|
||||||
|
|||||||
@@ -194,7 +194,7 @@
|
|||||||
"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_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.",
|
"download_error_not_cached_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.",
|
||||||
"download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available.",
|
"download_error_not_cached_in_torbox": "This download is not available on TorBox and polling download status from TorBox is not yet available.",
|
||||||
"game_removed_from_favorites": "Game removed from favorites",
|
"game_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"
|
||||||
@@ -338,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",
|
||||||
|
|||||||
@@ -191,7 +191,7 @@
|
|||||||
"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_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_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_in_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"
|
||||||
},
|
},
|
||||||
@@ -326,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",
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
"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_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.",
|
"download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.",
|
||||||
"download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível.",
|
"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",
|
||||||
"automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados"
|
"automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados"
|
||||||
@@ -325,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",
|
||||||
|
|||||||
@@ -176,7 +176,7 @@
|
|||||||
"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_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.",
|
"download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.",
|
||||||
"download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível.",
|
"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",
|
||||||
|
|||||||
@@ -194,7 +194,7 @@
|
|||||||
"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_in_real_debrid": "Эта загрузка недоступна на Real-Debrid, и получение статуса загрузки с Real-Debrid пока недоступно.",
|
"download_error_not_cached_in_real_debrid": "Эта загрузка недоступна на Real-Debrid, и получение статуса загрузки с Real-Debrid пока недоступно.",
|
||||||
"download_error_not_cached_in_torbox": "Эта загрузка недоступна на Torbox, и получить статус загрузки с Torbox пока невозможно.",
|
"download_error_not_cached_in_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 привязан",
|
||||||
|
|||||||
@@ -194,7 +194,7 @@
|
|||||||
"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_in_real_debrid": "Bu indirme Real-Debrid üzerinde mevcut değil ve Real-Debrid'den indirme durumu henüz sorgulanamıyor.",
|
"download_error_not_cached_in_real_debrid": "Bu indirme Real-Debrid üzerinde mevcut değil ve Real-Debrid'den indirme durumu henüz sorgulanamıyor.",
|
||||||
"download_error_not_cached_in_torbox": "Bu indirme Torbox'ta mevcut değil ve Torbox'tan indirme durumu henüz sorgulanamıyor.",
|
"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,4 +1,4 @@
|
|||||||
import { DownloadManager, Ludusavi, startMainLoop } from "./services";
|
import { Aria2, 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,6 +20,8 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ 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";
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ 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;
|
||||||
@@ -320,20 +318,7 @@ export function DownloadGroup({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="download-group__cover-content">
|
<div className="download-group__cover-content">
|
||||||
{game.download?.downloader === Downloader.TorBox ? (
|
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
|
||||||
<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>
|
||||||
|
|||||||
@@ -87,14 +87,14 @@ export function DownloadSettingsModal({
|
|||||||
return Downloader.Hydra;
|
return Downloader.Hydra;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (availableDownloaders.includes(Downloader.TorBox)) {
|
|
||||||
return Downloader.TorBox;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableDownloaders.includes(Downloader.RealDebrid)) {
|
if (availableDownloaders.includes(Downloader.RealDebrid)) {
|
||||||
return Downloader.RealDebrid;
|
return Downloader.RealDebrid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (availableDownloaders.includes(Downloader.TorBox)) {
|
||||||
|
return Downloader.TorBox;
|
||||||
|
}
|
||||||
|
|
||||||
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,7 +3,6 @@ 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,
|
||||||
@@ -14,7 +13,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");
|
||||||
@@ -23,7 +22,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 = [
|
||||||
@@ -34,19 +33,10 @@ export default function Settings() {
|
|||||||
tabLabel: t("appearance"),
|
tabLabel: t("appearance"),
|
||||||
contentTitle: t("appearance"),
|
contentTitle: t("appearance"),
|
||||||
},
|
},
|
||||||
...(isTorboxEnabled
|
...(isTorBoxEnabled
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
tabLabel: (
|
tabLabel: "TorBox",
|
||||||
<>
|
|
||||||
<img
|
|
||||||
src={torBoxLogo}
|
|
||||||
alt="TorBox"
|
|
||||||
style={{ width: 13, height: 13 }}
|
|
||||||
/>{" "}
|
|
||||||
Torbox
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
contentTitle: "TorBox",
|
contentTitle: "TorBox",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -60,7 +50,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>
|
||||||
@@ -84,7 +74,7 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentCategoryIndex === 4) {
|
if (currentCategoryIndex === 4) {
|
||||||
return <SettingsTorbox />;
|
return <SettingsTorBox />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentCategoryIndex === 5) {
|
if (currentCategoryIndex === 5) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export enum AuthPage {
|
|||||||
|
|
||||||
export enum DownloadError {
|
export enum DownloadError {
|
||||||
NotCachedInRealDebrid = "download_error_not_cached_in_real_debrid",
|
NotCachedInRealDebrid = "download_error_not_cached_in_real_debrid",
|
||||||
NotCachedInTorbox = "download_error_not_cached_in_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",
|
||||||
NotCachedInHydra = "download_error_not_cached_in_hydra",
|
NotCachedInHydra = "download_error_not_cached_in_hydra",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user