refactor: migrate torrent backend to python RPC service

This commit is contained in:
Chubby Granny Chaser
2026-03-23 23:16:10 +00:00
parent 598cfa3bf2
commit 54396a0f85
54 changed files with 1684 additions and 454 deletions
+17
View File
@@ -79,6 +79,23 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install Python dependencies
run: pip install -r requirements.txt
- name: Build Python RPC 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 - name: Build Linux
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: | run: |
+17
View File
@@ -77,6 +77,23 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install Python dependencies
run: pip install -r requirements.txt
- name: Build Python RPC 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 - name: Build Linux
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: | run: |
-2
View File
@@ -18,5 +18,3 @@ native/hydra-native/target/
.env.sentry-build-plugin .env.sentry-build-plugin
*storybook.log *storybook.log
aria2/
+4 -1
View File
@@ -5,7 +5,7 @@
<h1 align="center">Hydra Launcher</h1> <h1 align="center">Hydra Launcher</h1>
<p align="center"> <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 Rust.</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), Python, and Rust.</strong>
</p> </p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions) [![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -32,11 +32,14 @@ Please, refer to our Documentation pages: [docs.hydralauncher.gg](https://docs.h
### Local development requirements ### Local development requirements
- Node.js + Yarn - Node.js + Yarn
- Python 3.9+ with `pip install -r requirements.txt`
- Rust toolchain (for `hydra-native`) - Rust toolchain (for `hydra-native`)
- `libtorrent-rasterbar` development package (Linux/macOS, usually `libtorrent-rasterbar2.0-dev` or `libtorrent-rasterbar-dev`) or vcpkg `libtorrent` (Windows) - `libtorrent-rasterbar` development package (Linux/macOS, usually `libtorrent-rasterbar2.0-dev` or `libtorrent-rasterbar-dev`) or vcpkg `libtorrent` (Windows)
After installing dependencies, `postinstall` now builds the Rust native addon automatically (`hydra-native/hydra-native.node`). After installing dependencies, `postinstall` now builds the Rust native addon automatically (`hydra-native/hydra-native.node`).
Packaging scripts (`yarn build:win`, `yarn build:mac`, `yarn build:linux`, `yarn build:unpack`) now run `yarn build:python-rpc` automatically.
## Contributors ## Contributors
<a href="https://github.com/hydralauncher/hydra/graphs/contributors"> <a href="https://github.com/hydralauncher/hydra/graphs/contributors">
+1
View File
@@ -4,6 +4,7 @@ directories:
buildResources: build buildResources: build
extraResources: extraResources:
- ludusavi - ludusavi
- hydra-python-rpc
- hydra-native - hydra-native
- seeds - seeds
- from: node_modules/create-desktop-shortcuts/src/windows.vbs - from: node_modules/create-desktop-shortcuts/src/windows.vbs
+5 -4
View File
@@ -25,10 +25,11 @@
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build", "build": "npm run typecheck && electron-vite build",
"postinstall": "npm run build:native && 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:python-rpc": "python3 python_rpc/setup.py build || python python_rpc/setup.py build",
"build:win": "npm run build:native && electron-vite build && electron-builder --win", "build:unpack": "npm run build && npm run build:python-rpc && electron-builder --dir",
"build:mac": "npm run build:native && electron-vite build && electron-builder --mac", "build:win": "npm run build:native && npm run build:python-rpc && electron-vite build && electron-builder --win",
"build:linux": "npm run build:native && electron-vite build && electron-builder --linux", "build:mac": "npm run build:native && npm run build:python-rpc && electron-vite build && electron-builder --mac",
"build:linux": "npm run build:native && npm run build:python-rpc && electron-vite build && electron-builder --linux",
"prepare": "husky", "prepare": "husky",
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto" "protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
}, },
+615
View File
@@ -0,0 +1,615 @@
import hmac
import json
import logging
import re
import sys
import tempfile
import threading
import time
import urllib.parse
from typing import Any, Optional
import libtorrent as lt
from torrent_downloader import TorrentDownloader
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("hydra.rpc")
def parse_cli_args(argv):
if len(argv) >= 6:
# Legacy format:
# [script, torrent_port, http_port, rpc_password, initial_download, initial_seeding]
torrent_port_arg = argv[1]
rpc_password_arg = argv[3]
initial_download_arg = argv[4]
initial_seeding_arg = argv[5]
return (
torrent_port_arg,
rpc_password_arg,
initial_download_arg,
initial_seeding_arg,
)
if len(argv) >= 5:
# Stdio format with RPC password:
# [script, torrent_port, rpc_password, initial_download, initial_seeding]
torrent_port_arg = argv[1]
rpc_password_arg = argv[2]
initial_download_arg = argv[3]
initial_seeding_arg = argv[4]
return (
torrent_port_arg,
rpc_password_arg,
initial_download_arg,
initial_seeding_arg,
)
if len(argv) >= 4:
# Backward-compatible stdio format (no RPC password):
# [script, torrent_port, initial_download, initial_seeding]
torrent_port_arg = argv[1]
initial_download_arg = argv[2]
initial_seeding_arg = argv[3]
return (
torrent_port_arg,
"",
initial_download_arg,
initial_seeding_arg,
)
raise ValueError("invalid_arguments")
torrent_port, rpc_password, start_download_payload, start_seeding_payload = parse_cli_args(
sys.argv
)
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()
stdout_lock = threading.RLock()
class RpcError(Exception):
def __init__(self, code: str, message: Optional[str] = None):
super().__init__(message or code)
self.code = code
self.message = message or code
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_code(error: Exception):
code = str(error)
if isinstance(error, TimeoutError) or code == "metadata_timeout":
return "metadata_timeout"
if code in {
"invalid_magnet",
"invalid_file_indices",
"empty_selection",
"invalid_url",
"invalid_save_path",
}:
return code
if code == "metadata_incomplete":
return "metadata_incomplete"
if code == "too_many_files":
return "too_many_files"
logger.error("Unhandled RPC error: %s", error, exc_info=True)
return "internal_error"
def normalize_download_limit(value):
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def normalize_metadata_timeout_ms(value):
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return max(5000, min(parsed, 120000))
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_value(password: Optional[str]):
if rpc_password == "":
return True
if not isinstance(password, str):
return False
return hmac.compare_digest(password, rpc_password)
def start_torrent_download(
game_id,
url,
save_path,
file_indices=None,
flags=None,
metadata_timeout_ms=None,
):
normalized_metadata_timeout_ms = normalize_metadata_timeout_ms(metadata_timeout_ms)
start_kwargs = {
"file_indices": file_indices,
}
if normalized_metadata_timeout_ms is not None:
start_kwargs["wait_timeout_seconds"] = normalized_metadata_timeout_ms / 1000
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, **start_kwargs)
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, **start_kwargs)
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,
metadata_timeout_ms=initial_download.get("metadata_timeout_ms"),
)
else:
raise ValueError("invalid_url")
except Exception as error:
downloading_game_id = -1
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)
def status():
with downloads_lock:
downloader = downloads.get(downloading_game_id)
if not downloader:
return None
status_payload = downloader.get_download_status()
if not status_payload:
return None
return status_payload
def seed_status():
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 seed_payload
def torrent_files(data: Optional[dict] = None):
data = data or {}
try:
magnet, info_hash = validate_magnet_uri(data.get("magnet"))
except Exception as error:
raise RpcError(map_downloader_error_code(error)) from error
cached_payload = get_cached_torrent_files(info_hash)
if cached_payload is not None:
return cached_payload
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):
raise RpcError("metadata_busy")
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 response
except Exception as error:
raise RpcError(map_downloader_error_code(error)) from error
finally:
temp_downloader.cancel_download()
metadata_semaphore.release()
def action(data: Optional[dict] = None):
global downloading_game_id
global current_download_limit
data = data or {}
action_name = data.get("action")
game_id = data.get("game_id")
if not action_name:
raise RpcError("invalid_action")
requires_game_id = {"start", "pause", "cancel", "resume_seeding", "pause_seeding"}
if action_name in requires_game_id and not game_id:
raise RpcError("invalid_game_id")
try:
if action_name == "start":
url = data.get("url")
if not isinstance(url, str):
raise RpcError("invalid_url")
save_path = data.get("save_path")
if not isinstance(save_path, str):
raise RpcError("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,
metadata_timeout_ms=data.get("metadata_timeout_ms"),
)
else:
raise RpcError("invalid_url")
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:
raise RpcError("invalid_action")
except RpcError:
raise
except Exception as error:
raise RpcError(map_downloader_error_code(error)) from error
return None
def write_response(payload: dict):
serialized = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
with stdout_lock:
sys.stdout.write(serialized + "\n")
sys.stdout.flush()
def build_error_response(request_id: Any, code: str, message: Optional[str] = None):
return {
"id": request_id,
"error": {
"code": code,
"message": message or code,
},
}
def dispatch_method(method: str, params: Optional[dict]):
payload = params or {}
if method == "status":
return status()
if method == "seed_status":
return seed_status()
if method == "torrent_files":
return torrent_files(payload)
if method == "action":
return action(payload)
raise RpcError("method_not_found", f"Unknown method: {method}")
def handle_request(request_payload: dict):
request_id = request_payload.get("id")
method = request_payload.get("method")
params = request_payload.get("params")
rpc_password_value = request_payload.get("rpc_password")
if not validate_rpc_password_value(rpc_password_value):
write_response(build_error_response(request_id, "unauthorized", "Unauthorized"))
return
if request_id is None:
write_response(build_error_response(None, "invalid_request", "Missing request id"))
return
if not isinstance(method, str) or not method:
write_response(build_error_response(request_id, "invalid_method", "Invalid method"))
return
if params is not None and not isinstance(params, dict):
write_response(
build_error_response(request_id, "invalid_params", "Params must be an object")
)
return
try:
result = dispatch_method(method, params)
write_response({"id": request_id, "result": result})
except RpcError as error:
write_response(build_error_response(request_id, error.code, error.message))
except Exception as error:
logger.error("Unhandled RPC dispatcher error: %s", error, exc_info=True)
write_response(build_error_response(request_id, "internal_error", "internal_error"))
def start_stdio_rpc_loop():
write_response({"event": "ready", "protocolVersion": 1})
for raw_line in sys.stdin:
line = raw_line.strip()
if not line:
continue
try:
payload = json.loads(line)
except Exception:
write_response(build_error_response(None, "invalid_json", "Invalid JSON"))
continue
if not isinstance(payload, dict):
write_response(build_error_response(None, "invalid_request", "Request must be an object"))
continue
request_thread = threading.Thread(
target=handle_request,
args=(payload,),
daemon=True,
)
request_thread.start()
bootstrap_downloads()
if __name__ == "__main__":
start_stdio_rpc_loop()
+20
View File
@@ -0,0 +1,20 @@
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"
)]
)
+391
View File
@@ -0,0 +1,391 @@
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
+4
View File
@@ -0,0 +1,4 @@
libtorrent
cx_Freeze == 7.2.3
cx_Logging; sys_platform == 'win32'
pywin32; sys_platform == 'win32'
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -754,8 +754,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -784,8 +784,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_shortcuts_on_download": "Create desktop and Start Menu shortcuts when game finishes downloading", "create_shortcuts_on_download": "Create desktop and Start Menu shortcuts when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override" "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override"
-2
View File
@@ -718,8 +718,6 @@
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego", "autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego", "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego",
"downloads": "Descargas", "downloads": "Descargas",
"use_native_http_downloader": "Usar descargador HTTP nativo (experimental)",
"cannot_change_downloader_while_downloading": "No se puede cambiar esta configuración mientras una descarga está en progreso",
"create_shortcuts_on_download": "Crear accesos directos en el escritorio y en el Menú de Inicio cuando el juego termine de descargarse", "create_shortcuts_on_download": "Crear accesos directos en el escritorio y en el Menú de Inicio cuando el juego termine de descargarse",
"default_proton_version": "Versión predeterminada de Proton", "default_proton_version": "Versión predeterminada de Proton",
"default_proton_version_description": "Seleccioná la versión de Proton que usa el modo Automático cuando un juego no tiene una configuración personalizada", "default_proton_version_description": "Seleccioná la versión de Proton que usa el modo Automático cuando un juego no tiene una configuración personalizada",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -754,8 +754,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -768,8 +768,6 @@
"debrid_description": "Les services Debrid sont des téléchargeurs premium non restreints qui permettent de télécharger rapidement des fichiers hébergés sur différents services, limités uniquement par la vitesse de votre connexion internet.", "debrid_description": "Les services Debrid sont des téléchargeurs premium non restreints qui permettent de télécharger rapidement des fichiers hébergés sur différents services, limités uniquement par la vitesse de votre connexion internet.",
"enable_new_download_options_badges": "Afficher les badges des nouvelles options de téléchargement", "enable_new_download_options_badges": "Afficher les badges des nouvelles options de téléchargement",
"downloads": "Téléchargements", "downloads": "Téléchargements",
"use_native_http_downloader": "Utiliser le téléchargeur HTTP natif (expérimental)",
"cannot_change_downloader_while_downloading": "Impossible de modifier ce paramètre pendant quun téléchargement est en cours",
"create_shortcuts_on_download": "Créer un raccourci dans le menu Démarrer à la fin du téléchargement du jeu", "create_shortcuts_on_download": "Créer un raccourci dans le menu Démarrer à la fin du téléchargement du jeu",
"launch_hydra_in_library_page": "Lancer Hydra sur la page Bibliothèque", "launch_hydra_in_library_page": "Lancer Hydra sur la page Bibliothèque",
"enable_premiumize": "Activer Premiumize", "enable_premiumize": "Activer Premiumize",
-2
View File
@@ -729,8 +729,6 @@
"autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán",
"hide_to_tray_on_game_start": "Hydra elrejtése játék indításakor a tálcára", "hide_to_tray_on_game_start": "Hydra elrejtése játék indításakor a tálcára",
"downloads": "Letöltések", "downloads": "Letöltések",
"use_native_http_downloader": "Natív HTTP letöltő használata (kísérleti)",
"cannot_change_downloader_while_downloading": "Ez a beállítás nem változtatható letöltés közben.",
"create_start_menu_shortcut_on_download": "Start Menü parancsikon létrehozása letöltés után", "create_start_menu_shortcut_on_download": "Start Menü parancsikon létrehozása letöltés után",
"content_gameplay": "Content & gameplay", "content_gameplay": "Content & gameplay",
"integrations": "Integrations", "integrations": "Integrations",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -729,8 +729,6 @@
"autoplay_trailers_on_game_page": "Riproduci automaticamente i trailer nella pagina del gioco", "autoplay_trailers_on_game_page": "Riproduci automaticamente i trailer nella pagina del gioco",
"hide_to_tray_on_game_start": "Nascondi Hydra nel tray all'avvio del gioco", "hide_to_tray_on_game_start": "Nascondi Hydra nel tray all'avvio del gioco",
"downloads": "Download", "downloads": "Download",
"use_native_http_downloader": "Usa downloader HTTP nativo (sperimentale)",
"cannot_change_downloader_while_downloading": "Impossibile cambiare questa impostazione mentre un download è in corso",
"create_start_menu_shortcut_on_download": "Crea collegamento nel menu Start quando il gioco finisce di scaricarsi", "create_start_menu_shortcut_on_download": "Crea collegamento nel menu Start quando il gioco finisce di scaricarsi",
"content_gameplay": "Content & gameplay", "content_gameplay": "Content & gameplay",
"integrations": "Integrations", "integrations": "Integrations",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -754,8 +754,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -754,8 +754,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -762,8 +762,6 @@
"autoplay_trailers_on_game_page": "Automatycznie odtwarzaj zwiastuny na stronie gry", "autoplay_trailers_on_game_page": "Automatycznie odtwarzaj zwiastuny na stronie gry",
"hide_to_tray_on_game_start": "Ukryj Hydrę do zasobnika przy uruchomieniu gry", "hide_to_tray_on_game_start": "Ukryj Hydrę do zasobnika przy uruchomieniu gry",
"downloads": "Pobieranie", "downloads": "Pobieranie",
"use_native_http_downloader": "Użyj natywnego downloadera HTTP (eksperymentalny)",
"cannot_change_downloader_while_downloading": "Nie można zmienić tego ustawienia podczas trwającego pobierania",
"create_shortcuts_on_download": "Twórz skróty na pulpicie i w menu Start po zakończeniu pobierania gry", "create_shortcuts_on_download": "Twórz skróty na pulpicie i w menu Start po zakończeniu pobierania gry",
"default_proton_version": "Domyślna wersja Proton", "default_proton_version": "Domyślna wersja Proton",
"default_proton_version_description": "Wybierz wersję Proton używaną w trybie Auto, gdy gra nie ma niestandardowego nadpisania", "default_proton_version_description": "Wybierz wersję Proton używaną w trybie Auto, gdy gra nie ma niestandardowego nadpisania",
-2
View File
@@ -704,8 +704,6 @@
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo", "hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Usar downloader HTTP nativo (experimental)",
"cannot_change_downloader_while_downloading": "Não é possível alterar esta configuração enquanto um download estiver em andamento",
"create_shortcuts_on_download": "Criar atalhos na Área de Trabalho e no Menu Iniciar quando o jogo terminar de baixar", "create_shortcuts_on_download": "Criar atalhos na Área de Trabalho e no Menu Iniciar quando o jogo terminar de baixar",
"default_proton_version": "Versão padrão do Proton", "default_proton_version": "Versão padrão do Proton",
"default_proton_version_description": "Selecione a versão do Proton usada pelo modo Automático quando um jogo não tiver substituição personalizada", "default_proton_version_description": "Selecione a versão do Proton usada pelo modo Automático quando um jogo não tiver substituição personalizada",
-2
View File
@@ -669,8 +669,6 @@
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo", "hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo",
"downloads": "Transferências", "downloads": "Transferências",
"use_native_http_downloader": "Usar transferidor HTTP nativo (experimental)",
"cannot_change_downloader_while_downloading": "Não é possível alterar esta definição enquanto uma transferência estiver em andamento",
"create_shortcuts_on_download": "Criar atalhos no Ambiente de Trabalho e no Menu Iniciar quando o jogo terminar de transferir", "create_shortcuts_on_download": "Criar atalhos no Ambiente de Trabalho e no Menu Iniciar quando o jogo terminar de transferir",
"default_proton_version": "Versão predefinida do Proton", "default_proton_version": "Versão predefinida do Proton",
"default_proton_version_description": "Seleciona a versão do Proton usada pelo modo Automático quando um jogo não tem uma substituição personalizada", "default_proton_version_description": "Seleciona a versão do Proton usada pelo modo Automático quando um jogo não tem uma substituição personalizada",
-2
View File
@@ -754,8 +754,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -721,8 +721,6 @@
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры", "autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
"hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры", "hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры",
"downloads": "Загрузки", "downloads": "Загрузки",
"use_native_http_downloader": "Использовать встроенный HTTP-загрузчик (экспериментально)",
"cannot_change_downloader_while_downloading": "Нельзя изменить эту настройку во время загрузки",
"create_shortcuts_on_download": "Создавать ярлыки на рабочем столе и в меню «Пуск» после завершения загрузки игры", "create_shortcuts_on_download": "Создавать ярлыки на рабочем столе и в меню «Пуск» после завершения загрузки игры",
"default_proton_version": "Версия Proton по умолчанию", "default_proton_version": "Версия Proton по умолчанию",
"default_proton_version_description": "Выберите версию Proton, которую использует автоматический режим, если для игры не задана отдельная настройка", "default_proton_version_description": "Выберите версию Proton, которую использует автоматический режим, если для игры не задана отдельная настройка",
-2
View File
@@ -761,8 +761,6 @@
"autoplay_trailers_on_game_page": "Samodejno predvajaj napovednike na strani igre", "autoplay_trailers_on_game_page": "Samodejno predvajaj napovednike na strani igre",
"hide_to_tray_on_game_start": "Skrij Hydreo v sistemsko vrstico ob zagonu igre", "hide_to_tray_on_game_start": "Skrij Hydreo v sistemsko vrstico ob zagonu igre",
"downloads": "Prenosi", "downloads": "Prenosi",
"use_native_http_downloader": "Uporabi izvorni HTTP prenašalnik (eksperimentalno)",
"cannot_change_downloader_while_downloading": "Nastavitve ni mogoče spremeniti med prenosom",
"system_tray": { "system_tray": {
"open": "Odpri Hydreo", "open": "Odpri Hydreo",
"quit": "Izhod" "quit": "Izhod"
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"run_games_with_mangohud": "Run games with MangoHud by default", "run_games_with_mangohud": "Run games with MangoHud by default",
"enable_new_download_options_badges": "Show new download options badges", "enable_new_download_options_badges": "Show new download options badges",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
-2
View File
@@ -757,8 +757,6 @@
"remove": "Remove", "remove": "Remove",
"no_sound_file_selected": "No sound file selected", "no_sound_file_selected": "No sound file selected",
"downloads": "Downloads", "downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress",
"create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading", "create_start_menu_shortcut_on_download": "Create Start Menu shortcut when game finishes downloading",
"default_proton_version": "Default Proton version", "default_proton_version": "Default Proton version",
"default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override", "default_proton_version_description": "Select the Proton version used by Auto mode when a game has no custom override",
@@ -1,11 +1,17 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { NativeAddon } from "@main/services/native-addon"; import { PythonRPC } from "@main/services/python-rpc";
import type { TorrentFilesResponse } from "@types"; import type { TorrentFilesResponse } from "@types";
import { DownloadError } from "@shared"; import { DownloadError } from "@shared";
const mapTorrentFilesError = (error: unknown) => { const mapTorrentFilesError = (error: unknown) => {
if (error instanceof Error) { const rpcError =
switch (error.message) { typeof error === "object" && error !== null && "response" in error
? ((error as { response?: { data?: { error?: string } } }).response?.data
?.error ?? undefined)
: undefined;
if (rpcError) {
switch (rpcError) {
case "invalid_magnet": case "invalid_magnet":
return DownloadError.InvalidMagnet; return DownloadError.InvalidMagnet;
case "metadata_timeout": case "metadata_timeout":
@@ -14,11 +20,17 @@ const mapTorrentFilesError = (error: unknown) => {
return DownloadError.TorrentMetadataIncomplete; return DownloadError.TorrentMetadataIncomplete;
case "too_many_files": case "too_many_files":
return DownloadError.TorrentTooManyFiles; return DownloadError.TorrentTooManyFiles;
case "metadata_busy":
return DownloadError.TorrentMetadataTimeout;
default: default:
return DownloadError.TorrentFilesUnavailable; return DownloadError.TorrentFilesUnavailable;
} }
} }
if (error instanceof Error) {
return DownloadError.TorrentFilesUnavailable;
}
return DownloadError.TorrentFilesUnavailable; return DownloadError.TorrentFilesUnavailable;
}; };
@@ -31,11 +43,20 @@ const getTorrentFiles = async (
} }
try { try {
const response = await NativeAddon.getTorrentFiles(magnet, 45_000); const response = await PythonRPC.rpc.post<TorrentFilesResponse>(
"/torrent-files",
{
magnet,
timeout_ms: 45_000,
},
{
timeout: 45000,
}
);
return { return {
ok: true, ok: true,
data: response as TorrentFilesResponse, data: response.data,
}; };
} catch (error) { } catch (error) {
return { return {
+3
View File
@@ -12,6 +12,7 @@ import {
PowerSaveBlockerManager, PowerSaveBlockerManager,
} from "@main/services"; } from "@main/services";
import resources from "@locales"; import resources from "@locales";
import { PythonRPC } from "./services/python-rpc";
import { db, gamesSublevel, levelKeys } from "./level"; import { db, gamesSublevel, levelKeys } from "./level";
import { GameShop, UserPreferences } from "@types"; import { GameShop, UserPreferences } from "@types";
import { launchGame } from "./helpers"; import { launchGame } from "./helpers";
@@ -279,6 +280,8 @@ app.on("before-quit", async (e) => {
if (!canAppBeClosed) { if (!canAppBeClosed) {
e.preventDefault(); e.preventDefault();
PowerSaveBlockerManager.reset(); PowerSaveBlockerManager.reset();
/* Disconnects libtorrent */
PythonRPC.kill();
await clearGamesPlaytime(); await clearGamesPlaytime();
canAppBeClosed = true; canAppBeClosed = true;
app.quit(); app.quit();
+23 -63
View File
@@ -119,11 +119,7 @@ export const loadState = async () => {
// Find interrupted active download (download that was running when app closed) // Find interrupted active download (download that was running when app closed)
// Mark it as paused but remember it for auto-resume // Mark it as paused but remember it for auto-resume
if ( if (download.status === "active" && !interruptedDownload) {
download.status === "active" &&
!interruptedDownload &&
download.queued
) {
interruptedDownload = download; interruptedDownload = download;
await downloadsSublevel.put(downloadKey, { await downloadsSublevel.put(downloadKey, {
...download, ...download,
@@ -148,32 +144,24 @@ export const loadState = async () => {
for (const download of updatedDownloads) { for (const download of updatedDownloads) {
const downloadKey = levelKeys.game(download.shop, download.objectId); const downloadKey = levelKeys.game(download.shop, download.objectId);
const hasInvalidQueuedState =
const shouldResetQueueFlag =
download.queued && download.queued &&
["removed", "complete", "seeding"].includes(download.status ?? ""); (download.status === "removed" ||
download.status === "complete" ||
download.status === "seeding");
if ( if (!hasInvalidQueuedState) {
shouldResetQueueFlag || normalizedDownloads.push(download);
(download.status === "removed" && download.shouldSeed)
) {
const normalizedDownload = {
...download,
queued: false,
shouldSeed: download.status === "removed" ? false : download.shouldSeed,
};
logger.warn(
`[Startup] Normalized invalid download flags for ${downloadKey} ` +
`(status=${download.status}, queued=${download.queued}, shouldSeed=${download.shouldSeed})`
);
await downloadsSublevel.put(downloadKey, normalizedDownload);
normalizedDownloads.push(normalizedDownload);
continue; continue;
} }
normalizedDownloads.push(download); const normalizedDownload = {
...download,
queued: false,
};
await downloadsSublevel.put(downloadKey, normalizedDownload);
normalizedDownloads.push(normalizedDownload);
} }
// Prioritize interrupted download, then queued downloads // Prioritize interrupted download, then queued downloads
@@ -184,15 +172,6 @@ export const loadState = async () => {
game.queued && (game.status === "paused" || game.status === "error") game.queued && (game.status === "paused" || game.status === "error")
); );
if (downloadToResume) {
logger.log(
`[Startup] Resuming download ${levelKeys.game(downloadToResume.shop, downloadToResume.objectId)} ` +
`(status=${downloadToResume.status}, queued=${downloadToResume.queued})`
);
} else {
logger.log("[Startup] No download selected for auto-resume");
}
const downloadsToSeed: Download[] = []; const downloadsToSeed: Download[] = [];
for (const game of normalizedDownloads) { for (const game of normalizedDownloads) {
@@ -200,8 +179,8 @@ export const loadState = async () => {
!game.shouldSeed || !game.shouldSeed ||
game.downloader !== Downloader.Torrent || game.downloader !== Downloader.Torrent ||
game.progress !== 1 || game.progress !== 1 ||
game.uri === null || game.status !== "seeding" ||
game.status !== "seeding" game.uri === null
) { ) {
continue; continue;
} }
@@ -217,7 +196,9 @@ export const loadState = async () => {
if (game.folderName) { if (game.folderName) {
const downloadTargetPath = path.join(game.downloadPath, game.folderName); const downloadTargetPath = path.join(game.downloadPath, game.folderName);
const currentSize = await getDirSize(downloadTargetPath); const currentSize = fs.existsSync(downloadTargetPath)
? await getDirSize(downloadTargetPath)
: 0;
progress = progress =
expectedSize > 0 expectedSize > 0
? Math.min(currentSize / expectedSize, 1) ? Math.min(currentSize / expectedSize, 1)
@@ -226,50 +207,29 @@ export const loadState = async () => {
await downloadsSublevel.put(gameKey, { await downloadsSublevel.put(gameKey, {
...game, ...game,
status: "error", status: "paused",
shouldSeed: false, shouldSeed: false,
queued: false, queued: false,
progress, progress,
}); });
logger.warn( logger.warn(
`[Startup] Seed files missing for ${gameKey}; moved download state to error` `[Startup] Seed files missing for ${gameKey}; seeding was disabled`
); );
} }
logger.log( // For torrents use Python RPC; HTTP downloads use JS downloader.
`[Startup] Seed queue prepared (${downloadsToSeed.length}) ids=${JSON.stringify(
downloadsToSeed.map((download) =>
levelKeys.game(download.shop, download.objectId)
)
)}`
);
// For torrents or if JS downloader is disabled, use Python RPC
const isTorrent = downloadToResume?.downloader === Downloader.Torrent; const isTorrent = downloadToResume?.downloader === Downloader.Torrent;
// Default to true - native HTTP downloader is enabled by default if (downloadToResume && !isTorrent) {
const useJsDownloader =
(userPreferences?.useNativeHttpDownloader ?? true) && !isTorrent;
if (useJsDownloader && downloadToResume) {
// Start Python RPC for seeding only, then resume HTTP download with JS // Start Python RPC for seeding only, then resume HTTP download with JS
await DownloadManager.startRPC(undefined, downloadsToSeed); await DownloadManager.startRPC(undefined, downloadsToSeed);
logger.log(
"[Startup] Started RPC for seeding bootstrap (JS downloader path)"
);
await DownloadManager.startDownload(downloadToResume).catch((err) => { await DownloadManager.startDownload(downloadToResume).catch((err) => {
// If resume fails, just log it - user can manually retry // If resume fails, just log it - user can manually retry
logger.error("Failed to auto-resume download:", err); logger.error("Failed to auto-resume download:", err);
}); });
logger.log(
`[Startup] Auto-resume transition complete for ${levelKeys.game(downloadToResume.shop, downloadToResume.objectId)} (js=${useJsDownloader})`
);
} else { } else {
// Use Python RPC for everything (torrent or fallback) // Use Python RPC for everything (torrent or fallback)
await DownloadManager.startRPC(downloadToResume, downloadsToSeed); await DownloadManager.startRPC(downloadToResume, downloadsToSeed);
logger.log(
`[Startup] RPC bootstrap complete (resume=${downloadToResume ? levelKeys.game(downloadToResume.shop, downloadToResume.objectId) : "none"}, js=${useJsDownloader})`
);
} }
startMainLoop(); startMainLoop();
+114 -153
View File
@@ -10,8 +10,12 @@ import {
VikingFileApi, VikingFileApi,
RootzApi, RootzApi,
} from "../hosters"; } from "../hosters";
import { NativeAddon } from "../native-addon"; import { PythonRPC } from "../python-rpc";
import { LibtorrentStatus } from "./types"; import {
LibtorrentPayload,
LibtorrentStatus,
PauseDownloadPayload,
} from "./types";
import { calculateETA, getDirSize } from "./helpers"; import { calculateETA, getDirSize } from "./helpers";
import { RealDebridClient } from "./real-debrid"; import { RealDebridClient } from "./real-debrid";
import path from "node:path"; import path from "node:path";
@@ -54,7 +58,6 @@ export class DownloadManager {
private static isPreparingDownload = false; private static isPreparingDownload = false;
private static allDebridBatch: AllDebridBatchState | null = null; private static allDebridBatch: AllDebridBatchState | null = null;
private static maxDownloadSpeedBytesPerSecond: number | null = null; private static maxDownloadSpeedBytesPerSecond: number | null = null;
private static readonly TORRENT_START_HANDSHAKE_TIMEOUT_MS = 1_500;
public static hasActiveDownload() { public static hasActiveDownload() {
return this.downloadingGameId !== null; return this.downloadingGameId !== null;
@@ -178,10 +181,6 @@ export class DownloadManager {
}; };
} }
private static async shouldUseJsDownloader(): Promise<boolean> {
return true;
}
private static isHttpDownloader(downloader: Downloader): boolean { private static isHttpDownloader(downloader: Downloader): boolean {
return downloader !== Downloader.Torrent; return downloader !== Downloader.Torrent;
} }
@@ -218,32 +217,25 @@ export class DownloadManager {
this.maxDownloadSpeedBytesPerSecond = normalizedLimit; this.maxDownloadSpeedBytesPerSecond = normalizedLimit;
this.jsDownloader?.setMaxDownloadSpeedBytesPerSecond(normalizedLimit); this.jsDownloader?.setMaxDownloadSpeedBytesPerSecond(normalizedLimit);
try { await PythonRPC.rpc
NativeAddon.setTorrentDownloadLimit(normalizedLimit); .post("/action", {
} catch (error) { action: "set_download_limit",
logger.error( max_download_speed_bytes_per_second: normalizedLimit,
"[DownloadManager] Failed to update torrent speed limit:", })
error .catch((error) => {
); logger.error(
} "[DownloadManager] Failed to update RPC download speed limit:",
} error
);
private static logNativePollStatus(payload: { });
gameId: string;
status: LibtorrentStatus;
isCheckingFiles: boolean;
progress: number;
bytesDownloaded: number;
fileSize: number;
folderName: string;
}) {
logger.log(`[DownloadManager] Native poll ${JSON.stringify(payload)}`);
} }
public static async startRPC( public static async startRPC(
download?: Download, download?: Download,
downloadsToSeed?: Download[] downloadsToSeed?: Download[]
) { ) {
await PythonRPC.spawn();
if (downloadsToSeed?.length) { if (downloadsToSeed?.length) {
for (const seedDownload of downloadsToSeed) { for (const seedDownload of downloadsToSeed) {
await this.resumeSeeding(seedDownload).catch((error) => { await this.resumeSeeding(seedDownload).catch((error) => {
@@ -252,7 +244,7 @@ export class DownloadManager {
} }
} }
if (download?.status === "active") { if (download) {
await this.startDownload(download).catch((error) => { await this.startDownload(download).catch((error) => {
logger.error("[DownloadManager] Failed to resume download", error); logger.error("[DownloadManager] Failed to resume download", error);
}); });
@@ -275,7 +267,6 @@ export class DownloadManager {
return { return {
numPeers: 0, numPeers: 0,
numSeeds: 0, numSeeds: 0,
estimatedSeeds: 0,
downloadSpeed: 0, downloadSpeed: 0,
timeRemaining: -1, timeRemaining: -1,
isDownloadingMetadata: true, // Use this to indicate "preparing" isDownloadingMetadata: true, // Use this to indicate "preparing"
@@ -374,7 +365,6 @@ export class DownloadManager {
return { return {
numPeers: 0, numPeers: 0,
numSeeds: 0, numSeeds: 0,
estimatedSeeds: 0,
downloadSpeed, downloadSpeed,
timeRemaining: calculateETA( timeRemaining: calculateETA(
effectiveFileSize ?? 0, effectiveFileSize ?? 0,
@@ -396,31 +386,29 @@ export class DownloadManager {
} }
private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> { private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> {
if (!this.downloadingGameId) return null; let response: { data: LibtorrentPayload | null };
const downloadId = this.downloadingGameId;
let response: ReturnType<typeof NativeAddon.getTorrentStatus> = null;
try { try {
response = NativeAddon.getTorrentStatus(downloadId); response = await PythonRPC.rpc.get<LibtorrentPayload | null>("/status");
} catch (error) { } catch (error) {
logger.error("[DownloadManager] Native status poll failed", error); logger.error("[DownloadManager] RPC status poll failed", error);
return null; return null;
} }
if (response === null) return null; if (response.data === null || !this.downloadingGameId) return null;
const downloadId = this.downloadingGameId;
try { try {
const { const {
progress, progress,
numPeers, numPeers,
numSeeds, numSeeds,
estimatedSeeds,
downloadSpeed, downloadSpeed,
bytesDownloaded, bytesDownloaded,
fileSize, fileSize,
folderName, folderName,
status, status,
} = response; } = response.data;
const isDownloadingMetadata = const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata; status === LibtorrentStatus.DownloadingMetadata;
@@ -446,48 +434,20 @@ export class DownloadManager {
}); });
} }
const fallbackFileSize =
download?.selectedFilesSize ?? download?.fileSize ?? 0;
const effectiveFileSize = fileSize > 0 ? fileSize : fallbackFileSize;
const reportedProgress =
isDownloadingMetadata || isCheckingFiles
? Math.max(download?.progress ?? 0, progress)
: progress;
const reportedBytesDownloaded =
isDownloadingMetadata || isCheckingFiles
? Math.max(download?.bytesDownloaded ?? 0, bytesDownloaded)
: bytesDownloaded;
const shouldSuppressLiveMetrics =
isDownloadingMetadata || isCheckingFiles;
this.logNativePollStatus({
gameId: downloadId,
status,
isCheckingFiles,
progress: reportedProgress,
bytesDownloaded: reportedBytesDownloaded,
fileSize: effectiveFileSize,
folderName,
});
return { return {
numPeers: shouldSuppressLiveMetrics ? 0 : numPeers, numPeers,
numSeeds: shouldSuppressLiveMetrics ? 0 : numSeeds, numSeeds,
estimatedSeeds, downloadSpeed,
downloadSpeed: shouldSuppressLiveMetrics ? 0 : downloadSpeed, timeRemaining: calculateETA(
timeRemaining: shouldSuppressLiveMetrics fileSize > 0
? -1 ? fileSize
: calculateETA( : (download?.selectedFilesSize ?? download?.fileSize ?? 0),
effectiveFileSize, bytesDownloaded,
reportedBytesDownloaded, downloadSpeed
downloadSpeed ),
),
isDownloadingMetadata, isDownloadingMetadata,
isCheckingFiles, isCheckingFiles,
progress: reportedProgress, progress,
gameId: downloadId, gameId: downloadId,
download, download,
} as DownloadProgress; } as DownloadProgress;
@@ -635,7 +595,6 @@ export class DownloadManager {
queued: false, queued: false,
extracting: shouldExtract, extracting: shouldExtract,
}); });
await this.cancelDownload(gameId); await this.cancelDownload(gameId);
return false; return false;
@@ -649,7 +608,7 @@ export class DownloadManager {
: null; : null;
if (!extractionPath || !fs.existsSync(extractionPath)) { if (!extractionPath || !fs.existsSync(extractionPath)) {
gameFilesManager await gameFilesManager
.failExtraction(new Error("No downloaded archive was found to extract")) .failExtraction(new Error("No downloaded archive was found to extract"))
.catch((error) => { .catch((error) => {
logger.error( logger.error(
@@ -683,19 +642,17 @@ export class DownloadManager {
} else if (extractionStats.isDirectory()) { } else if (extractionStats.isDirectory()) {
await gameFilesManager await gameFilesManager
.extractFilesInDirectory(extractionPath) .extractFilesInDirectory(extractionPath)
.then((success) => { .then(async (success) => {
if (success) { if (success) {
return gameFilesManager.setExtractionComplete(); await gameFilesManager.setExtractionComplete();
} }
return undefined;
}) })
.catch((error) => { .catch((error) => {
logger.error( logger.error(
"[DownloadManager] Failed to extract files in directory", "[DownloadManager] Failed to extract files in directory",
error error
); );
gameFilesManager.failExtraction(error).catch((failError) => { return gameFilesManager.failExtraction(error).catch((failError) => {
logger.error( logger.error(
"[DownloadManager] Failed to persist extraction failure state", "[DownloadManager] Failed to persist extraction failure state",
failError failError
@@ -703,7 +660,7 @@ export class DownloadManager {
}); });
}); });
} else { } else {
gameFilesManager await gameFilesManager
.failExtraction( .failExtraction(
new Error( new Error(
`Invalid extraction source type for "${download.folderName ?? "unknown"}"` `Invalid extraction source type for "${download.folderName ?? "unknown"}"`
@@ -743,12 +700,14 @@ export class DownloadManager {
} }
public static async getSeedStatus() { public static async getSeedStatus() {
let seedStatus: ReturnType<typeof NativeAddon.getTorrentSeedStatus> = []; let seedStatus: LibtorrentPayload[] = [];
try { try {
seedStatus = NativeAddon.getTorrentSeedStatus(); seedStatus = await PythonRPC.rpc
.get<LibtorrentPayload[] | []>("/seed-status")
.then((res) => res.data);
} catch (error) { } catch (error) {
logger.error("[DownloadManager] Native seed status poll failed", error); logger.error("[DownloadManager] RPC seed status poll failed", error);
WindowManager.mainWindow?.webContents.send("on-seeding-status", []); WindowManager.mainWindow?.webContents.send("on-seeding-status", []);
return; return;
} }
@@ -774,9 +733,8 @@ export class DownloadManager {
await downloadsSublevel.put(status.gameId, { await downloadsSublevel.put(status.gameId, {
...download, ...download,
status: "error", status: "paused",
shouldSeed: false, shouldSeed: false,
queued: false,
progress: progress:
status.fileSize > 0 status.fileSize > 0
? Math.min(totalSize / status.fileSize, 1) ? Math.min(totalSize / status.fileSize, 1)
@@ -795,11 +753,12 @@ export class DownloadManager {
logger.log("[DownloadManager] Pausing JS download"); logger.log("[DownloadManager] Pausing JS download");
this.jsDownloader.pauseDownload(); this.jsDownloader.pauseDownload();
} else if (downloadKey) { } else if (downloadKey) {
try { await PythonRPC.rpc
NativeAddon.pauseTorrentDownload(downloadKey); .post("/action", {
} catch { action: "pause",
// ignore pause failures game_id: downloadKey,
} } as PauseDownloadPayload)
.catch(() => {});
} }
if (downloadKey === this.downloadingGameId) { if (downloadKey === this.downloadingGameId) {
@@ -822,12 +781,10 @@ export class DownloadManager {
this.jsDownloader = null; this.jsDownloader = null;
this.usingJsDownloader = false; this.usingJsDownloader = false;
this.allDebridBatch = null; this.allDebridBatch = null;
} else if (!this.isPreparingDownload) { } else {
try { await PythonRPC.rpc
NativeAddon.cancelTorrentDownload(downloadKey!); .post("/action", { action: "cancel", game_id: downloadKey })
} catch (err) { .catch((err) => logger.error("Failed to cancel game download", err));
logger.error("Failed to cancel game download", err);
}
} }
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@@ -836,20 +793,27 @@ export class DownloadManager {
this.isPreparingDownload = false; this.isPreparingDownload = false;
this.usingJsDownloader = false; this.usingJsDownloader = false;
this.allDebridBatch = null; this.allDebridBatch = null;
} else if (downloadKey) {
await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
} }
} }
static async resumeSeeding(download: Download) { static async resumeSeeding(download: Download) {
NativeAddon.resumeTorrentSeeding({ await PythonRPC.rpc.post("/action", {
gameId: levelKeys.game(download.shop, download.objectId), action: "resume_seeding",
game_id: levelKeys.game(download.shop, download.objectId),
url: download.uri, url: download.uri,
savePath: download.downloadPath, save_path: download.downloadPath,
folderName: download.folderName ?? undefined,
}); });
} }
static async pauseSeeding(downloadKey: string) { static async pauseSeeding(downloadKey: string) {
NativeAddon.pauseTorrentSeeding(downloadKey); await PythonRPC.rpc.post("/action", {
action: "pause_seeding",
game_id: downloadKey,
});
} }
private static async getJsDownloadOptions(download: Download): Promise<{ private static async getJsDownloadOptions(download: Download): Promise<{
@@ -1314,12 +1278,19 @@ export class DownloadManager {
}; };
} }
case Downloader.Torrent: case Downloader.Torrent:
const hasSelectedFileIndices =
Array.isArray(download.fileIndices) &&
download.fileIndices.length > 0;
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: downloadId,
url: download.uri, url: download.uri,
save_path: download.downloadPath, save_path: download.downloadPath,
file_indices: download.fileIndices, file_indices: hasSelectedFileIndices
? download.fileIndices
: undefined,
metadata_timeout_ms: hasSelectedFileIndices ? 60_000 : undefined,
}; };
case Downloader.RealDebrid: { case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
@@ -1417,25 +1388,21 @@ export class DownloadManager {
} }
static async validateDownloadUrl(download: Download): Promise<void> { static async validateDownloadUrl(download: Download): Promise<void> {
const useJsDownloader = await this.shouldUseJsDownloader();
const isHttp = this.isHttpDownloader(download.downloader); const isHttp = this.isHttpDownloader(download.downloader);
if (useJsDownloader && isHttp) { if (isHttp) {
const options = await this.getJsDownloadOptions(download); const options = await this.getJsDownloadOptions(download);
if (!options) { if (!options) {
throw new Error("Failed to validate download URL"); throw new Error("Failed to validate download URL");
} }
} else if (isHttp) {
await this.getDownloadPayload(download);
} }
} }
static async startDownload(download: Download) { static async startDownload(download: Download) {
const useJsDownloader = await this.shouldUseJsDownloader();
const isHttp = this.isHttpDownloader(download.downloader); const isHttp = this.isHttpDownloader(download.downloader);
const downloadId = levelKeys.game(download.shop, download.objectId); const downloadId = levelKeys.game(download.shop, download.objectId);
if (useJsDownloader && isHttp) { if (isHttp) {
logger.log("[DownloadManager] Using JS HTTP downloader"); logger.log("[DownloadManager] Using JS HTTP downloader");
// Set preparing state immediately so UI knows download is starting // Set preparing state immediately so UI knows download is starting
@@ -1511,7 +1478,8 @@ export class DownloadManager {
throw err; throw err;
} }
} else { } else {
logger.log("[DownloadManager] Using native torrent downloader"); logger.log("[DownloadManager] Using Python RPC downloader");
const payload = await this.getDownloadPayload(download);
const isSelectiveTorrentStart = const isSelectiveTorrentStart =
download.downloader === Downloader.Torrent && download.downloader === Downloader.Torrent &&
Array.isArray(download.fileIndices) && Array.isArray(download.fileIndices) &&
@@ -1527,48 +1495,41 @@ export class DownloadManager {
this.usingJsDownloader = false; this.usingJsDownloader = false;
this.allDebridBatch = null; this.allDebridBatch = null;
const startPromise = NativeAddon.startTorrentDownload({ if (payload?.url) {
gameId: downloadId, this.logResolvedUrl(payload.url);
url: download.uri, }
savePath: download.downloadPath,
folderName: download.folderName ?? undefined,
fileIndices: download.fileIndices,
timeoutMs: isSelectiveTorrentStart ? 60_000 : 10_000,
});
void startPromise try {
.then(() => { await PythonRPC.rpc.post("/action", payload, {
const downloadWasCancelledOrReplaced = timeout: isSelectiveTorrentStart ? 60_000 : 10_000,
this.downloadingGameId !== downloadId;
if (!downloadWasCancelledOrReplaced) {
this.isPreparingDownload = false;
return;
}
try {
NativeAddon.cancelTorrentDownload(downloadId);
} catch (err) {
logger.error("Failed to cancel stale torrent download", err);
}
})
.catch((error) => {
if (this.downloadingGameId === downloadId) {
this.isPreparingDownload = previousIsPreparingDownload;
this.downloadingGameId = previousDownloadingGameId;
this.usingJsDownloader = previousUsingJsDownloader;
this.allDebridBatch = previousAllDebridBatch;
}
logger.error("[DownloadManager] Native torrent start error:", error);
}); });
await Promise.race([ const downloadWasCancelledOrReplaced =
startPromise, this.downloadingGameId !== downloadId;
new Promise<void>((resolve) => {
setTimeout(resolve, this.TORRENT_START_HANDSHAKE_TIMEOUT_MS); if (downloadWasCancelledOrReplaced) {
}), await PythonRPC.rpc
]); .post("/action", { action: "cancel", game_id: downloadId })
.catch((error) => {
logger.error(
"[DownloadManager] Failed to cancel stale torrent download",
error
);
});
return;
}
this.isPreparingDownload = false;
} catch (error) {
if (this.downloadingGameId === downloadId) {
this.downloadingGameId = previousDownloadingGameId;
this.isPreparingDownload = previousIsPreparingDownload;
this.usingJsDownloader = previousUsingJsDownloader;
this.allDebridBatch = previousAllDebridBatch;
}
throw error;
}
} }
} }
} }
@@ -11,7 +11,6 @@ export interface JsHttpDownloaderStatus {
downloadSpeed: number; downloadSpeed: number;
numPeers: number; numPeers: number;
numSeeds: number; numSeeds: number;
estimatedSeeds: number;
status: "active" | "paused" | "complete" | "error"; status: "active" | "paused" | "complete" | "error";
bytesDownloaded: number; bytesDownloaded: number;
} }
@@ -600,7 +599,6 @@ export class JsHttpDownloader {
downloadSpeed: this.downloadSpeed, downloadSpeed: this.downloadSpeed,
numPeers: 0, numPeers: 0,
numSeeds: 0, numSeeds: 0,
estimatedSeeds: 0,
status: this.status, status: this.status,
bytesDownloaded: this.bytesDownloaded, bytesDownloaded: this.bytesDownloaded,
}; };
-1
View File
@@ -18,7 +18,6 @@ export interface LibtorrentPayload {
progress: number; progress: number;
numPeers: number; numPeers: number;
numSeeds: number; numSeeds: number;
estimatedSeeds: number;
downloadSpeed: number; downloadSpeed: number;
uploadSpeed: number; uploadSpeed: number;
bytesDownloaded: number; bytesDownloaded: number;
+1 -106
View File
@@ -3,8 +3,7 @@ import path from "node:path";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { app } from "electron"; import { app } from "electron";
import type { ProcessPayload, LibtorrentPayload } from "./download/types"; import type { ProcessPayload } from "./download/types";
import type { TorrentFilesResponse } from "@types";
import { logger } from "./logger"; import { logger } from "./logger";
@@ -21,33 +20,6 @@ type HydraNativeModule = {
targetExtension?: string targetExtension?: string
) => NativeProcessProfileImageResponse; ) => NativeProcessProfileImageResponse;
listProcesses: () => ProcessPayload[]; listProcesses: () => ProcessPayload[];
torrentGetStatus: (gameId: string) => LibtorrentPayload | null;
torrentGetSeedStatus: () => Array<LibtorrentPayload & { gameId: string }>;
torrentGetFiles: (
magnet: string,
timeoutMs?: number
) => Promise<TorrentFilesResponse>;
torrentStart: (payload: {
gameId: string;
url: string;
savePath: string;
folderName?: string;
fileIndices?: number[];
timeoutMs?: number;
}) => Promise<void>;
torrentPause: (gameId: string) => void;
torrentCancel: (gameId: string) => void;
torrentResumeSeeding: (payload: {
gameId: string;
url: string;
savePath: string;
folderName?: string;
}) => void;
torrentPauseSeeding: (gameId: string) => void;
torrentSetDownloadLimit: (
maxDownloadSpeedBytesPerSecond?: number | null
) => void;
torrentBackend?: () => string;
}; };
export class NativeAddon { export class NativeAddon {
@@ -84,28 +56,6 @@ export class NativeAddon {
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const nativeModule = require(addonPath) as HydraNativeModule; const nativeModule = require(addonPath) as HydraNativeModule;
try {
const backend = nativeModule.torrentBackend?.();
if (backend === "libtorrent") {
logger.log(`[NativeAddon] Torrent backend: ${backend}`);
} else if (backend) {
throw new Error(
`Unsupported native torrent backend '${backend}'. Expected 'libtorrent'.`
);
} else if (!app.isPackaged) {
throw new Error(
"Native addon does not expose torrent backend identifier. This usually means a stale hydra-native.node build. Rebuild with `npm run build:native` after installing libtorrent dev packages."
);
} else {
logger.warn(
"[NativeAddon] Torrent backend identifier unavailable (stale native addon binary?)"
);
}
} catch (error) {
logger.error("[NativeAddon] Failed backend validation", error);
throw error;
}
this.nativeModule = nativeModule; this.nativeModule = nativeModule;
return nativeModule; return nativeModule;
@@ -158,59 +108,4 @@ export class NativeAddon {
return []; return [];
} }
} }
public static getTorrentStatus(gameId: string): LibtorrentPayload | null {
return this.load().torrentGetStatus(gameId);
}
public static getTorrentSeedStatus(): Array<
LibtorrentPayload & { gameId: string }
> {
return this.load().torrentGetSeedStatus();
}
public static async getTorrentFiles(
magnet: string,
timeoutMs?: number
): Promise<TorrentFilesResponse> {
return this.load().torrentGetFiles(magnet, timeoutMs);
}
public static async startTorrentDownload(payload: {
gameId: string;
url: string;
savePath: string;
folderName?: string;
fileIndices?: number[];
timeoutMs?: number;
}) {
return 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;
folderName?: string;
}) {
this.load().torrentResumeSeeding(payload);
}
public static pauseTorrentSeeding(gameId: string) {
this.load().torrentPauseSeeding(gameId);
}
public static setTorrentDownloadLimit(
maxDownloadSpeedBytesPerSecond?: number | null
) {
this.load().torrentSetDownloadLimit(maxDownloadSpeedBytesPerSecond);
}
} }
+443
View File
@@ -0,0 +1,443 @@
import cp from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pythonRpcLogger } from "./logger";
import { Readable } from "node:stream";
import { app, dialog } from "electron";
interface GamePayload {
action: string;
game_id: string;
url: string | string[];
save_path: string;
header?: string;
out?: string;
total_size?: number;
file_indices?: number[];
metadata_timeout_ms?: number;
}
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: "hydra-python-rpc",
linux: "hydra-python-rpc",
win32: "hydra-python-rpc.exe",
};
type PythonRpcMethod = "status" | "seed_status" | "torrent_files" | "action";
type PythonRpcResponse<T = unknown> =
| {
id: number;
result: T;
}
| {
id: number;
error: {
code: string;
message: string;
};
}
| {
event: "ready";
protocolVersion: number;
};
type PendingRpcRequest = {
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
timer: NodeJS.Timeout;
};
type RpcRequestConfig = {
timeout?: number;
};
export class PythonRpcError extends Error {
public readonly code: string;
public readonly response: {
data: {
error: string;
};
};
constructor(code: string, message?: string) {
super(message || code);
this.name = "PythonRpcError";
this.code = code;
this.response = {
data: {
error: code,
},
};
}
}
export class PythonRPC {
public static readonly BITTORRENT_PORT = "5881";
public static readonly rpc = {
get: async <T>(path: string, config?: RpcRequestConfig) => {
const data = await this.request<T>(this.pathToMethod(path), undefined, {
timeout: config?.timeout,
});
return { data };
},
post: async <T>(
path: string,
payload?: unknown,
config?: RpcRequestConfig
) => {
const data = await this.request<T>(this.pathToMethod(path), payload, {
timeout: config?.timeout,
});
return { data };
},
};
private static pythonProcess: cp.ChildProcess | null = null;
private static pendingRequests = new Map<number, PendingRpcRequest>();
private static nextRequestId = 1;
private static stdoutBuffer = "";
private static rpcPassword = "";
private static pythonExecutable: string | null = null;
private static ready = false;
private static readyPromise: Promise<void> | null = null;
private static readyResolver: (() => void) | null = null;
private static readyRejecter: ((error: unknown) => void) | null = null;
private static pathToMethod(pathname: string): PythonRpcMethod {
switch (pathname) {
case "/status":
return "status";
case "/seed-status":
return "seed_status";
case "/torrent-files":
return "torrent_files";
case "/action":
return "action";
default:
throw new Error(`Unsupported Python RPC path: ${pathname}`);
}
}
private static logStderr(readable: Readable | null) {
if (!readable) return;
readable.setEncoding("utf-8");
readable.on("data", pythonRpcLogger.log);
}
private static logStdout(readable: Readable | null) {
if (!readable) return;
readable.setEncoding("utf-8");
readable.on("data", (chunk: string) => {
this.stdoutBuffer += chunk;
this.processStdoutBuffer();
});
}
private static processStdoutBuffer() {
let newlineIndex = this.stdoutBuffer.indexOf("\n");
while (newlineIndex >= 0) {
const rawLine = this.stdoutBuffer.slice(0, newlineIndex);
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
this.handleStdoutLine(rawLine);
newlineIndex = this.stdoutBuffer.indexOf("\n");
}
}
private static handleStdoutLine(line: string) {
const payload = line.trim();
if (!payload) return;
let parsed: PythonRpcResponse;
try {
parsed = JSON.parse(payload) as PythonRpcResponse;
} catch {
pythonRpcLogger.error(`Failed to parse RPC stdout line: ${payload}`);
return;
}
if ("event" in parsed && parsed.event === "ready") {
this.markReady();
return;
}
if (!("id" in parsed) || typeof parsed.id !== "number") {
pythonRpcLogger.error(`Unexpected RPC message: ${payload}`);
return;
}
const pending = this.pendingRequests.get(parsed.id);
if (!pending) {
pythonRpcLogger.error(`No pending request for RPC id ${parsed.id}`);
return;
}
clearTimeout(pending.timer);
this.pendingRequests.delete(parsed.id);
if ("error" in parsed) {
pending.reject(
new PythonRpcError(parsed.error.code, parsed.error.message)
);
return;
}
pending.resolve(parsed.result);
}
private static markReady() {
if (this.ready) return;
this.ready = true;
if (this.readyResolver) {
this.readyResolver();
}
this.readyResolver = null;
this.readyRejecter = null;
}
private static resetReadyState() {
this.ready = false;
this.readyPromise = new Promise<void>((resolve, reject) => {
this.readyResolver = resolve;
this.readyRejecter = reject;
});
}
private static rejectAllPendingRequests(error: unknown) {
for (const pending of this.pendingRequests.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pendingRequests.clear();
}
private static handleProcessExit(reason: string) {
const error = new Error(`Python RPC exited: ${reason}`);
this.rejectAllPendingRequests(error);
if (this.readyRejecter && !this.ready) {
this.readyRejecter(error);
}
this.readyPromise = null;
this.readyResolver = null;
this.readyRejecter = null;
this.ready = false;
this.stdoutBuffer = "";
this.pythonProcess = null;
}
private static async request<T>(
method: PythonRpcMethod,
params?: unknown,
config?: RpcRequestConfig
): Promise<T> {
if (!this.pythonProcess) {
await this.spawn();
}
try {
await this.ensureReady();
} catch (error) {
pythonRpcLogger.error("Python RPC not ready, restarting process", error);
this.kill();
await this.spawn();
await this.ensureReady();
}
if (!this.pythonProcess || !this.pythonProcess.stdin) {
throw new Error("Python RPC process is not available");
}
const timeoutMs = Math.max(config?.timeout ?? 10_000, 1_000);
const id = this.nextRequestId++;
const payload = {
id,
method,
params: params ?? {},
rpc_password: this.rpcPassword,
};
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Python RPC timeout for method '${method}'`));
}, timeoutMs);
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timer,
});
const requestLine = `${JSON.stringify(payload)}\n`;
const didWrite = this.pythonProcess?.stdin?.write(requestLine);
if (!didWrite) {
this.pythonProcess?.stdin?.once("drain", () => {
// Request remains pending and will resolve when response arrives.
});
}
});
}
public static async ensureReady(timeoutMs = 10_000): Promise<void> {
if (this.ready) return;
if (!this.readyPromise) {
throw new Error("Python RPC process is not running");
}
await Promise.race([
this.readyPromise,
new Promise<void>((_, reject) => {
setTimeout(
() => reject(new Error("Python RPC startup timeout")),
timeoutMs
);
}),
]);
}
public static async spawn(
initialDownload?: GamePayload,
initialSeeding?: GamePayload[]
) {
if (this.pythonProcess) {
await this.ensureReady().catch(() => {
this.kill();
});
if (this.pythonProcess) return;
}
this.rpcPassword = Math.random().toString(36).slice(2);
this.resetReadyState();
this.stdoutBuffer = "";
const commonArgs = [
this.BITTORRENT_PORT,
this.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();
throw new Error(`Hydra Python RPC binary not found at ${binaryPath}`);
}
const childProcess = cp.spawn(binaryPath, commonArgs, {
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
});
this.logStderr(childProcess.stderr);
this.logStdout(childProcess.stdout);
this.pythonProcess = childProcess;
} else {
const pythonExecutable = this.resolvePythonExecutable();
const scriptPath = path.join(
__dirname,
"..",
"..",
"python_rpc",
"main.py"
);
const childProcess = cp.spawn(
pythonExecutable,
[scriptPath, ...commonArgs],
{
stdio: ["pipe", "pipe", "pipe"],
}
);
this.logStderr(childProcess.stderr);
this.logStdout(childProcess.stdout);
this.pythonProcess = childProcess;
}
this.pythonProcess.once("error", (error) => {
this.handleProcessExit(String(error));
});
this.pythonProcess.once("exit", (code, signal) => {
this.handleProcessExit(
`code=${code ?? "null"} signal=${signal ?? "null"}`
);
});
if (!this.pythonProcess) {
throw new Error("Failed to start Python RPC process");
}
await this.ensureReady();
}
private static resolvePythonExecutable() {
if (this.pythonExecutable) {
return this.pythonExecutable;
}
const candidates = [
process.env.HYDRA_PYTHON_BIN,
process.env.PYTHON,
"python3",
"python",
].filter((value): value is string => Boolean(value));
for (const candidate of candidates) {
const check = cp.spawnSync(candidate, ["--version"], {
stdio: "ignore",
});
if (!check.error) {
this.pythonExecutable = candidate;
return candidate;
}
}
throw new Error(
"Python executable not found. Set HYDRA_PYTHON_BIN or install python3/python."
);
}
public static kill() {
if (this.pythonProcess) {
pythonRpcLogger.log("Killing python process");
this.pythonProcess.kill();
}
this.handleProcessExit("killed");
}
}
@@ -16,12 +16,6 @@ export function SettingsContextDownloads() {
(state) => state.userPreferences.value (state) => state.userPreferences.value
); );
const lastPacket = useAppSelector((state) => state.download.lastPacket);
const hasActiveDownload =
lastPacket !== null &&
lastPacket.progress < 1 &&
!lastPacket.isDownloadingMetadata;
const formatLimitInputValue = ( const formatLimitInputValue = (
value: number, value: number,
useMegabytes: boolean useMegabytes: boolean
@@ -50,7 +44,6 @@ export function SettingsContextDownloads() {
}; };
const [form, setForm] = useState({ const [form, setForm] = useState({
useNativeHttpDownloader: true,
seedAfterDownloadComplete: false, seedAfterDownloadComplete: false,
showDownloadSpeedInMegabytes: false, showDownloadSpeedInMegabytes: false,
extractFilesByDefault: true, extractFilesByDefault: true,
@@ -62,7 +55,6 @@ export function SettingsContextDownloads() {
if (!userPreferences) return; if (!userPreferences) return;
setForm({ setForm({
useNativeHttpDownloader: userPreferences.useNativeHttpDownloader ?? true,
seedAfterDownloadComplete: seedAfterDownloadComplete:
userPreferences.seedAfterDownloadComplete ?? false, userPreferences.seedAfterDownloadComplete ?? false,
showDownloadSpeedInMegabytes: showDownloadSpeedInMegabytes:
@@ -141,17 +133,6 @@ export function SettingsContextDownloads() {
<div className="settings-context-panel__group"> <div className="settings-context-panel__group">
<h3>{t("download_behavior")}</h3> <h3>{t("download_behavior")}</h3>
<CheckboxField
label={t("use_native_http_downloader")}
checked={form.useNativeHttpDownloader}
disabled={hasActiveDownload}
onChange={() =>
handleChange({
useNativeHttpDownloader: !form.useNativeHttpDownloader,
})
}
/>
<TextField <TextField
type="number" type="number"
min="0" min="0"
@@ -175,12 +156,6 @@ export function SettingsContextDownloads() {
placeholder={t("max_download_speed_unlimited")} placeholder={t("max_download_speed_unlimited")}
/> />
{hasActiveDownload && (
<p className="settings-general__disabled-hint">
{t("cannot_change_downloader_while_downloading")}
</p>
)}
<CheckboxField <CheckboxField
label={t("seed_after_download_complete")} label={t("seed_after_download_complete")}
checked={form.seedAfterDownloadComplete} checked={form.seedAfterDownloadComplete}
@@ -37,12 +37,6 @@ export function SettingsGeneral() {
(state) => state.userPreferences.value (state) => state.userPreferences.value
); );
const lastPacket = useAppSelector((state) => state.download.lastPacket);
const hasActiveDownload =
lastPacket !== null &&
lastPacket.progress < 1 &&
!lastPacket.isDownloadingMetadata;
const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false); const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false);
const [installingCommonRedist, setInstallingCommonRedist] = useState(false); const [installingCommonRedist, setInstallingCommonRedist] = useState(false);
@@ -59,7 +53,6 @@ export function SettingsGeneral() {
achievementSoundVolume: 15, achievementSoundVolume: 15,
language: "", language: "",
customStyles: window.localStorage.getItem("customStyles") || "", customStyles: window.localStorage.getItem("customStyles") || "",
useNativeHttpDownloader: true,
}); });
const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]); const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]);
@@ -138,8 +131,6 @@ export function SettingsGeneral() {
friendStartGameNotificationsEnabled: friendStartGameNotificationsEnabled:
userPreferences.friendStartGameNotificationsEnabled ?? true, userPreferences.friendStartGameNotificationsEnabled ?? true,
language: language ?? "en", language: language ?? "en",
useNativeHttpDownloader:
userPreferences.useNativeHttpDownloader ?? true,
})); }));
} }
}, [userPreferences, defaultDownloadsPath]); }, [userPreferences, defaultDownloadsPath]);
@@ -259,23 +250,6 @@ export function SettingsGeneral() {
<h2 className="settings-general__section-title">{t("downloads")}</h2> <h2 className="settings-general__section-title">{t("downloads")}</h2>
<CheckboxField
label={t("use_native_http_downloader")}
checked={form.useNativeHttpDownloader}
disabled={hasActiveDownload}
onChange={() =>
handleChange({
useNativeHttpDownloader: !form.useNativeHttpDownloader,
})
}
/>
{hasActiveDownload && (
<p className="settings-general__disabled-hint">
{t("cannot_change_downloader_while_downloading")}
</p>
)}
<h2 className="settings-general__section-title">{t("notifications")}</h2> <h2 className="settings-general__section-title">{t("notifications")}</h2>
<CheckboxField <CheckboxField
-1
View File
@@ -15,7 +15,6 @@ export interface DownloadProgress {
timeRemaining: number; timeRemaining: number;
numPeers: number; numPeers: number;
numSeeds: number; numSeeds: number;
estimatedSeeds: number;
isDownloadingMetadata: boolean; isDownloadingMetadata: boolean;
isCheckingFiles: boolean; isCheckingFiles: boolean;
progress: number; progress: number;
-1
View File
@@ -139,7 +139,6 @@ export interface UserPreferences {
autoplayGameTrailers?: boolean; autoplayGameTrailers?: boolean;
hideToTrayOnGameStart?: boolean; hideToTrayOnGameStart?: boolean;
enableNewDownloadOptionsBadges?: boolean; enableNewDownloadOptionsBadges?: boolean;
useNativeHttpDownloader?: boolean;
createStartMenuShortcut?: boolean; createStartMenuShortcut?: boolean;
maxDownloadSpeedBytesPerSecond?: number | null; maxDownloadSpeedBytesPerSecond?: number | null;
defaultProtonPath?: string | null; defaultProtonPath?: string | null;