mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-06-01 22:09:27 +02:00
refactor: replace Python RPC and aria2 with native addon
This commit is contained in:
@@ -4,4 +4,5 @@ out
|
||||
.gitignore
|
||||
migration.stub
|
||||
hydra-python-rpc/
|
||||
hydra-native/
|
||||
src/main/generated/
|
||||
|
||||
@@ -28,26 +28,12 @@ jobs:
|
||||
with:
|
||||
node-version: 22.21.0
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Build with cx_Freeze
|
||||
run: python python_rpc/setup.py build
|
||||
|
||||
- name: Copy OpenSSL DLLs
|
||||
if: matrix.os == 'windows-2022'
|
||||
run: |
|
||||
cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
|
||||
cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
|
||||
|
||||
- name: Build Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
node-version: 22.21.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
run: yarn --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Validate current commit (last commit) with commitlint
|
||||
run: npx commitlint --last --verbose
|
||||
|
||||
@@ -26,26 +26,12 @@ jobs:
|
||||
with:
|
||||
node-version: 22.21.0
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Build with cx_Freeze
|
||||
run: python python_rpc/setup.py build
|
||||
|
||||
- name: Copy OpenSSL DLLs
|
||||
if: matrix.os == 'windows-2022'
|
||||
run: |
|
||||
cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
|
||||
cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
|
||||
|
||||
- name: Build Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
|
||||
@@ -10,6 +10,8 @@ out
|
||||
ludusavi/**
|
||||
!ludusavi/config.yaml
|
||||
hydra-python-rpc/
|
||||
/hydra-native/
|
||||
native/hydra-native/target/
|
||||
.python-version
|
||||
|
||||
# Sentry Config File
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra Launcher is an open-source gaming platform created to be the single tool that you need in order to manage your gaming library. Hydra is written in Node.js (Electron, React, Typescript) and Python.</strong>
|
||||
<strong>Hydra Launcher is an open-source gaming platform created to be the single tool that you need in order to manage your gaming library. Hydra is written in Node.js (Electron, React, Typescript) and Rust.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -29,6 +29,13 @@
|
||||
|
||||
Please, refer to our Documentation pages: [docs.hydralauncher.gg](https://docs.hydralauncher.gg/getting-started)
|
||||
|
||||
### Local development requirements
|
||||
|
||||
- Node.js + Yarn
|
||||
- Rust toolchain (for `hydra-native`)
|
||||
|
||||
After installing dependencies, `postinstall` now builds the Rust native addon automatically (`hydra-native/hydra-native.node`).
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -4,7 +4,7 @@ directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- ludusavi
|
||||
- hydra-python-rpc
|
||||
- hydra-native
|
||||
- seeds
|
||||
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
||||
- from: resources/achievement.wav
|
||||
@@ -22,7 +22,6 @@ asarUnpack:
|
||||
win:
|
||||
executableName: Hydra
|
||||
extraResources:
|
||||
- from: binaries/aria2c.exe
|
||||
- from: binaries/7z.exe
|
||||
- from: binaries/7z.dll
|
||||
target:
|
||||
@@ -53,7 +52,6 @@ dmg:
|
||||
linux:
|
||||
extraResources:
|
||||
- from: binaries/7zzs
|
||||
- from: binaries/aria2c
|
||||
- from: binaries/umu/umu-run
|
||||
target:
|
||||
- AppImage
|
||||
|
||||
Generated
+3670
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "hydra-native"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
image = { version = "0.25.8", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
|
||||
librqbit = "8.1.1"
|
||||
mime_guess = "2.0.5"
|
||||
napi = { version = "3.5.2", default-features = false, features = ["napi8"] }
|
||||
napi-derive = "3.3.2"
|
||||
once_cell = "1.21.3"
|
||||
data-encoding = "2.9.0"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.12.24", default-features = false, features = ["rustls-tls"] }
|
||||
sysinfo = "0.37.2"
|
||||
tokio = { version = "1.48.0", features = ["rt-multi-thread", "time"] }
|
||||
url = "2.5.7"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.11.0", features = ["v4"] }
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.1"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{cmp::Ordering, collections::HashMap};
|
||||
|
||||
mod torrent;
|
||||
|
||||
use image::codecs::gif::GifDecoder;
|
||||
use image::codecs::png::PngDecoder;
|
||||
use image::codecs::webp::WebPDecoder;
|
||||
use image::{AnimationDecoder, ImageFormat, ImageReader};
|
||||
use napi::bindgen_prelude::Error;
|
||||
use napi_derive::napi;
|
||||
use sysinfo::{ProcessesToUpdate, System};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ProcessedImageData {
|
||||
pub image_path: String,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct NativeProcessPayload {
|
||||
pub exe: Option<String>,
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub environ: Option<HashMap<String, String>>,
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn process_profile_image(
|
||||
image_path: String,
|
||||
target_extension: Option<String>,
|
||||
) -> napi::Result<ProcessedImageData> {
|
||||
let input_path = PathBuf::from(image_path);
|
||||
|
||||
if !input_path.exists() {
|
||||
return Err(Error::from_reason("Image file not found"));
|
||||
}
|
||||
|
||||
let format = detect_image_format(&input_path)?;
|
||||
let animated = is_animated_image(&input_path, format)?;
|
||||
|
||||
if !animated {
|
||||
return Ok(ProcessedImageData {
|
||||
image_path: input_path.to_string_lossy().to_string(),
|
||||
mime_type: mime_type_from_format_or_path(format, &input_path),
|
||||
});
|
||||
}
|
||||
|
||||
let extension = target_extension
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.unwrap_or_else(|| "webp".to_string());
|
||||
|
||||
let output_format = output_format_from_extension(&extension)?;
|
||||
let output_path = build_temp_output_path(&extension);
|
||||
|
||||
let image = ImageReader::open(&input_path)
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?
|
||||
.with_guessed_format()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?
|
||||
.decode()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
image
|
||||
.save_with_format(&output_path, output_format)
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
Ok(ProcessedImageData {
|
||||
image_path: output_path.to_string_lossy().to_string(),
|
||||
mime_type: mime_type_from_format_or_path(Some(output_format), &output_path),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn list_processes() -> Vec<NativeProcessPayload> {
|
||||
let mut system = System::new_all();
|
||||
system.refresh_processes(ProcessesToUpdate::All, true);
|
||||
|
||||
let mut processes: Vec<NativeProcessPayload> = system
|
||||
.processes()
|
||||
.values()
|
||||
.map(|process| {
|
||||
let include_linux_extras = !cfg!(target_os = "windows");
|
||||
|
||||
NativeProcessPayload {
|
||||
exe: process
|
||||
.exe()
|
||||
.map(|value| value.to_string_lossy().to_string()),
|
||||
pid: process.pid().as_u32(),
|
||||
name: process.name().to_string_lossy().to_string(),
|
||||
cwd: if include_linux_extras {
|
||||
process
|
||||
.cwd()
|
||||
.map(|value| value.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
environ: if include_linux_extras {
|
||||
let env_map: HashMap<String, String> = process
|
||||
.environ()
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let entry_value = entry.to_string_lossy();
|
||||
entry_value.split_once('=').and_then(|(key, value)| {
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((key.to_string(), value.to_string()))
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if env_map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(env_map)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
processes.sort_by(|left, right| {
|
||||
let by_pid = left.pid.cmp(&right.pid);
|
||||
if by_pid == Ordering::Equal {
|
||||
left.name.cmp(&right.name)
|
||||
} else {
|
||||
by_pid
|
||||
}
|
||||
});
|
||||
|
||||
processes
|
||||
}
|
||||
|
||||
fn detect_image_format(path: &Path) -> napi::Result<Option<ImageFormat>> {
|
||||
let reader = ImageReader::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
let guessed = reader
|
||||
.with_guessed_format()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
Ok(guessed.format())
|
||||
}
|
||||
|
||||
fn is_animated_image(path: &Path, format: Option<ImageFormat>) -> napi::Result<bool> {
|
||||
match format {
|
||||
Some(ImageFormat::Gif) => is_gif_animated(path),
|
||||
Some(ImageFormat::WebP) => is_webp_animated(path),
|
||||
Some(ImageFormat::Png) => is_apng(path),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_gif_animated(path: &Path) -> napi::Result<bool> {
|
||||
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
let decoder =
|
||||
GifDecoder::new(BufReader::new(file)).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
let mut frames = decoder.into_frames();
|
||||
let _ = frames.next().transpose();
|
||||
Ok(matches!(frames.next().transpose(), Ok(Some(_))))
|
||||
}
|
||||
|
||||
fn is_webp_animated(path: &Path) -> napi::Result<bool> {
|
||||
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
let decoder = WebPDecoder::new(BufReader::new(file))
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
Ok(decoder.has_animation())
|
||||
}
|
||||
|
||||
fn is_apng(path: &Path) -> napi::Result<bool> {
|
||||
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
let decoder =
|
||||
PngDecoder::new(BufReader::new(file)).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
decoder
|
||||
.is_apng()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))
|
||||
}
|
||||
|
||||
fn output_format_from_extension(extension: &str) -> napi::Result<ImageFormat> {
|
||||
match extension {
|
||||
"png" => Ok(ImageFormat::Png),
|
||||
"jpg" | "jpeg" => Ok(ImageFormat::Jpeg),
|
||||
"webp" => Ok(ImageFormat::WebP),
|
||||
_ => Err(Error::from_reason("Unsupported target extension")),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_temp_output_path(extension: &str) -> PathBuf {
|
||||
let mut output_path = std::env::temp_dir();
|
||||
output_path.push(format!("{}.{}", Uuid::new_v4(), extension));
|
||||
output_path
|
||||
}
|
||||
|
||||
fn mime_type_from_format_or_path(format: Option<ImageFormat>, path: &Path) -> String {
|
||||
if let Some(value) = mime_type_from_image_format(format) {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.essence_str()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn mime_type_from_image_format(format: Option<ImageFormat>) -> Option<&'static str> {
|
||||
match format {
|
||||
Some(ImageFormat::Png) => Some("image/png"),
|
||||
Some(ImageFormat::Jpeg) => Some("image/jpeg"),
|
||||
Some(ImageFormat::Gif) => Some("image/gif"),
|
||||
Some(ImageFormat::WebP) => Some("image/webp"),
|
||||
Some(ImageFormat::Bmp) => Some("image/bmp"),
|
||||
Some(ImageFormat::Ico) => Some("image/x-icon"),
|
||||
Some(ImageFormat::Tiff) => Some("image/tiff"),
|
||||
Some(ImageFormat::Avif) => Some("image/avif"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -20,14 +20,15 @@
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"build:native": "node ./scripts/build-native-addon.cjs",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
|
||||
"postinstall": "npm run build:native && electron-builder install-app-deps && node ./scripts/postinstall.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "electron-vite build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux",
|
||||
"build:win": "npm run build:native && electron-vite build && electron-builder --win",
|
||||
"build:mac": "npm run build:native && electron-vite build && electron-builder --mac",
|
||||
"build:linux": "npm run build:native && electron-vite build && electron-builder --linux",
|
||||
"prepare": "husky",
|
||||
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
|
||||
},
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import aria2p
|
||||
from aria2p.client import ClientException as DownloadNotFound
|
||||
|
||||
class HttpDownloader:
|
||||
def __init__(self):
|
||||
self.download = None
|
||||
self.max_download_speed = None
|
||||
self.aria2 = aria2p.API(
|
||||
aria2p.Client(
|
||||
host="http://localhost",
|
||||
port=6800,
|
||||
secret=""
|
||||
)
|
||||
)
|
||||
|
||||
def set_download_limit(self, max_download_speed: int = None):
|
||||
self.max_download_speed = max_download_speed if max_download_speed and max_download_speed > 0 else None
|
||||
speed_limit = str(self.max_download_speed) if self.max_download_speed else "0"
|
||||
try:
|
||||
self.aria2.set_global_options({"max-overall-download-limit": speed_limit})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def start_download(self, url: str, save_path: str, header, out: str = None):
|
||||
if self.download:
|
||||
self.aria2.resume([self.download])
|
||||
else:
|
||||
options = {"dir": save_path}
|
||||
if self.max_download_speed:
|
||||
options["max-download-limit"] = str(self.max_download_speed)
|
||||
if header:
|
||||
options["header"] = header
|
||||
if out:
|
||||
options["out"] = out
|
||||
downloads = self.aria2.add(url, options=options)
|
||||
self.download = downloads[0]
|
||||
|
||||
def pause_download(self):
|
||||
if self.download:
|
||||
self.aria2.pause([self.download])
|
||||
|
||||
def cancel_download(self):
|
||||
if self.download:
|
||||
self.aria2.remove([self.download])
|
||||
self.download = None
|
||||
|
||||
def get_download_status(self):
|
||||
if self.download == None:
|
||||
return None
|
||||
|
||||
try:
|
||||
download = self.aria2.get_download(self.download.gid)
|
||||
except DownloadNotFound:
|
||||
self.download = None
|
||||
return None
|
||||
|
||||
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
|
||||
@@ -1,541 +0,0 @@
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import libtorrent as lt
|
||||
import psutil
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
from http_downloader import HttpDownloader
|
||||
from profile_image_processor import ProfileImageProcessor
|
||||
from torrent_downloader import TorrentDownloader
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("hydra.rpc")
|
||||
|
||||
|
||||
# Retrieve command line arguments
|
||||
torrent_port = sys.argv[1]
|
||||
http_port = sys.argv[2]
|
||||
rpc_password = sys.argv[3]
|
||||
start_download_payload = sys.argv[4]
|
||||
start_seeding_payload = sys.argv[5]
|
||||
|
||||
|
||||
downloads = {}
|
||||
downloads_lock = threading.RLock()
|
||||
metadata_semaphore = threading.BoundedSemaphore(value=2)
|
||||
|
||||
# This can be streamed down from Node
|
||||
downloading_game_id = -1
|
||||
current_download_limit = None
|
||||
|
||||
torrent_session = lt.session(
|
||||
{"listen_interfaces": "0.0.0.0:{port}".format(port=torrent_port)}
|
||||
)
|
||||
|
||||
MAGNET_HASH_HEX_RE = re.compile(r"^[a-fA-F0-9]{40}$")
|
||||
MAGNET_HASH_BASE32_RE = re.compile(r"^[a-zA-Z2-7]{32}$")
|
||||
|
||||
TORRENT_FILES_CACHE_TTL_SECONDS = 300
|
||||
TORRENT_FILES_CACHE_MAX_ITEMS = 128
|
||||
torrent_files_cache = {}
|
||||
torrent_files_cache_lock = threading.RLock()
|
||||
|
||||
|
||||
def load_json_payload(raw_payload: str):
|
||||
if not raw_payload:
|
||||
return None
|
||||
|
||||
return json.loads(urllib.parse.unquote(raw_payload))
|
||||
|
||||
|
||||
def parse_file_indices(file_indices):
|
||||
if file_indices is None:
|
||||
return None
|
||||
|
||||
if not isinstance(file_indices, list):
|
||||
raise ValueError("invalid_file_indices")
|
||||
|
||||
parsed = []
|
||||
for index in file_indices:
|
||||
if isinstance(index, bool) or not isinstance(index, int):
|
||||
raise ValueError("invalid_file_indices")
|
||||
parsed.append(index)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def validate_magnet_uri(magnet: str):
|
||||
if not isinstance(magnet, str):
|
||||
raise ValueError("invalid_magnet")
|
||||
|
||||
magnet = magnet.strip()
|
||||
if not magnet.startswith("magnet:"):
|
||||
raise ValueError("invalid_magnet")
|
||||
|
||||
if len(magnet) > 8192:
|
||||
raise ValueError("invalid_magnet")
|
||||
|
||||
parsed = urllib.parse.urlparse(magnet)
|
||||
if parsed.scheme != "magnet":
|
||||
raise ValueError("invalid_magnet")
|
||||
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
xt_values = query.get("xt") or []
|
||||
|
||||
info_hash = None
|
||||
for xt in xt_values:
|
||||
if not xt.startswith("urn:btih:"):
|
||||
continue
|
||||
|
||||
hash_candidate = xt[len("urn:btih:") :].strip()
|
||||
|
||||
if MAGNET_HASH_HEX_RE.match(hash_candidate) or MAGNET_HASH_BASE32_RE.match(
|
||||
hash_candidate
|
||||
):
|
||||
info_hash = hash_candidate.lower()
|
||||
break
|
||||
|
||||
if info_hash is None:
|
||||
raise ValueError("invalid_magnet")
|
||||
|
||||
return magnet, info_hash
|
||||
|
||||
|
||||
def get_cached_torrent_files(info_hash: str):
|
||||
with torrent_files_cache_lock:
|
||||
item = torrent_files_cache.get(info_hash)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
if time.time() - item["timestamp"] > TORRENT_FILES_CACHE_TTL_SECONDS:
|
||||
torrent_files_cache.pop(info_hash, None)
|
||||
return None
|
||||
|
||||
return item["value"]
|
||||
|
||||
|
||||
def set_cached_torrent_files(info_hash: str, value):
|
||||
with torrent_files_cache_lock:
|
||||
if len(torrent_files_cache) >= TORRENT_FILES_CACHE_MAX_ITEMS:
|
||||
oldest_key = min(
|
||||
torrent_files_cache,
|
||||
key=lambda cache_key: torrent_files_cache[cache_key]["timestamp"],
|
||||
)
|
||||
torrent_files_cache.pop(oldest_key, None)
|
||||
|
||||
torrent_files_cache[info_hash] = {
|
||||
"timestamp": time.time(),
|
||||
"value": value,
|
||||
}
|
||||
|
||||
|
||||
def map_downloader_error(error: Exception):
|
||||
code = str(error)
|
||||
|
||||
if isinstance(error, TimeoutError) or code == "metadata_timeout":
|
||||
return jsonify({"error": "metadata_timeout"}), 408
|
||||
|
||||
if code in {
|
||||
"invalid_magnet",
|
||||
"invalid_file_indices",
|
||||
"empty_selection",
|
||||
"invalid_url",
|
||||
"invalid_save_path",
|
||||
}:
|
||||
return jsonify({"error": code}), 400
|
||||
|
||||
if code == "metadata_incomplete":
|
||||
return jsonify({"error": "metadata_incomplete"}), 422
|
||||
|
||||
if code == "too_many_files":
|
||||
return jsonify({"error": "too_many_files"}), 413
|
||||
|
||||
logger.error("Unhandled RPC error: %s", error, exc_info=True)
|
||||
return jsonify({"error": "internal_error"}), 500
|
||||
|
||||
|
||||
def normalize_download_limit(value):
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def apply_download_limit(downloader):
|
||||
if not downloader:
|
||||
return
|
||||
|
||||
set_download_limit = getattr(downloader, "set_download_limit", None)
|
||||
if callable(set_download_limit):
|
||||
set_download_limit(current_download_limit)
|
||||
|
||||
def validate_rpc_password():
|
||||
"""Middleware to validate RPC password."""
|
||||
header_password = request.headers.get("x-hydra-rpc-password")
|
||||
if not isinstance(header_password, str) or not hmac.compare_digest(
|
||||
header_password, rpc_password
|
||||
):
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
|
||||
def start_torrent_download(game_id, url, save_path, file_indices=None, flags=None):
|
||||
with downloads_lock:
|
||||
existing_downloader = downloads.get(game_id)
|
||||
|
||||
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
|
||||
apply_download_limit(existing_downloader)
|
||||
existing_downloader.start_download(url, save_path, file_indices=file_indices)
|
||||
return
|
||||
|
||||
torrent_downloader = TorrentDownloader(
|
||||
torrent_session,
|
||||
flags or lt.torrent_flags.auto_managed,
|
||||
session_lock=downloads_lock,
|
||||
)
|
||||
apply_download_limit(torrent_downloader)
|
||||
|
||||
with downloads_lock:
|
||||
downloads[game_id] = torrent_downloader
|
||||
|
||||
try:
|
||||
torrent_downloader.start_download(url, save_path, file_indices=file_indices)
|
||||
except Exception:
|
||||
with downloads_lock:
|
||||
downloads.pop(game_id, None)
|
||||
raise
|
||||
|
||||
|
||||
def start_http_download(game_id, url, save_path, header=None, out=None):
|
||||
with downloads_lock:
|
||||
existing_downloader = downloads.get(game_id)
|
||||
|
||||
if existing_downloader and isinstance(existing_downloader, HttpDownloader):
|
||||
apply_download_limit(existing_downloader)
|
||||
existing_downloader.start_download(url, save_path, header, out)
|
||||
return
|
||||
|
||||
http_downloader = HttpDownloader()
|
||||
apply_download_limit(http_downloader)
|
||||
|
||||
with downloads_lock:
|
||||
downloads[game_id] = http_downloader
|
||||
|
||||
try:
|
||||
http_downloader.start_download(url, save_path, header, out)
|
||||
except Exception:
|
||||
with downloads_lock:
|
||||
downloads.pop(game_id, None)
|
||||
raise
|
||||
|
||||
|
||||
def bootstrap_downloads():
|
||||
global downloading_game_id
|
||||
|
||||
initial_download = load_json_payload(start_download_payload)
|
||||
if initial_download:
|
||||
downloading_game_id = initial_download["game_id"]
|
||||
|
||||
try:
|
||||
if initial_download["url"].startswith("magnet"):
|
||||
file_indices = parse_file_indices(initial_download.get("file_indices"))
|
||||
start_torrent_download(
|
||||
initial_download["game_id"],
|
||||
initial_download["url"],
|
||||
initial_download["save_path"],
|
||||
file_indices=file_indices,
|
||||
)
|
||||
else:
|
||||
start_http_download(
|
||||
initial_download["game_id"],
|
||||
initial_download["url"],
|
||||
initial_download["save_path"],
|
||||
initial_download.get("header"),
|
||||
initial_download.get("out"),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Error starting initial download: %s", error, exc_info=True)
|
||||
|
||||
initial_seeding = load_json_payload(start_seeding_payload)
|
||||
if initial_seeding:
|
||||
for seed in initial_seeding:
|
||||
try:
|
||||
start_torrent_download(
|
||||
seed["game_id"],
|
||||
seed["url"],
|
||||
seed["save_path"],
|
||||
flags=lt.torrent_flags.upload_mode,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Error starting initial seeding: %s", error, exc_info=True)
|
||||
|
||||
|
||||
@app.route("/status", methods=["GET"])
|
||||
def status():
|
||||
auth_error = validate_rpc_password()
|
||||
if auth_error:
|
||||
return auth_error
|
||||
|
||||
with downloads_lock:
|
||||
downloader = downloads.get(downloading_game_id)
|
||||
|
||||
if not downloader:
|
||||
return jsonify(None)
|
||||
|
||||
status_payload = downloader.get_download_status()
|
||||
if not status_payload:
|
||||
return jsonify(None)
|
||||
|
||||
return jsonify(status_payload), 200
|
||||
|
||||
|
||||
@app.route("/seed-status", methods=["GET"])
|
||||
def seed_status():
|
||||
auth_error = validate_rpc_password()
|
||||
if auth_error:
|
||||
return auth_error
|
||||
|
||||
with downloads_lock:
|
||||
download_items = list(downloads.items())
|
||||
|
||||
seed_payload = []
|
||||
for game_id, downloader in download_items:
|
||||
if not downloader:
|
||||
continue
|
||||
|
||||
response = downloader.get_download_status()
|
||||
if not response:
|
||||
continue
|
||||
|
||||
if response.get("status") == 5: # Torrent seeding check
|
||||
seed_payload.append(
|
||||
{
|
||||
"gameId": game_id,
|
||||
**response,
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(seed_payload), 200
|
||||
|
||||
|
||||
@app.route("/healthcheck", methods=["GET"])
|
||||
def healthcheck():
|
||||
return "ok", 200
|
||||
|
||||
|
||||
@app.route("/torrent-files", methods=["POST"])
|
||||
def torrent_files():
|
||||
auth_error = validate_rpc_password()
|
||||
if auth_error:
|
||||
return auth_error
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
magnet, info_hash = validate_magnet_uri(data.get("magnet"))
|
||||
except Exception as error:
|
||||
return map_downloader_error(error)
|
||||
|
||||
cached_payload = get_cached_torrent_files(info_hash)
|
||||
if cached_payload is not None:
|
||||
return jsonify(cached_payload), 200
|
||||
|
||||
timeout_ms = data.get("timeout_ms", 30000)
|
||||
try:
|
||||
timeout_ms = int(timeout_ms)
|
||||
except (TypeError, ValueError):
|
||||
timeout_ms = 30000
|
||||
|
||||
timeout_ms = max(5000, min(timeout_ms, 120000))
|
||||
timeout_seconds = timeout_ms / 1000
|
||||
|
||||
if not metadata_semaphore.acquire(timeout=5):
|
||||
return jsonify({"error": "metadata_busy"}), 429
|
||||
|
||||
temp_downloader = TorrentDownloader(
|
||||
torrent_session,
|
||||
lt.torrent_flags.upload_mode,
|
||||
session_lock=downloads_lock,
|
||||
)
|
||||
|
||||
started_at = time.time()
|
||||
|
||||
try:
|
||||
temp_downloader.start_download(magnet, tempfile.gettempdir())
|
||||
files_payload = temp_downloader.get_torrent_files(timeout_seconds=timeout_seconds)
|
||||
response = {
|
||||
"infoHash": info_hash,
|
||||
**files_payload,
|
||||
}
|
||||
|
||||
set_cached_torrent_files(info_hash, response)
|
||||
|
||||
elapsed_ms = int((time.time() - started_at) * 1000)
|
||||
logger.info("Resolved torrent metadata hash=%s in %sms", info_hash, elapsed_ms)
|
||||
|
||||
return jsonify(response), 200
|
||||
except Exception as error:
|
||||
return map_downloader_error(error)
|
||||
finally:
|
||||
temp_downloader.cancel_download()
|
||||
metadata_semaphore.release()
|
||||
|
||||
|
||||
@app.route("/process-list", methods=["GET"])
|
||||
def process_list():
|
||||
auth_error = validate_rpc_password()
|
||||
if auth_error:
|
||||
return auth_error
|
||||
|
||||
iter_list = ["exe", "pid", "name"]
|
||||
if sys.platform != "win32":
|
||||
iter_list.append("cwd")
|
||||
iter_list.append("environ")
|
||||
|
||||
process_list_payload = [proc.info for proc in psutil.process_iter(iter_list)]
|
||||
return jsonify(process_list_payload), 200
|
||||
|
||||
|
||||
@app.route("/profile-image", methods=["POST"])
|
||||
def profile_image():
|
||||
auth_error = validate_rpc_password()
|
||||
if auth_error:
|
||||
return auth_error
|
||||
|
||||
data = request.get_json()
|
||||
image_path = data.get("image_path")
|
||||
|
||||
# use webp as default value for target_extension
|
||||
target_extension = data.get("target_extension") or "webp"
|
||||
|
||||
try:
|
||||
processed_image_path, mime_type = ProfileImageProcessor.process_image(
|
||||
image_path, target_extension
|
||||
)
|
||||
return jsonify({"imagePath": processed_image_path, "mimeType": mime_type}), 200
|
||||
except Exception as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
|
||||
@app.route("/action", methods=["POST"])
|
||||
def action():
|
||||
global downloading_game_id
|
||||
global current_download_limit
|
||||
|
||||
auth_error = validate_rpc_password()
|
||||
if auth_error:
|
||||
return auth_error
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
action_name = data.get("action")
|
||||
game_id = data.get("game_id")
|
||||
|
||||
if not action_name:
|
||||
return jsonify({"error": "invalid_action"}), 400
|
||||
|
||||
requires_game_id = {"start", "pause", "cancel", "resume_seeding", "pause_seeding"}
|
||||
if action_name in requires_game_id and not game_id:
|
||||
return jsonify({"error": "invalid_game_id"}), 400
|
||||
|
||||
try:
|
||||
if action_name == "start":
|
||||
url = data.get("url")
|
||||
if not isinstance(url, str):
|
||||
raise ValueError("invalid_url")
|
||||
|
||||
save_path = data.get("save_path")
|
||||
if not isinstance(save_path, str):
|
||||
raise ValueError("invalid_save_path")
|
||||
|
||||
if url.startswith("magnet"):
|
||||
file_indices = parse_file_indices(data.get("file_indices"))
|
||||
start_torrent_download(
|
||||
game_id,
|
||||
url,
|
||||
save_path,
|
||||
file_indices=file_indices,
|
||||
)
|
||||
else:
|
||||
start_http_download(
|
||||
game_id,
|
||||
url,
|
||||
save_path,
|
||||
data.get("header"),
|
||||
data.get("out"),
|
||||
)
|
||||
|
||||
downloading_game_id = game_id
|
||||
elif action_name == "pause":
|
||||
with downloads_lock:
|
||||
downloader = downloads.get(game_id)
|
||||
|
||||
if downloader:
|
||||
downloader.pause_download()
|
||||
|
||||
if downloading_game_id == game_id:
|
||||
downloading_game_id = -1
|
||||
elif action_name == "cancel":
|
||||
with downloads_lock:
|
||||
downloader = downloads.get(game_id)
|
||||
|
||||
if downloader:
|
||||
downloader.cancel_download()
|
||||
|
||||
with downloads_lock:
|
||||
downloads.pop(game_id, None)
|
||||
|
||||
if downloading_game_id == game_id:
|
||||
downloading_game_id = -1
|
||||
elif action_name == "resume_seeding":
|
||||
start_torrent_download(
|
||||
game_id,
|
||||
data["url"],
|
||||
data["save_path"],
|
||||
flags=lt.torrent_flags.upload_mode,
|
||||
)
|
||||
elif action_name == "pause_seeding":
|
||||
with downloads_lock:
|
||||
downloader = downloads.get(game_id)
|
||||
|
||||
if downloader:
|
||||
downloader.cancel_download()
|
||||
|
||||
with downloads_lock:
|
||||
downloads.pop(game_id, None)
|
||||
elif action_name == "set_download_limit":
|
||||
current_download_limit = normalize_download_limit(
|
||||
data.get("max_download_speed_bytes_per_second")
|
||||
)
|
||||
|
||||
with downloads_lock:
|
||||
active_downloaders = list(downloads.values())
|
||||
|
||||
for downloader in active_downloaders:
|
||||
apply_download_limit(downloader)
|
||||
else:
|
||||
return jsonify({"error": "invalid_action"}), 400
|
||||
except Exception as error:
|
||||
return map_downloader_error(error)
|
||||
|
||||
return "", 200
|
||||
|
||||
|
||||
bootstrap_downloads()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=int(http_port), threaded=True)
|
||||
@@ -1,30 +0,0 @@
|
||||
from PIL import Image
|
||||
import os, uuid, tempfile
|
||||
|
||||
class ProfileImageProcessor:
|
||||
|
||||
@staticmethod
|
||||
def get_parsed_image_data(image_path, target_extension):
|
||||
Image.MAX_IMAGE_PIXELS = 933120000
|
||||
|
||||
image = Image.open(image_path)
|
||||
|
||||
try:
|
||||
image.seek(1)
|
||||
except EOFError:
|
||||
mime_type = image.get_format_mimetype()
|
||||
return image_path, mime_type
|
||||
else:
|
||||
new_uuid = str(uuid.uuid4())
|
||||
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + "." + target_extension
|
||||
image.save(new_image_path)
|
||||
|
||||
new_image = Image.open(new_image_path)
|
||||
mime_type = new_image.get_format_mimetype()
|
||||
|
||||
return new_image_path, mime_type
|
||||
|
||||
|
||||
@staticmethod
|
||||
def process_image(image_path, target_extension):
|
||||
return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension)
|
||||
@@ -1,20 +0,0 @@
|
||||
from cx_Freeze import setup, Executable
|
||||
|
||||
# Dependencies are automatically detected, but it might need fine tuning.
|
||||
build_exe_options = {
|
||||
"packages": ["libtorrent"],
|
||||
"build_exe": "hydra-python-rpc",
|
||||
"include_msvcr": True
|
||||
}
|
||||
|
||||
setup(
|
||||
name="hydra-python-rpc",
|
||||
version="0.1",
|
||||
description="Hydra",
|
||||
options={"build_exe": build_exe_options},
|
||||
executables=[Executable(
|
||||
"python_rpc/main.py",
|
||||
target_name="hydra-python-rpc",
|
||||
icon="build/icon.ico"
|
||||
)]
|
||||
)
|
||||
@@ -1,391 +0,0 @@
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Optional, Set
|
||||
|
||||
import libtorrent as lt
|
||||
|
||||
|
||||
class TorrentDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
torrent_session,
|
||||
flags=lt.torrent_flags.auto_managed,
|
||||
session_lock: Optional[threading.RLock] = None,
|
||||
):
|
||||
self.torrent_handle = None
|
||||
self.session = torrent_session
|
||||
self.flags = flags
|
||||
self.session_lock = session_lock or threading.RLock()
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
self.logger = logging.getLogger("hydra.torrent")
|
||||
self.trackers = [
|
||||
"udp://tracker.opentrackr.org:1337/announce",
|
||||
"http://tracker.opentrackr.org:1337/announce",
|
||||
"udp://open.tracker.cl:1337/announce",
|
||||
"udp://open.demonii.com:1337/announce",
|
||||
"udp://open.stealth.si:80/announce",
|
||||
"udp://tracker.torrent.eu.org:451/announce",
|
||||
"udp://exodus.desync.com:6969/announce",
|
||||
"udp://tracker.theoks.net:6969/announce",
|
||||
"udp://tracker-udp.gbitt.info:80/announce",
|
||||
"udp://explodie.org:6969/announce",
|
||||
"https://tracker.tamersunion.org:443/announce",
|
||||
"udp://tracker2.dler.org:80/announce",
|
||||
"udp://tracker1.myporn.club:9337/announce",
|
||||
"udp://tracker.tiny-vps.com:6969/announce",
|
||||
"udp://tracker.dler.org:6969/announce",
|
||||
"udp://tracker.bittor.pw:1337/announce",
|
||||
"udp://tracker.0x7c0.com:6969/announce",
|
||||
"udp://retracker01-msk-virt.corbina.net:80/announce",
|
||||
"udp://opentracker.io:6969/announce",
|
||||
"udp://open.free-tracker.ga:6969/announce",
|
||||
"udp://new-line.net:6969/announce",
|
||||
"udp://moonburrow.club:6969/announce",
|
||||
"udp://leet-tracker.moe:1337/announce",
|
||||
"udp://bt2.archive.org:6969/announce",
|
||||
"udp://bt1.archive.org:6969/announce",
|
||||
"http://tracker2.dler.org:80/announce",
|
||||
"http://tracker1.bt.moack.co.kr:80/announce",
|
||||
"http://tracker.dler.org:6969/announce",
|
||||
"http://tr.kxmp.cf:80/announce",
|
||||
"udp://u.peer-exchange.download:6969/announce",
|
||||
"udp://ttk2.nbaonlineservice.com:6969/announce",
|
||||
"udp://tracker.tryhackx.org:6969/announce",
|
||||
"udp://tracker.srv00.com:6969/announce",
|
||||
"udp://tracker.skynetcloud.site:6969/announce",
|
||||
"udp://tracker.jamesthebard.net:6969/announce",
|
||||
"udp://tracker.fnix.net:6969/announce",
|
||||
"udp://tracker.filemail.com:6969/announce",
|
||||
"udp://tracker.farted.net:6969/announce",
|
||||
"udp://tracker.edkj.club:6969/announce",
|
||||
"udp://tracker.dump.cl:6969/announce",
|
||||
"udp://tracker.deadorbit.nl:6969/announce",
|
||||
"udp://tracker.darkness.services:6969/announce",
|
||||
"udp://tracker.ccp.ovh:6969/announce",
|
||||
"udp://tamas3.ynh.fr:6969/announce",
|
||||
"udp://ryjer.com:6969/announce",
|
||||
"udp://run.publictracker.xyz:6969/announce",
|
||||
"udp://public.tracker.vraphim.com:6969/announce",
|
||||
"udp://p4p.arenabg.com:1337/announce",
|
||||
"udp://p2p.publictracker.xyz:6969/announce",
|
||||
"udp://open.u-p.pw:6969/announce",
|
||||
"udp://open.publictracker.xyz:6969/announce",
|
||||
"udp://open.dstud.io:6969/announce",
|
||||
"udp://open.demonoid.ch:6969/announce",
|
||||
"udp://odd-hd.fr:6969/announce",
|
||||
"udp://martin-gebhardt.eu:25/announce",
|
||||
"udp://jutone.com:6969/announce",
|
||||
"udp://isk.richardsw.club:6969/announce",
|
||||
"udp://evan.im:6969/announce",
|
||||
"udp://epider.me:6969/announce",
|
||||
"udp://d40969.acod.regrucolo.ru:6969/announce",
|
||||
"udp://bt.rer.lol:6969/announce",
|
||||
"udp://amigacity.xyz:6969/announce",
|
||||
"udp://1c.premierzal.ru:6969/announce",
|
||||
"https://trackers.run:443/announce",
|
||||
"https://tracker.yemekyedim.com:443/announce",
|
||||
"https://tracker.renfei.net:443/announce",
|
||||
"https://tracker.pmman.tech:443/announce",
|
||||
"https://tracker.lilithraws.org:443/announce",
|
||||
"https://tracker.imgoingto.icu:443/announce",
|
||||
"https://tracker.cloudit.top:443/announce",
|
||||
"https://tracker-zhuqiy.dgj055.icu:443/announce",
|
||||
"http://tracker.renfei.net:8080/announce",
|
||||
"http://tracker.mywaifu.best:6969/announce",
|
||||
"http://tracker.ipv6tracker.org:80/announce",
|
||||
"http://tracker.files.fm:6969/announce",
|
||||
"http://tracker.edkj.club:6969/announce",
|
||||
"http://tracker.bt4g.com:2095/announce",
|
||||
"http://tracker-zhuqiy.dgj055.icu:80/announce",
|
||||
"http://t1.aag.moe:17715/announce",
|
||||
"http://t.overflow.biz:6969/announce",
|
||||
"http://bittorrent-tracker.e-n-c-r-y-p-t.net:1337/announce",
|
||||
"udp://torrents.artixlinux.org:6969/announce",
|
||||
"udp://mail.artixlinux.org:6969/announce",
|
||||
"udp://ipv4.rer.lol:2710/announce",
|
||||
"udp://concen.org:6969/announce",
|
||||
"udp://bt.rer.lol:2710/announce",
|
||||
"udp://aegir.sexy:6969/announce",
|
||||
"https://www.peckservers.com:9443/announce",
|
||||
"https://tracker.ipfsscan.io:443/announce",
|
||||
"https://tracker.gcrenwp.top:443/announce",
|
||||
"http://www.peckservers.com:9000/announce",
|
||||
"http://tracker1.itzmx.com:8080/announce",
|
||||
"http://ch3oh.ru:6969/announce",
|
||||
"http://bvarf.tracker.sh:2086/announce",
|
||||
]
|
||||
|
||||
def set_download_limit(self, max_download_speed: int = None):
|
||||
download_limit = (
|
||||
max_download_speed if max_download_speed and max_download_speed > 0 else 0
|
||||
)
|
||||
try:
|
||||
self.session.set_download_rate_limit(download_limit)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _wait_for_metadata(self, timeout_seconds: float = 30.0, poll_interval: float = 0.25):
|
||||
if not self.torrent_handle or not self.torrent_handle.is_valid():
|
||||
return False
|
||||
|
||||
deadline = time.monotonic() + max(timeout_seconds, 1.0)
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status = self.torrent_handle.status()
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
if status.has_metadata:
|
||||
return True
|
||||
|
||||
time.sleep(max(poll_interval, 0.05))
|
||||
|
||||
return False
|
||||
|
||||
def wait_for_metadata(self, timeout_seconds: float = 30.0):
|
||||
return self._wait_for_metadata(timeout_seconds=timeout_seconds)
|
||||
|
||||
def _sanitize_file_indices(self, file_indices: List[int], files_storage):
|
||||
if file_indices is None:
|
||||
return None
|
||||
|
||||
if not isinstance(file_indices, list):
|
||||
raise ValueError("invalid_file_indices")
|
||||
|
||||
max_index = files_storage.num_files() - 1
|
||||
sanitized: Set[int] = set()
|
||||
|
||||
for index in file_indices:
|
||||
if isinstance(index, bool) or not isinstance(index, int):
|
||||
raise ValueError("invalid_file_indices")
|
||||
|
||||
if index < 0 or index > max_index:
|
||||
raise ValueError("invalid_file_indices")
|
||||
|
||||
sanitized.add(index)
|
||||
|
||||
if not sanitized:
|
||||
raise ValueError("empty_selection")
|
||||
|
||||
return sorted(sanitized)
|
||||
|
||||
def _set_selected_file_priorities(self, selected_indices: List[int], files_storage):
|
||||
priorities = [0] * files_storage.num_files()
|
||||
for index in selected_indices:
|
||||
priorities[index] = 1
|
||||
|
||||
self.torrent_handle.prioritize_files(priorities)
|
||||
|
||||
deadline = time.monotonic() + 3.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
current_priorities = [int(priority) for priority in self.torrent_handle.get_file_priorities()]
|
||||
except RuntimeError:
|
||||
break
|
||||
|
||||
if current_priorities == priorities:
|
||||
return
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
self.logger.warning("File priority synchronization timeout")
|
||||
|
||||
def start_download(
|
||||
self,
|
||||
magnet: str,
|
||||
save_path: str,
|
||||
file_indices: Optional[List[int]] = None,
|
||||
wait_timeout_seconds: float = 30.0,
|
||||
):
|
||||
selective_download = file_indices is not None
|
||||
|
||||
with self.session_lock:
|
||||
if self.torrent_handle and self.torrent_handle.is_valid():
|
||||
if not selective_download:
|
||||
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
||||
self.torrent_handle.resume()
|
||||
return
|
||||
|
||||
self.torrent_handle.pause()
|
||||
self.session.remove_torrent(self.torrent_handle)
|
||||
self.torrent_handle = None
|
||||
|
||||
initial_flags = self.flags | lt.torrent_flags.paused
|
||||
|
||||
if selective_download:
|
||||
initial_flags |= lt.torrent_flags.default_dont_download
|
||||
initial_flags |= lt.torrent_flags.auto_managed
|
||||
else:
|
||||
initial_flags |= lt.torrent_flags.auto_managed
|
||||
|
||||
params = {
|
||||
"url": magnet,
|
||||
"save_path": save_path,
|
||||
"trackers": self.trackers,
|
||||
"flags": initial_flags,
|
||||
}
|
||||
|
||||
if self.torrent_handle is None or not self.torrent_handle.is_valid():
|
||||
self.torrent_handle = self.session.add_torrent(params)
|
||||
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
|
||||
if selective_download:
|
||||
try:
|
||||
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
||||
self.torrent_handle.resume()
|
||||
|
||||
if not self._wait_for_metadata(timeout_seconds=wait_timeout_seconds):
|
||||
raise TimeoutError("metadata_timeout")
|
||||
|
||||
try:
|
||||
info = self.torrent_handle.get_torrent_info()
|
||||
files_storage = info.files()
|
||||
except RuntimeError as error:
|
||||
raise RuntimeError("metadata_incomplete") from error
|
||||
|
||||
self.torrent_handle.pause()
|
||||
self.torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
|
||||
|
||||
sanitized_indices = self._sanitize_file_indices(file_indices, files_storage)
|
||||
self._set_selected_file_priorities(sanitized_indices, files_storage)
|
||||
|
||||
self.selected_file_indices = sanitized_indices
|
||||
self.selected_size_bytes = sum(files_storage.file_size(index) for index in sanitized_indices)
|
||||
except Exception:
|
||||
self.cancel_download()
|
||||
raise
|
||||
|
||||
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
||||
self.torrent_handle.resume()
|
||||
|
||||
def get_torrent_files(self, timeout_seconds: float = 30.0, max_files: int = 100000):
|
||||
if not self._wait_for_metadata(timeout_seconds=timeout_seconds):
|
||||
raise TimeoutError("metadata_timeout")
|
||||
|
||||
try:
|
||||
info = self.torrent_handle.get_torrent_info()
|
||||
except RuntimeError as error:
|
||||
raise RuntimeError("metadata_incomplete") from error
|
||||
|
||||
files_storage = info.files()
|
||||
file_count = files_storage.num_files()
|
||||
|
||||
if file_count > max_files:
|
||||
raise OverflowError("too_many_files")
|
||||
|
||||
files = []
|
||||
for index in range(file_count):
|
||||
files.append(
|
||||
{
|
||||
"index": index,
|
||||
"path": files_storage.file_path(index),
|
||||
"length": files_storage.file_size(index),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"name": info.name(),
|
||||
"totalSize": info.total_size(),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
def pause_download(self):
|
||||
if self.torrent_handle:
|
||||
self.torrent_handle.pause()
|
||||
self.torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
|
||||
|
||||
def cancel_download(self):
|
||||
with self.session_lock:
|
||||
if self.torrent_handle:
|
||||
if self.torrent_handle.is_valid():
|
||||
self.torrent_handle.pause()
|
||||
self.session.remove_torrent(self.torrent_handle, lt.session.delete_partfile)
|
||||
self.torrent_handle = None
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
|
||||
def abort_session(self):
|
||||
self.cancel_download()
|
||||
self.session.abort()
|
||||
self.torrent_handle = None
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
|
||||
def _get_handle_status(self):
|
||||
if self.torrent_handle is None:
|
||||
return None
|
||||
|
||||
if not self.torrent_handle.is_valid():
|
||||
return None
|
||||
|
||||
try:
|
||||
return self.torrent_handle.status()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
def _get_torrent_info_if_available(self, status):
|
||||
if not status.has_metadata:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self.torrent_handle.get_torrent_info()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
def _get_file_size(self, status, info):
|
||||
total_wanted = getattr(status, "total_wanted", 0)
|
||||
if total_wanted > 0:
|
||||
return total_wanted
|
||||
|
||||
if self.selected_size_bytes is not None:
|
||||
return self.selected_size_bytes
|
||||
|
||||
if info:
|
||||
return info.total_size()
|
||||
|
||||
return 0
|
||||
|
||||
def _get_bytes_downloaded(self, status, file_size):
|
||||
total_wanted_done = getattr(status, "total_wanted_done", -1)
|
||||
if total_wanted_done >= 0:
|
||||
return total_wanted_done
|
||||
|
||||
if file_size > 0:
|
||||
return int(status.progress * file_size)
|
||||
|
||||
return status.all_time_download
|
||||
|
||||
def _get_progress(self, status, file_size, bytes_downloaded):
|
||||
if file_size <= 0:
|
||||
return status.progress
|
||||
|
||||
return min(max(bytes_downloaded / file_size, 0), 1)
|
||||
|
||||
def get_download_status(self):
|
||||
status = self._get_handle_status()
|
||||
if status is None:
|
||||
return None
|
||||
|
||||
info = self._get_torrent_info_if_available(status)
|
||||
file_size = self._get_file_size(status, info)
|
||||
bytes_downloaded = self._get_bytes_downloaded(status, file_size)
|
||||
progress = self._get_progress(status, file_size, bytes_downloaded)
|
||||
|
||||
response = {
|
||||
'folderName': info.name() if info else "",
|
||||
'fileSize': file_size,
|
||||
'progress': progress,
|
||||
'downloadSpeed': status.download_rate,
|
||||
'uploadSpeed': status.upload_rate,
|
||||
'numPeers': status.num_peers,
|
||||
'numSeeds': status.num_seeds,
|
||||
'status': status.state,
|
||||
'bytesDownloaded': bytes_downloaded,
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -1,8 +0,0 @@
|
||||
libtorrent
|
||||
cx_Freeze == 7.2.3
|
||||
cx_Logging; sys_platform == 'win32'
|
||||
pywin32; sys_platform == 'win32'
|
||||
psutil
|
||||
Pillow
|
||||
flask
|
||||
aria2p
|
||||
@@ -0,0 +1,117 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const util = require("node:util");
|
||||
const childProcess = require("node:child_process");
|
||||
|
||||
const execFile = util.promisify(childProcess.execFile);
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const manifestPath = path.join(
|
||||
projectRoot,
|
||||
"native",
|
||||
"hydra-native",
|
||||
"Cargo.toml"
|
||||
);
|
||||
const cargoTargetDir = path.join(
|
||||
projectRoot,
|
||||
"native",
|
||||
"hydra-native",
|
||||
"target"
|
||||
);
|
||||
const outputDir = path.join(projectRoot, "hydra-native");
|
||||
const outputNodePath = path.join(outputDir, "hydra-native.node");
|
||||
|
||||
const sourceLibraryNameByPlatform = {
|
||||
linux: "libhydra_native.so",
|
||||
darwin: "libhydra_native.dylib",
|
||||
win32: "hydra_native.dll",
|
||||
};
|
||||
|
||||
const run = async (command, args, options = {}) => {
|
||||
await execFile(command, args, {
|
||||
cwd: projectRoot,
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
const ensureDepsResolvableOnLinux = async () => {
|
||||
if (process.platform !== "linux") return;
|
||||
|
||||
const { stdout } = await execFile("ldd", [outputNodePath], {
|
||||
cwd: projectRoot,
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
});
|
||||
|
||||
if (stdout.includes("not found")) {
|
||||
throw new Error(
|
||||
`Unresolved dynamic dependencies found for ${outputNodePath}\n${stdout}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const copySidecarLibrariesOnWindows = async (sourceDirectory) => {
|
||||
if (process.platform !== "win32") return;
|
||||
|
||||
const candidateDlls = [
|
||||
"libgcc_s_seh-1.dll",
|
||||
"libstdc++-6.dll",
|
||||
"libwinpthread-1.dll",
|
||||
"vcruntime140.dll",
|
||||
"vcruntime140_1.dll",
|
||||
"msvcp140.dll",
|
||||
];
|
||||
|
||||
for (const dll of candidateDlls) {
|
||||
const sourcePath = path.join(sourceDirectory, dll);
|
||||
if (!fs.existsSync(sourcePath)) continue;
|
||||
const targetPath = path.join(outputDir, dll);
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const build = async () => {
|
||||
const sourceLibraryName = sourceLibraryNameByPlatform[process.platform];
|
||||
|
||||
if (!sourceLibraryName) {
|
||||
throw new Error(
|
||||
`Unsupported platform for native build: ${process.platform}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Building hydra-native Rust addon...");
|
||||
|
||||
await run("cargo", [
|
||||
"build",
|
||||
"--release",
|
||||
"--manifest-path",
|
||||
manifestPath,
|
||||
"--target-dir",
|
||||
cargoTargetDir,
|
||||
]);
|
||||
|
||||
const sourceLibraryPath = path.join(
|
||||
cargoTargetDir,
|
||||
"release",
|
||||
sourceLibraryName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(sourceLibraryPath)) {
|
||||
throw new Error(`Native build output not found at ${sourceLibraryPath}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.copyFileSync(sourceLibraryPath, outputNodePath);
|
||||
|
||||
await copySidecarLibrariesOnWindows(path.dirname(sourceLibraryPath));
|
||||
await ensureDepsResolvableOnLinux();
|
||||
|
||||
console.log(`Hydra native addon ready at ${outputNodePath}`);
|
||||
};
|
||||
|
||||
build().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -2,11 +2,10 @@ import { registerEvent } from "../register-event";
|
||||
import { logger, Wine } from "@main/services";
|
||||
import sudo from "sudo-prompt";
|
||||
import { app } from "electron";
|
||||
import { PythonRPC } from "@main/services/python-rpc";
|
||||
import { ProcessPayload } from "@main/services/download/types";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import { GameShop } from "@types";
|
||||
import path from "node:path";
|
||||
import { NativeAddon } from "@main/services/native-addon";
|
||||
|
||||
const getKillCommand = (pid: number) => {
|
||||
if (process.platform == "win32") {
|
||||
@@ -21,9 +20,7 @@ const closeGame = async (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => {
|
||||
const processes =
|
||||
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
|
||||
[];
|
||||
const processes = NativeAddon.listProcesses();
|
||||
|
||||
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { PythonRPC } from "@main/services/python-rpc";
|
||||
import { NativeAddon } from "@main/services/native-addon";
|
||||
|
||||
const processProfileImageEvent = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -9,12 +9,7 @@ const processProfileImageEvent = async (
|
||||
};
|
||||
|
||||
export const processProfileImage = async (path: string, extension?: string) => {
|
||||
return PythonRPC.rpc
|
||||
.post<{
|
||||
imagePath: string;
|
||||
mimeType: string;
|
||||
}>("/profile-image", { image_path: path, target_extension: extension })
|
||||
.then((response) => response.data);
|
||||
return NativeAddon.processProfileImage(path, extension);
|
||||
};
|
||||
|
||||
registerEvent("processProfileImage", processProfileImageEvent);
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { PythonRPC } from "@main/services/python-rpc";
|
||||
import { NativeAddon } from "@main/services/native-addon";
|
||||
import type { TorrentFilesResponse } from "@types";
|
||||
import { AxiosError } from "axios";
|
||||
import { DownloadError } from "@shared";
|
||||
|
||||
const mapTorrentFilesError = (error: unknown) => {
|
||||
if (error instanceof AxiosError) {
|
||||
const rpcError = (error.response?.data as { error?: string } | undefined)
|
||||
?.error;
|
||||
|
||||
switch (rpcError) {
|
||||
if (error instanceof Error) {
|
||||
switch (error.message) {
|
||||
case "invalid_magnet":
|
||||
return DownloadError.InvalidMagnet;
|
||||
case "metadata_timeout":
|
||||
@@ -18,17 +14,11 @@ const mapTorrentFilesError = (error: unknown) => {
|
||||
return DownloadError.TorrentMetadataIncomplete;
|
||||
case "too_many_files":
|
||||
return DownloadError.TorrentTooManyFiles;
|
||||
case "metadata_busy":
|
||||
return DownloadError.TorrentMetadataTimeout;
|
||||
default:
|
||||
return DownloadError.TorrentFilesUnavailable;
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return DownloadError.TorrentFilesUnavailable;
|
||||
};
|
||||
|
||||
@@ -41,21 +31,11 @@ const getTorrentFiles = async (
|
||||
}
|
||||
|
||||
try {
|
||||
await PythonRPC.ensureReady();
|
||||
|
||||
const response = await PythonRPC.rpc.post<TorrentFilesResponse>(
|
||||
"/torrent-files",
|
||||
{
|
||||
magnet,
|
||||
},
|
||||
{
|
||||
timeout: 45000,
|
||||
}
|
||||
);
|
||||
const response = NativeAddon.getTorrentFiles(magnet, 45_000);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: response.data,
|
||||
data: response as TorrentFilesResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -114,6 +114,34 @@ const handleHostSpecificError = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const mapTorrentErrorCode = (code: string): DownloadErrorResult | null => {
|
||||
if (code === "invalid_magnet") {
|
||||
return { ok: false, error: DownloadError.InvalidMagnet };
|
||||
}
|
||||
|
||||
if (code === "metadata_timeout") {
|
||||
return { ok: false, error: DownloadError.TorrentMetadataTimeout };
|
||||
}
|
||||
|
||||
if (code === "metadata_incomplete") {
|
||||
return { ok: false, error: DownloadError.TorrentMetadataIncomplete };
|
||||
}
|
||||
|
||||
if (code === "empty_selection") {
|
||||
return { ok: false, error: DownloadError.TorrentNoFilesSelected };
|
||||
}
|
||||
|
||||
if (code === "invalid_file_indices") {
|
||||
return { ok: false, error: DownloadError.TorrentInvalidFileSelection };
|
||||
}
|
||||
|
||||
if (code === "too_many_files") {
|
||||
return { ok: false, error: DownloadError.TorrentTooManyFiles };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const handleDownloadError = (
|
||||
err: unknown,
|
||||
downloader: Downloader
|
||||
@@ -124,6 +152,11 @@ export const handleDownloadError = (
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (downloader === Downloader.Torrent) {
|
||||
const mapped = mapTorrentErrorCode(err.message);
|
||||
if (mapped) return mapped;
|
||||
}
|
||||
|
||||
const hostResult = handleHostSpecificError(err.message, downloader);
|
||||
if (hostResult) return hostResult;
|
||||
|
||||
|
||||
@@ -9,10 +9,9 @@ import {
|
||||
Umu,
|
||||
PowerSaveBlockerManager,
|
||||
Wine,
|
||||
NativeAddon,
|
||||
} from "@main/services";
|
||||
import { CommonRedistManager } from "@main/services/common-redist-manager";
|
||||
import { ProcessPayload } from "@main/services/download/types";
|
||||
import { PythonRPC } from "@main/services/python-rpc";
|
||||
import { parseExecutablePath } from "../events/helpers/parse-executable-path";
|
||||
import { isGamemodeAvailable } from "./is-gamemode-available";
|
||||
import { isMangohudAvailable } from "./is-mangohud-available";
|
||||
@@ -147,9 +146,7 @@ const cleanupStaleCompatibilityProcesses = async (
|
||||
const defaultPrefixPath = Wine.getDefaultPrefixPathForGame(objectId);
|
||||
if (defaultPrefixPath !== winePrefixPath) return;
|
||||
|
||||
const processes =
|
||||
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
|
||||
[];
|
||||
const processes = NativeAddon.listProcesses();
|
||||
|
||||
const stalePids = processes
|
||||
.filter((runningProcess) => {
|
||||
|
||||
@@ -9,11 +9,9 @@ import {
|
||||
clearGamesPlaytime,
|
||||
WindowManager,
|
||||
Lock,
|
||||
Aria2,
|
||||
PowerSaveBlockerManager,
|
||||
} from "@main/services";
|
||||
import resources from "@locales";
|
||||
import { PythonRPC } from "./services/python-rpc";
|
||||
import { db, gamesSublevel, levelKeys } from "./level";
|
||||
import { GameShop, UserPreferences } from "@types";
|
||||
import { launchGame } from "./helpers";
|
||||
@@ -281,9 +279,6 @@ app.on("before-quit", async (e) => {
|
||||
if (!canAppBeClosed) {
|
||||
e.preventDefault();
|
||||
PowerSaveBlockerManager.reset();
|
||||
/* Disconnects libtorrent */
|
||||
PythonRPC.kill();
|
||||
Aria2.kill();
|
||||
await clearGamesPlaytime();
|
||||
canAppBeClosed = true;
|
||||
app.quit();
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
RealDebridClient,
|
||||
PremiumizeClient,
|
||||
AllDebridClient,
|
||||
Aria2,
|
||||
DownloadManager,
|
||||
HydraApi,
|
||||
uploadGamesBatch,
|
||||
@@ -36,8 +35,6 @@ export const loadState = async () => {
|
||||
|
||||
await import("./events");
|
||||
|
||||
Aria2.spawn();
|
||||
|
||||
if (userPreferences?.realDebridApiToken) {
|
||||
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import { app } from "electron";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export class Aria2 {
|
||||
private static process: cp.ChildProcess | null = null;
|
||||
|
||||
public static spawn() {
|
||||
const binaryPath =
|
||||
process.platform === "darwin"
|
||||
? "aria2c"
|
||||
: app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2c")
|
||||
: path.join(__dirname, "..", "..", "binaries", "aria2c");
|
||||
|
||||
this.process = cp.spawn(
|
||||
binaryPath,
|
||||
[
|
||||
"--enable-rpc",
|
||||
"--rpc-listen-all",
|
||||
"--file-allocation=none",
|
||||
"--allow-overwrite=true",
|
||||
],
|
||||
{ stdio: "inherit", windowsHide: true }
|
||||
);
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
if (this.process) {
|
||||
logger.log("Killing aria2 process");
|
||||
this.process.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,8 @@ import {
|
||||
VikingFileApi,
|
||||
RootzApi,
|
||||
} from "../hosters";
|
||||
import { PythonRPC } from "../python-rpc";
|
||||
import {
|
||||
LibtorrentPayload,
|
||||
LibtorrentStatus,
|
||||
PauseDownloadPayload,
|
||||
} from "./types";
|
||||
import { NativeAddon } from "../native-addon";
|
||||
import { LibtorrentStatus } from "./types";
|
||||
import { calculateETA, getDirSize } from "./helpers";
|
||||
import { RealDebridClient } from "./real-debrid";
|
||||
import path from "node:path";
|
||||
@@ -167,7 +163,7 @@ export class DownloadManager {
|
||||
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
|
||||
} else {
|
||||
logger.log(
|
||||
`[DownloadManager] No filename extracted, aria2 will use default`
|
||||
`[DownloadManager] No filename extracted, downloader will use default`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,12 +178,7 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
private static async shouldUseJsDownloader(): Promise<boolean> {
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
// Default to true - native HTTP downloader is enabled by default (opt-out)
|
||||
return userPreferences?.useNativeHttpDownloader ?? true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static isHttpDownloader(downloader: Downloader): boolean {
|
||||
@@ -226,40 +217,32 @@ export class DownloadManager {
|
||||
this.maxDownloadSpeedBytesPerSecond = normalizedLimit;
|
||||
this.jsDownloader?.setMaxDownloadSpeedBytesPerSecond(normalizedLimit);
|
||||
|
||||
await PythonRPC.rpc
|
||||
.post("/action", {
|
||||
action: "set_download_limit",
|
||||
max_download_speed_bytes_per_second: normalizedLimit,
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
"[DownloadManager] Failed to update RPC download speed limit:",
|
||||
error
|
||||
);
|
||||
});
|
||||
try {
|
||||
NativeAddon.setTorrentDownloadLimit(normalizedLimit);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[DownloadManager] Failed to update torrent speed limit:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static async startRPC(
|
||||
download?: Download,
|
||||
downloadsToSeed?: Download[]
|
||||
) {
|
||||
await PythonRPC.spawn(
|
||||
download?.status === "active"
|
||||
? await this.getDownloadPayload(download).catch((err) => {
|
||||
logger.error("Error getting download payload", err);
|
||||
return undefined;
|
||||
})
|
||||
: undefined,
|
||||
downloadsToSeed?.map((download) => ({
|
||||
action: "seed",
|
||||
game_id: levelKeys.game(download.shop, download.objectId),
|
||||
url: download.uri,
|
||||
save_path: download.downloadPath,
|
||||
}))
|
||||
);
|
||||
if (downloadsToSeed?.length) {
|
||||
for (const seedDownload of downloadsToSeed) {
|
||||
await this.resumeSeeding(seedDownload).catch((error) => {
|
||||
logger.error("[DownloadManager] Failed to resume seeding", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (download) {
|
||||
this.downloadingGameId = levelKeys.game(download.shop, download.objectId);
|
||||
if (download?.status === "active") {
|
||||
await this.startDownload(download).catch((error) => {
|
||||
logger.error("[DownloadManager] Failed to resume download", error);
|
||||
});
|
||||
}
|
||||
|
||||
await this.applyDownloadSpeedLimit();
|
||||
@@ -398,10 +381,8 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> {
|
||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
||||
"/status"
|
||||
);
|
||||
if (response.data === null || !this.downloadingGameId) return null;
|
||||
const response = NativeAddon.getTorrentStatus();
|
||||
if (response === null || !this.downloadingGameId) return null;
|
||||
const downloadId = this.downloadingGameId;
|
||||
|
||||
try {
|
||||
@@ -414,7 +395,7 @@ export class DownloadManager {
|
||||
fileSize,
|
||||
folderName,
|
||||
status,
|
||||
} = response.data;
|
||||
} = response;
|
||||
|
||||
const isDownloadingMetadata =
|
||||
status === LibtorrentStatus.DownloadingMetadata;
|
||||
@@ -685,9 +666,7 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
public static async getSeedStatus() {
|
||||
const seedStatus = await PythonRPC.rpc
|
||||
.get<LibtorrentPayload[] | []>("/seed-status")
|
||||
.then((res) => res.data);
|
||||
const seedStatus = NativeAddon.getTorrentSeedStatus();
|
||||
|
||||
if (!seedStatus.length) return;
|
||||
|
||||
@@ -723,13 +702,12 @@ export class DownloadManager {
|
||||
if (this.usingJsDownloader && this.jsDownloader) {
|
||||
logger.log("[DownloadManager] Pausing JS download");
|
||||
this.jsDownloader.pauseDownload();
|
||||
} else {
|
||||
await PythonRPC.rpc
|
||||
.post("/action", {
|
||||
action: "pause",
|
||||
game_id: downloadKey,
|
||||
} as PauseDownloadPayload)
|
||||
.catch(() => {});
|
||||
} else if (downloadKey) {
|
||||
try {
|
||||
NativeAddon.pauseTorrentDownload(downloadKey);
|
||||
} catch {
|
||||
// ignore pause failures
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadKey === this.downloadingGameId) {
|
||||
@@ -753,9 +731,11 @@ export class DownloadManager {
|
||||
this.usingJsDownloader = false;
|
||||
this.allDebridBatch = null;
|
||||
} else if (!this.isPreparingDownload) {
|
||||
await PythonRPC.rpc
|
||||
.post("/action", { action: "cancel", game_id: downloadKey })
|
||||
.catch((err) => logger.error("Failed to cancel game download", err));
|
||||
try {
|
||||
NativeAddon.cancelTorrentDownload(downloadKey!);
|
||||
} catch (err) {
|
||||
logger.error("Failed to cancel game download", err);
|
||||
}
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
@@ -768,19 +748,15 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
static async resumeSeeding(download: Download) {
|
||||
await PythonRPC.rpc.post("/action", {
|
||||
action: "resume_seeding",
|
||||
game_id: levelKeys.game(download.shop, download.objectId),
|
||||
NativeAddon.resumeTorrentSeeding({
|
||||
gameId: levelKeys.game(download.shop, download.objectId),
|
||||
url: download.uri,
|
||||
save_path: download.downloadPath,
|
||||
savePath: download.downloadPath,
|
||||
});
|
||||
}
|
||||
|
||||
static async pauseSeeding(downloadKey: string) {
|
||||
await PythonRPC.rpc.post("/action", {
|
||||
action: "pause_seeding",
|
||||
game_id: downloadKey,
|
||||
});
|
||||
NativeAddon.pauseTorrentSeeding(downloadKey);
|
||||
}
|
||||
|
||||
private static async getJsDownloadOptions(download: Download): Promise<{
|
||||
@@ -1442,18 +1418,18 @@ export class DownloadManager {
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
logger.log("[DownloadManager] Using Python RPC downloader");
|
||||
const payload = await this.getDownloadPayload(download);
|
||||
logger.log("[DownloadManager] Using native torrent downloader");
|
||||
const isSelectiveTorrentStart =
|
||||
download.downloader === Downloader.Torrent &&
|
||||
Array.isArray(download.fileIndices) &&
|
||||
download.fileIndices.length > 0;
|
||||
|
||||
if (payload?.url) {
|
||||
this.logResolvedUrl(payload.url);
|
||||
}
|
||||
await PythonRPC.rpc.post("/action", payload, {
|
||||
timeout: isSelectiveTorrentStart ? 60_000 : 10_000,
|
||||
NativeAddon.startTorrentDownload({
|
||||
gameId: downloadId,
|
||||
url: download.uri,
|
||||
savePath: download.downloadPath,
|
||||
fileIndices: download.fileIndices,
|
||||
timeoutMs: isSelectiveTorrentStart ? 60_000 : 10_000,
|
||||
});
|
||||
this.downloadingGameId = downloadId;
|
||||
this.usingJsDownloader = false;
|
||||
|
||||
@@ -12,7 +12,6 @@ export * from "./7zip";
|
||||
export * from "./game-files-manager";
|
||||
export * from "./game-executables";
|
||||
export * from "./common-redist-manager";
|
||||
export * from "./aria2";
|
||||
export * from "./ws";
|
||||
export * from "./system-path";
|
||||
export * from "./library-sync";
|
||||
@@ -24,3 +23,4 @@ export * from "./user";
|
||||
export * from "./download-sources-checker";
|
||||
export * from "./notifications/local-notifications";
|
||||
export * from "./power-save-blocker";
|
||||
export * from "./native-addon";
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { app } from "electron";
|
||||
import type { ProcessPayload, LibtorrentPayload } from "./download/types";
|
||||
import type { TorrentFilesResponse } from "@types";
|
||||
|
||||
import { logger } from "./logger";
|
||||
|
||||
type NativeProcessProfileImageResponse = {
|
||||
imagePath?: string;
|
||||
image_path?: string;
|
||||
mimeType?: string;
|
||||
mime_type?: string;
|
||||
};
|
||||
|
||||
type HydraNativeModule = {
|
||||
processProfileImage: (
|
||||
imagePath: string,
|
||||
targetExtension?: string
|
||||
) => NativeProcessProfileImageResponse;
|
||||
listProcesses: () => ProcessPayload[];
|
||||
torrentGetStatus: () => LibtorrentPayload | null;
|
||||
torrentGetSeedStatus: () => Array<LibtorrentPayload & { gameId: string }>;
|
||||
torrentGetFiles: (magnet: string, timeoutMs?: number) => TorrentFilesResponse;
|
||||
torrentStart: (payload: {
|
||||
gameId: string;
|
||||
url: string;
|
||||
savePath: string;
|
||||
fileIndices?: number[];
|
||||
timeoutMs?: number;
|
||||
}) => void;
|
||||
torrentPause: (gameId: string) => void;
|
||||
torrentCancel: (gameId: string) => void;
|
||||
torrentResumeSeeding: (payload: {
|
||||
gameId: string;
|
||||
url: string;
|
||||
savePath: string;
|
||||
}) => void;
|
||||
torrentPauseSeeding: (gameId: string) => void;
|
||||
torrentSetDownloadLimit: (
|
||||
maxDownloadSpeedBytesPerSecond?: number | null
|
||||
) => void;
|
||||
};
|
||||
|
||||
export class NativeAddon {
|
||||
private static nativeModule: HydraNativeModule | null = null;
|
||||
|
||||
private static resolveAddonPath() {
|
||||
if (app.isPackaged) {
|
||||
return path.join(
|
||||
process.resourcesPath,
|
||||
"hydra-native",
|
||||
"hydra-native.node"
|
||||
);
|
||||
}
|
||||
|
||||
return path.join(app.getAppPath(), "hydra-native", "hydra-native.node");
|
||||
}
|
||||
|
||||
private static load() {
|
||||
if (this.nativeModule) return this.nativeModule;
|
||||
|
||||
const addonPath = this.resolveAddonPath();
|
||||
|
||||
if (!fs.existsSync(addonPath)) {
|
||||
throw new Error(`Hydra native addon not found at ${addonPath}`);
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const nativeModule = require(addonPath) as HydraNativeModule;
|
||||
this.nativeModule = nativeModule;
|
||||
|
||||
return nativeModule;
|
||||
}
|
||||
|
||||
public static processProfileImage(
|
||||
imagePath: string,
|
||||
targetExtension = "webp"
|
||||
) {
|
||||
try {
|
||||
const response = this.load().processProfileImage(
|
||||
imagePath,
|
||||
targetExtension
|
||||
);
|
||||
|
||||
const normalizedImagePath = response.imagePath ?? response.image_path;
|
||||
const normalizedMimeType = response.mimeType ?? response.mime_type;
|
||||
|
||||
if (!normalizedImagePath || !normalizedMimeType) {
|
||||
throw new Error("Hydra native addon returned an invalid payload");
|
||||
}
|
||||
|
||||
return {
|
||||
imagePath: normalizedImagePath,
|
||||
mimeType: normalizedMimeType,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to process profile image via native addon", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static listProcesses(): ProcessPayload[] {
|
||||
try {
|
||||
const response = this.load().listProcesses();
|
||||
|
||||
if (!Array.isArray(response)) {
|
||||
throw new Error("Hydra native addon returned an invalid process list");
|
||||
}
|
||||
|
||||
return response.filter((process): process is ProcessPayload => {
|
||||
return (
|
||||
typeof process?.pid === "number" &&
|
||||
typeof process?.name === "string" &&
|
||||
process.name.length > 0
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to list processes via native addon", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static getTorrentStatus(): LibtorrentPayload | null {
|
||||
return this.load().torrentGetStatus();
|
||||
}
|
||||
|
||||
public static getTorrentSeedStatus(): Array<
|
||||
LibtorrentPayload & { gameId: string }
|
||||
> {
|
||||
return this.load().torrentGetSeedStatus();
|
||||
}
|
||||
|
||||
public static getTorrentFiles(
|
||||
magnet: string,
|
||||
timeoutMs?: number
|
||||
): TorrentFilesResponse {
|
||||
return this.load().torrentGetFiles(magnet, timeoutMs);
|
||||
}
|
||||
|
||||
public static startTorrentDownload(payload: {
|
||||
gameId: string;
|
||||
url: string;
|
||||
savePath: string;
|
||||
fileIndices?: number[];
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
this.load().torrentStart(payload);
|
||||
}
|
||||
|
||||
public static pauseTorrentDownload(gameId: string) {
|
||||
this.load().torrentPause(gameId);
|
||||
}
|
||||
|
||||
public static cancelTorrentDownload(gameId: string) {
|
||||
this.load().torrentCancel(gameId);
|
||||
}
|
||||
|
||||
public static resumeTorrentSeeding(payload: {
|
||||
gameId: string;
|
||||
url: string;
|
||||
savePath: string;
|
||||
}) {
|
||||
this.load().torrentResumeSeeding(payload);
|
||||
}
|
||||
|
||||
public static pauseTorrentSeeding(gameId: string) {
|
||||
this.load().torrentPauseSeeding(gameId);
|
||||
}
|
||||
|
||||
public static setTorrentDownloadLimit(
|
||||
maxDownloadSpeedBytesPerSecond?: number | null
|
||||
) {
|
||||
this.load().torrentSetDownloadLimit(maxDownloadSpeedBytesPerSecond);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { createGame, trackGamePlaytime } from "./library-sync";
|
||||
import type { Game, GameRunning, UserPreferences } from "@types";
|
||||
import { PythonRPC } from "./python-rpc";
|
||||
import axios from "axios";
|
||||
import { ProcessPayload } from "./download/types";
|
||||
import { db, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { CloudSync } from "./cloud-sync";
|
||||
import { logger } from "./logger";
|
||||
import { PowerSaveBlockerManager } from "./power-save-blocker";
|
||||
import path from "path";
|
||||
import path from "node:path";
|
||||
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
|
||||
import { MAIN_LOOP_INTERVAL } from "@main/constants";
|
||||
import { Wine } from "./wine";
|
||||
import { NativeAddon } from "./native-addon";
|
||||
|
||||
export const gamesPlaytime = new Map<
|
||||
string,
|
||||
@@ -123,9 +122,7 @@ const findGamePathByProcess = async (
|
||||
};
|
||||
|
||||
const getSystemProcessMap = async () => {
|
||||
const processes =
|
||||
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
|
||||
[];
|
||||
const processes = NativeAddon.listProcesses();
|
||||
|
||||
const processMap = new Map<string, Set<string>>();
|
||||
const winePrefixMap = new Map<string, string>();
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import axios from "axios";
|
||||
import http from "node:http";
|
||||
|
||||
import cp from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { pythonRpcLogger } from "./logger";
|
||||
import { Readable } from "node:stream";
|
||||
import { app, dialog } from "electron";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
|
||||
interface GamePayload {
|
||||
action: string;
|
||||
game_id: string;
|
||||
url: string | string[];
|
||||
save_path: string;
|
||||
header?: string;
|
||||
out?: string;
|
||||
total_size?: number;
|
||||
file_indices?: number[];
|
||||
}
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "hydra-python-rpc",
|
||||
linux: "hydra-python-rpc",
|
||||
win32: "hydra-python-rpc.exe",
|
||||
};
|
||||
|
||||
const RPC_PORT_RANGE_START = 8080;
|
||||
const RPC_PORT_RANGE_END = 9000;
|
||||
const DEFAULT_RPC_PORT = 8087;
|
||||
|
||||
export class PythonRPC {
|
||||
public static readonly BITTORRENT_PORT = "5881";
|
||||
public static readonly rpc = axios.create({
|
||||
baseURL: `http://localhost:${DEFAULT_RPC_PORT}`,
|
||||
timeout: 10000,
|
||||
httpAgent: new http.Agent({
|
||||
family: 4, // Force IPv4
|
||||
}),
|
||||
});
|
||||
|
||||
private static pythonProcess: cp.ChildProcess | null = null;
|
||||
|
||||
private static logStderr(readable: Readable | null) {
|
||||
if (!readable) return;
|
||||
|
||||
readable.setEncoding("utf-8");
|
||||
readable.on("data", pythonRpcLogger.log);
|
||||
}
|
||||
|
||||
private static async getRPCPassword() {
|
||||
const existingPassword = await db.get(levelKeys.rpcPassword, {
|
||||
valueEncoding: "utf8",
|
||||
});
|
||||
|
||||
if (existingPassword) return existingPassword;
|
||||
|
||||
const newPassword = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
await db.put(levelKeys.rpcPassword, newPassword, {
|
||||
valueEncoding: "utf8",
|
||||
});
|
||||
|
||||
return newPassword;
|
||||
}
|
||||
|
||||
private static async isPortAvailable(port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
|
||||
server.on("error", () => {
|
||||
server.close(() => resolve(false));
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static async findAvailablePort() {
|
||||
const scannedPorts = new Set<number>();
|
||||
const enqueuePort = (port: number) => {
|
||||
if (port < RPC_PORT_RANGE_START || port > RPC_PORT_RANGE_END) return;
|
||||
if (!scannedPorts.has(port)) scannedPorts.add(port);
|
||||
};
|
||||
|
||||
enqueuePort(DEFAULT_RPC_PORT);
|
||||
for (let port = RPC_PORT_RANGE_START; port <= RPC_PORT_RANGE_END; port++) {
|
||||
enqueuePort(port);
|
||||
}
|
||||
|
||||
for (const port of scannedPorts) {
|
||||
if (await this.isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No available RPC port found");
|
||||
}
|
||||
|
||||
private static updateRpcPort(port: number) {
|
||||
this.rpc.defaults.baseURL = `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
public static async ensureReady(
|
||||
retries = 20,
|
||||
delayMs = 250,
|
||||
timeoutMs = 2000
|
||||
): Promise<void> {
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
await this.rpc.get("/healthcheck", { timeout: timeoutMs });
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < retries - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error("Python RPC healthcheck failed");
|
||||
}
|
||||
|
||||
public static async spawn(
|
||||
initialDownload?: GamePayload,
|
||||
initialSeeding?: GamePayload[]
|
||||
) {
|
||||
const rpcPassword = await this.getRPCPassword();
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
port = await this.findAvailablePort();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: "Unknown error while selecting RPC port";
|
||||
dialog.showErrorBox(
|
||||
"RPC Error",
|
||||
`Failed to select an available port for the download service.\n\n${message}`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.updateRpcPort(port);
|
||||
pythonRpcLogger.log(`Using RPC port: ${port}`);
|
||||
|
||||
const commonArgs = [
|
||||
this.BITTORRENT_PORT,
|
||||
String(port),
|
||||
rpcPassword,
|
||||
initialDownload ? JSON.stringify(initialDownload) : "",
|
||||
initialSeeding ? JSON.stringify(initialSeeding) : "",
|
||||
];
|
||||
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
||||
const binaryPath = path.join(
|
||||
process.resourcesPath,
|
||||
"hydra-python-rpc",
|
||||
binaryName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
dialog.showErrorBox(
|
||||
"Fatal",
|
||||
"Hydra Python Instance binary not found. Please check if it has been removed by Windows Defender."
|
||||
);
|
||||
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
||||
windowsHide: true,
|
||||
stdio: ["inherit", "inherit"],
|
||||
});
|
||||
|
||||
this.logStderr(childProcess.stderr);
|
||||
|
||||
this.pythonProcess = childProcess;
|
||||
} else {
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"python_rpc",
|
||||
"main.py"
|
||||
);
|
||||
|
||||
const childProcess = cp.spawn("python", [scriptPath, ...commonArgs], {
|
||||
stdio: ["inherit", "inherit"],
|
||||
});
|
||||
|
||||
this.logStderr(childProcess.stderr);
|
||||
|
||||
this.pythonProcess = childProcess;
|
||||
}
|
||||
|
||||
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
|
||||
|
||||
await this.ensureReady();
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
if (this.pythonProcess) {
|
||||
pythonRpcLogger.log("Killing python process");
|
||||
this.pythonProcess.kill();
|
||||
this.pythonProcess = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
@@ -24,6 +31,7 @@ import cn from "classnames";
|
||||
import { SearchDropdown } from "@renderer/components";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import type { GameShop } from "@types";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
const pathTitle: Record<string, string> = {
|
||||
"/": "home",
|
||||
@@ -61,8 +69,27 @@ export function Header() {
|
||||
? librarySearchValue
|
||||
: catalogueSearchValue;
|
||||
|
||||
const [localSearchValue, setLocalSearchValue] = useState(searchValue);
|
||||
const deferredSearchValue = useDeferredValue(localSearchValue);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const debouncedLibrarySearch = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
dispatch(setLibrarySearchQuery(value));
|
||||
}, 180),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const debouncedCatalogueSearch = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
dispatch(setFilters({ title: value }));
|
||||
}, 250),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
@@ -83,7 +110,7 @@ export function Header() {
|
||||
useSearchHistory();
|
||||
|
||||
const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions(
|
||||
searchValue,
|
||||
deferredSearchValue,
|
||||
isOnLibraryPage,
|
||||
isDropdownVisible && isFocused && !isOnCataloguePage
|
||||
);
|
||||
@@ -107,6 +134,17 @@ export function Header() {
|
||||
|
||||
const totalItems = historyItems.length + suggestions.length;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSearchValue(searchValue);
|
||||
}, [searchValue, isOnLibraryPage, isOnCataloguePage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedLibrarySearch.cancel();
|
||||
debouncedCatalogueSearch.cancel();
|
||||
};
|
||||
}, [debouncedCatalogueSearch, debouncedLibrarySearch]);
|
||||
|
||||
const updateDropdownPosition = () => {
|
||||
if (searchContainerRef.current) {
|
||||
const rect = searchContainerRef.current.getBoundingClientRect();
|
||||
@@ -149,6 +187,9 @@ export function Header() {
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
debouncedLibrarySearch.cancel();
|
||||
debouncedCatalogueSearch.cancel();
|
||||
|
||||
if (isOnLibraryPage) {
|
||||
dispatch(setLibrarySearchQuery(value.slice(0, 255)));
|
||||
} else {
|
||||
@@ -157,7 +198,23 @@ export function Header() {
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
const normalizedValue = value.slice(0, 255);
|
||||
|
||||
setLocalSearchValue(normalizedValue);
|
||||
setActiveIndex(-1);
|
||||
|
||||
if (isOnLibraryPage) {
|
||||
debouncedCatalogueSearch.cancel();
|
||||
debouncedLibrarySearch(normalizedValue);
|
||||
} else {
|
||||
debouncedLibrarySearch.cancel();
|
||||
debouncedCatalogueSearch(normalizedValue);
|
||||
}
|
||||
};
|
||||
|
||||
const executeSearch = (query: string) => {
|
||||
setLocalSearchValue(query.slice(0, 255));
|
||||
const context = isOnLibraryPage ? "library" : "catalogue";
|
||||
if (query.trim()) {
|
||||
addToHistory(query, context);
|
||||
@@ -187,6 +244,11 @@ export function Header() {
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
debouncedLibrarySearch.cancel();
|
||||
debouncedCatalogueSearch.cancel();
|
||||
|
||||
setLocalSearchValue("");
|
||||
|
||||
if (isOnLibraryPage) {
|
||||
dispatch(setLibrarySearchQuery(""));
|
||||
} else {
|
||||
@@ -219,8 +281,8 @@ export function Header() {
|
||||
const suggestionIndex = activeIndex - historyItems.length;
|
||||
handleSelectSuggestion(suggestions[suggestionIndex]);
|
||||
}
|
||||
} else if (searchValue.trim()) {
|
||||
executeSearch(searchValue);
|
||||
} else if (localSearchValue.trim()) {
|
||||
executeSearch(localSearchValue);
|
||||
}
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
@@ -347,15 +409,15 @@ export function Header() {
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder={isOnLibraryPage ? t("search_library") : t("search")}
|
||||
value={searchValue}
|
||||
value={localSearchValue}
|
||||
className="header__search-input"
|
||||
onChange={(event) => handleSearch(event.target.value)}
|
||||
onChange={(event) => handleInputChange(event.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
{searchValue && (
|
||||
{localSearchValue && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={handleClearSearchMouseDown}
|
||||
@@ -378,7 +440,7 @@ export function Header() {
|
||||
<SearchDropdown
|
||||
visible={
|
||||
isDropdownVisible &&
|
||||
(searchValue.trim().length > 0 ||
|
||||
(localSearchValue.trim().length > 0 ||
|
||||
historyItems.length > 0 ||
|
||||
suggestions.length > 0 ||
|
||||
isLoadingSuggestions)
|
||||
@@ -393,7 +455,7 @@ export function Header() {
|
||||
onClearHistory={handleClearHistory}
|
||||
onClose={handleCloseDropdown}
|
||||
activeIndex={activeIndex}
|
||||
currentQuery={searchValue}
|
||||
currentQuery={deferredSearchValue}
|
||||
searchContainerRef={searchContainerRef}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useAppSelector } from "./redux";
|
||||
import { debounce } from "lodash-es";
|
||||
import { logger } from "@renderer/logger";
|
||||
@@ -22,31 +22,49 @@ export function useSearchSuggestions(
|
||||
const library = useAppSelector((state) => state.library.value);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const cacheRef = useRef<Map<string, SearchSuggestion[]>>(new Map());
|
||||
const librarySearchIndex = useMemo(
|
||||
() =>
|
||||
library.map((game) => ({
|
||||
titleLower: game.title.toLowerCase(),
|
||||
game,
|
||||
})),
|
||||
[library]
|
||||
);
|
||||
|
||||
const getLibrarySuggestions = useCallback(
|
||||
(searchQuery: string, limit: number = 3): SearchSuggestion[] => {
|
||||
if (!searchQuery.trim()) return [];
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
if (normalizedQuery.length < 2) return [];
|
||||
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
const matches: SearchSuggestion[] = [];
|
||||
|
||||
for (const game of library) {
|
||||
for (const { game, titleLower } of librarySearchIndex) {
|
||||
if (matches.length >= limit) break;
|
||||
|
||||
const titleLower = game.title.toLowerCase();
|
||||
if (titleLower.includes(normalizedQuery)) {
|
||||
matches.push({
|
||||
title: game.title,
|
||||
objectId: game.objectId,
|
||||
shop: game.shop,
|
||||
iconUrl: game.iconUrl,
|
||||
source: "library",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let queryIndex = 0;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < titleLower.length && queryIndex < queryLower.length;
|
||||
i++
|
||||
let index = 0;
|
||||
index < titleLower.length && queryIndex < normalizedQuery.length;
|
||||
index++
|
||||
) {
|
||||
if (titleLower[i] === queryLower[queryIndex]) {
|
||||
if (titleLower[index] === normalizedQuery[queryIndex]) {
|
||||
queryIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (queryIndex === queryLower.length) {
|
||||
if (queryIndex === normalizedQuery.length) {
|
||||
matches.push({
|
||||
title: game.title,
|
||||
objectId: game.objectId,
|
||||
@@ -59,7 +77,7 @@ export function useSearchSuggestions(
|
||||
|
||||
return matches;
|
||||
},
|
||||
[library]
|
||||
[librarySearchIndex]
|
||||
);
|
||||
|
||||
const fetchCatalogueSuggestions = useCallback(
|
||||
|
||||
@@ -5,7 +5,15 @@ import type {
|
||||
} from "@types";
|
||||
|
||||
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import "./catalogue.scss";
|
||||
|
||||
@@ -82,7 +90,8 @@ const areSameValues = (first: string[], second: string[]) =>
|
||||
first.every((item) => second.includes(item));
|
||||
|
||||
export default function Catalogue() {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const hasResultsRef = useRef(false);
|
||||
const cataloguePageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
|
||||
@@ -90,8 +99,17 @@ export default function Catalogue() {
|
||||
const { steamGenres, steamUserTags, filters, page } = useAppSelector(
|
||||
(state) => state.catalogueSearch
|
||||
);
|
||||
const deferredTitleFilter = useDeferredValue(filters.title);
|
||||
|
||||
const effectiveFilters = useMemo(() => {
|
||||
return {
|
||||
...filters,
|
||||
title: deferredTitleFilter,
|
||||
};
|
||||
}, [filters, deferredTitleFilter]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const [results, setResults] = useState<CatalogueSearchResult[]>([]);
|
||||
|
||||
@@ -110,11 +128,9 @@ export default function Catalogue() {
|
||||
filters: CatalogueSearchPayload,
|
||||
downloadSources: DownloadSource[],
|
||||
pageSize: number,
|
||||
offset: number
|
||||
offset: number,
|
||||
requestId: number
|
||||
) => {
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
const requestData = {
|
||||
...filters,
|
||||
take: pageSize,
|
||||
@@ -124,19 +140,25 @@ export default function Catalogue() {
|
||||
),
|
||||
};
|
||||
|
||||
const response = await window.electron.hydraApi.post<{
|
||||
edges: CatalogueSearchResult[];
|
||||
count: number;
|
||||
}>("/catalogue/search", {
|
||||
data: requestData,
|
||||
needsAuth: false,
|
||||
});
|
||||
try {
|
||||
const response = await window.electron.hydraApi.post<{
|
||||
edges: CatalogueSearchResult[];
|
||||
count: number;
|
||||
}>("/catalogue/search", {
|
||||
data: requestData,
|
||||
needsAuth: false,
|
||||
});
|
||||
|
||||
if (abortController.signal.aborted) return;
|
||||
if (requestId !== requestSequenceRef.current) return;
|
||||
|
||||
setResults(response.edges);
|
||||
setItemsCount(response.count);
|
||||
setIsLoading(false);
|
||||
setResults(response.edges);
|
||||
setItemsCount(response.count);
|
||||
setIsLoading(false);
|
||||
} finally {
|
||||
if (requestId === requestSequenceRef.current) {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
500
|
||||
)
|
||||
@@ -146,21 +168,29 @@ export default function Catalogue() {
|
||||
s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
useEffect(() => {
|
||||
setResults([]);
|
||||
setIsLoading(true);
|
||||
abortControllerRef.current?.abort();
|
||||
hasResultsRef.current = results.length > 0;
|
||||
}, [results.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++requestSequenceRef.current;
|
||||
setIsFetching(true);
|
||||
|
||||
if (!hasResultsRef.current) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
debouncedSearch(
|
||||
filters,
|
||||
effectiveFilters,
|
||||
downloadSources,
|
||||
PAGE_SIZE,
|
||||
(page - 1) * PAGE_SIZE
|
||||
(page - 1) * PAGE_SIZE,
|
||||
requestId
|
||||
);
|
||||
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
}, [filters, downloadSources, page, debouncedSearch]);
|
||||
}, [effectiveFilters, downloadSources, page, debouncedSearch]);
|
||||
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
@@ -411,6 +441,10 @@ export default function Catalogue() {
|
||||
results.map((game) => <GameItem key={game.id} game={game} />)
|
||||
)}
|
||||
|
||||
{isFetching && !isLoading && (
|
||||
<span className="catalogue__result-count">{t("loading")}</span>
|
||||
)}
|
||||
|
||||
<div className="catalogue__pagination-container">
|
||||
<span className="catalogue__result-count">
|
||||
{t("result_count", {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import {
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
useLibrary,
|
||||
@@ -95,6 +101,7 @@ export default function Library() {
|
||||
const [isDeletingCollection, setIsDeletingCollection] = useState(false);
|
||||
|
||||
const searchQuery = useAppSelector((state) => state.library.searchQuery);
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation(["library", "sidebar"]);
|
||||
|
||||
@@ -371,42 +378,8 @@ export default function Library() {
|
||||
hasLoadedCollections,
|
||||
]);
|
||||
|
||||
const filteredLibrary = useMemo(() => {
|
||||
let filtered = library;
|
||||
|
||||
if (selectedCollectionId) {
|
||||
if (selectedCollectionId === FAVORITES_COLLECTION_ID) {
|
||||
filtered = filtered.filter((game) => game.favorite);
|
||||
} else {
|
||||
filtered = filtered.filter((game) =>
|
||||
getGameCollectionIds(game).includes(selectedCollectionId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!searchQuery.trim()) return filtered;
|
||||
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return filtered.filter((game) => {
|
||||
const titleLower = game.title.toLowerCase();
|
||||
let queryIndex = 0;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < titleLower.length && queryIndex < queryLower.length;
|
||||
i++
|
||||
) {
|
||||
if (titleLower[i] === queryLower[queryIndex]) {
|
||||
queryIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return queryIndex === queryLower.length;
|
||||
});
|
||||
}, [library, searchQuery, selectedCollectionId]);
|
||||
|
||||
const sortedLibrary = useMemo(() => {
|
||||
return [...filteredLibrary].sort((a, b) => {
|
||||
return [...library].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case "recently_played": {
|
||||
const aHasPlayed = a.lastTimePlayed !== null;
|
||||
@@ -459,7 +432,41 @@ export default function Library() {
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
}, [filteredLibrary, sortBy]);
|
||||
}, [library, sortBy]);
|
||||
|
||||
const filteredLibrary = useMemo(() => {
|
||||
let filtered = sortedLibrary;
|
||||
|
||||
if (selectedCollectionId) {
|
||||
if (selectedCollectionId === FAVORITES_COLLECTION_ID) {
|
||||
filtered = filtered.filter((game) => game.favorite);
|
||||
} else {
|
||||
filtered = filtered.filter((game) =>
|
||||
getGameCollectionIds(game).includes(selectedCollectionId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!deferredSearchQuery.trim()) return filtered;
|
||||
|
||||
const queryLower = deferredSearchQuery.toLowerCase();
|
||||
return filtered.filter((game) => {
|
||||
const titleLower = game.title.toLowerCase();
|
||||
let queryIndex = 0;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < titleLower.length && queryIndex < queryLower.length;
|
||||
i++
|
||||
) {
|
||||
if (titleLower[i] === queryLower[queryIndex]) {
|
||||
queryIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return queryIndex === queryLower.length;
|
||||
});
|
||||
}, [sortedLibrary, deferredSearchQuery, selectedCollectionId]);
|
||||
|
||||
const favoritesCount = useMemo(() => {
|
||||
return library.filter((game) => game.favorite).length;
|
||||
@@ -477,7 +484,7 @@ export default function Library() {
|
||||
}, [collections, favoritesCount, t]);
|
||||
|
||||
const hasGames = library.length > 0;
|
||||
const hasNoFilteredGames = sortedLibrary.length === 0;
|
||||
const hasNoFilteredGames = filteredLibrary.length === 0;
|
||||
const isFavoritesCollectionSelected =
|
||||
selectedCollectionId === FAVORITES_COLLECTION_ID;
|
||||
const shouldShowFavoritesEmptyState =
|
||||
@@ -593,7 +600,7 @@ export default function Library() {
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{sortedLibrary.map((game) => (
|
||||
{filteredLibrary.map((game) => (
|
||||
<LibraryGameCardLarge
|
||||
key={`${game.shop}-${game.objectId}`}
|
||||
game={game}
|
||||
@@ -612,7 +619,7 @@ export default function Library() {
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{sortedLibrary.map((game) => (
|
||||
{filteredLibrary.map((game) => (
|
||||
<li
|
||||
key={`${game.shop}-${game.objectId}`}
|
||||
style={{ listStyle: "none" }}
|
||||
|
||||
Reference in New Issue
Block a user