mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-06-01 22:09:27 +02:00
refactor: migrate torrent backend to python RPC service
This commit is contained in:
@@ -79,6 +79,23 @@ jobs:
|
||||
- name: Install dependencies
|
||||
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
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
|
||||
@@ -77,6 +77,23 @@ jobs:
|
||||
- name: Install dependencies
|
||||
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
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
|
||||
@@ -18,5 +18,3 @@ native/hydra-native/target/
|
||||
.env.sentry-build-plugin
|
||||
|
||||
*storybook.log
|
||||
|
||||
aria2/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra Launcher is an open-source gaming platform created to be the single tool that you need in order to manage your gaming library. Hydra is written in Node.js (Electron, React, Typescript) and 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>
|
||||
|
||||
[](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
|
||||
|
||||
- Node.js + Yarn
|
||||
- Python 3.9+ with `pip install -r requirements.txt`
|
||||
- Rust toolchain (for `hydra-native`)
|
||||
- `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`).
|
||||
|
||||
Packaging scripts (`yarn build:win`, `yarn build:mac`, `yarn build:linux`, `yarn build:unpack`) now run `yarn build:python-rpc` automatically.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
|
||||
|
||||
@@ -4,6 +4,7 @@ directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- ludusavi
|
||||
- hydra-python-rpc
|
||||
- hydra-native
|
||||
- seeds
|
||||
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
||||
|
||||
+5
-4
@@ -25,10 +25,11 @@
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "npm run build:native && electron-builder install-app-deps && node ./scripts/postinstall.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build:native && electron-vite build && electron-builder --win",
|
||||
"build:mac": "npm run build:native && electron-vite build && electron-builder --mac",
|
||||
"build:linux": "npm run build:native && electron-vite build && electron-builder --linux",
|
||||
"build:python-rpc": "python3 python_rpc/setup.py build || python python_rpc/setup.py build",
|
||||
"build:unpack": "npm run build && npm run build:python-rpc && electron-builder --dir",
|
||||
"build:win": "npm run build:native && npm run build:python-rpc && electron-vite build && electron-builder --win",
|
||||
"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",
|
||||
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
)]
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
libtorrent
|
||||
cx_Freeze == 7.2.3
|
||||
cx_Logging; sys_platform == 'win32'
|
||||
pywin32; sys_platform == 'win32'
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -754,8 +754,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -784,8 +784,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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"
|
||||
|
||||
@@ -718,8 +718,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -754,8 +754,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -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.",
|
||||
"enable_new_download_options_badges": "Afficher les badges des nouvelles options de téléchargement",
|
||||
"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 qu’un 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",
|
||||
"launch_hydra_in_library_page": "Lancer Hydra sur la page Bibliothèque",
|
||||
"enable_premiumize": "Activer Premiumize",
|
||||
|
||||
@@ -729,8 +729,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"content_gameplay": "Content & gameplay",
|
||||
"integrations": "Integrations",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -729,8 +729,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"content_gameplay": "Content & gameplay",
|
||||
"integrations": "Integrations",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -754,8 +754,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -754,8 +754,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -762,8 +762,6 @@
|
||||
"autoplay_trailers_on_game_page": "Automatycznie odtwarzaj zwiastuny na stronie gry",
|
||||
"hide_to_tray_on_game_start": "Ukryj Hydrę do zasobnika przy uruchomieniu gry",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -704,8 +704,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -669,8 +669,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -754,8 +754,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -721,8 +721,6 @@
|
||||
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
|
||||
"hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры",
|
||||
"downloads": "Загрузки",
|
||||
"use_native_http_downloader": "Использовать встроенный HTTP-загрузчик (экспериментально)",
|
||||
"cannot_change_downloader_while_downloading": "Нельзя изменить эту настройку во время загрузки",
|
||||
"create_shortcuts_on_download": "Создавать ярлыки на рабочем столе и в меню «Пуск» после завершения загрузки игры",
|
||||
"default_proton_version": "Версия Proton по умолчанию",
|
||||
"default_proton_version_description": "Выберите версию Proton, которую использует автоматический режим, если для игры не задана отдельная настройка",
|
||||
|
||||
@@ -761,8 +761,6 @@
|
||||
"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",
|
||||
"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": {
|
||||
"open": "Odpri Hydreo",
|
||||
"quit": "Izhod"
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"run_games_with_mangohud": "Run games with MangoHud by default",
|
||||
"enable_new_download_options_badges": "Show new download options badges",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -757,8 +757,6 @@
|
||||
"remove": "Remove",
|
||||
"no_sound_file_selected": "No sound file selected",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
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 { DownloadError } from "@shared";
|
||||
|
||||
const mapTorrentFilesError = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
switch (error.message) {
|
||||
const rpcError =
|
||||
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":
|
||||
return DownloadError.InvalidMagnet;
|
||||
case "metadata_timeout":
|
||||
@@ -14,11 +20,17 @@ const mapTorrentFilesError = (error: unknown) => {
|
||||
return DownloadError.TorrentMetadataIncomplete;
|
||||
case "too_many_files":
|
||||
return DownloadError.TorrentTooManyFiles;
|
||||
case "metadata_busy":
|
||||
return DownloadError.TorrentMetadataTimeout;
|
||||
default:
|
||||
return DownloadError.TorrentFilesUnavailable;
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return DownloadError.TorrentFilesUnavailable;
|
||||
}
|
||||
|
||||
return DownloadError.TorrentFilesUnavailable;
|
||||
};
|
||||
|
||||
@@ -31,11 +43,20 @@ const getTorrentFiles = async (
|
||||
}
|
||||
|
||||
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 {
|
||||
ok: true,
|
||||
data: response as TorrentFilesResponse,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PowerSaveBlockerManager,
|
||||
} from "@main/services";
|
||||
import resources from "@locales";
|
||||
import { PythonRPC } from "./services/python-rpc";
|
||||
import { db, gamesSublevel, levelKeys } from "./level";
|
||||
import { GameShop, UserPreferences } from "@types";
|
||||
import { launchGame } from "./helpers";
|
||||
@@ -279,6 +280,8 @@ app.on("before-quit", async (e) => {
|
||||
if (!canAppBeClosed) {
|
||||
e.preventDefault();
|
||||
PowerSaveBlockerManager.reset();
|
||||
/* Disconnects libtorrent */
|
||||
PythonRPC.kill();
|
||||
await clearGamesPlaytime();
|
||||
canAppBeClosed = true;
|
||||
app.quit();
|
||||
|
||||
+23
-63
@@ -119,11 +119,7 @@ export const loadState = async () => {
|
||||
|
||||
// Find interrupted active download (download that was running when app closed)
|
||||
// Mark it as paused but remember it for auto-resume
|
||||
if (
|
||||
download.status === "active" &&
|
||||
!interruptedDownload &&
|
||||
download.queued
|
||||
) {
|
||||
if (download.status === "active" && !interruptedDownload) {
|
||||
interruptedDownload = download;
|
||||
await downloadsSublevel.put(downloadKey, {
|
||||
...download,
|
||||
@@ -148,32 +144,24 @@ export const loadState = async () => {
|
||||
|
||||
for (const download of updatedDownloads) {
|
||||
const downloadKey = levelKeys.game(download.shop, download.objectId);
|
||||
|
||||
const shouldResetQueueFlag =
|
||||
const hasInvalidQueuedState =
|
||||
download.queued &&
|
||||
["removed", "complete", "seeding"].includes(download.status ?? "");
|
||||
(download.status === "removed" ||
|
||||
download.status === "complete" ||
|
||||
download.status === "seeding");
|
||||
|
||||
if (
|
||||
shouldResetQueueFlag ||
|
||||
(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);
|
||||
if (!hasInvalidQueuedState) {
|
||||
normalizedDownloads.push(download);
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedDownloads.push(download);
|
||||
const normalizedDownload = {
|
||||
...download,
|
||||
queued: false,
|
||||
};
|
||||
|
||||
await downloadsSublevel.put(downloadKey, normalizedDownload);
|
||||
normalizedDownloads.push(normalizedDownload);
|
||||
}
|
||||
|
||||
// Prioritize interrupted download, then queued downloads
|
||||
@@ -184,15 +172,6 @@ export const loadState = async () => {
|
||||
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[] = [];
|
||||
|
||||
for (const game of normalizedDownloads) {
|
||||
@@ -200,8 +179,8 @@ export const loadState = async () => {
|
||||
!game.shouldSeed ||
|
||||
game.downloader !== Downloader.Torrent ||
|
||||
game.progress !== 1 ||
|
||||
game.uri === null ||
|
||||
game.status !== "seeding"
|
||||
game.status !== "seeding" ||
|
||||
game.uri === null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -217,7 +196,9 @@ export const loadState = async () => {
|
||||
|
||||
if (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 =
|
||||
expectedSize > 0
|
||||
? Math.min(currentSize / expectedSize, 1)
|
||||
@@ -226,50 +207,29 @@ export const loadState = async () => {
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...game,
|
||||
status: "error",
|
||||
status: "paused",
|
||||
shouldSeed: false,
|
||||
queued: false,
|
||||
progress,
|
||||
});
|
||||
|
||||
logger.warn(
|
||||
`[Startup] Seed files missing for ${gameKey}; moved download state to error`
|
||||
`[Startup] Seed files missing for ${gameKey}; seeding was disabled`
|
||||
);
|
||||
}
|
||||
|
||||
logger.log(
|
||||
`[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
|
||||
// For torrents use Python RPC; HTTP downloads use JS downloader.
|
||||
const isTorrent = downloadToResume?.downloader === Downloader.Torrent;
|
||||
// Default to true - native HTTP downloader is enabled by default
|
||||
const useJsDownloader =
|
||||
(userPreferences?.useNativeHttpDownloader ?? true) && !isTorrent;
|
||||
|
||||
if (useJsDownloader && downloadToResume) {
|
||||
if (downloadToResume && !isTorrent) {
|
||||
// Start Python RPC for seeding only, then resume HTTP download with JS
|
||||
await DownloadManager.startRPC(undefined, downloadsToSeed);
|
||||
logger.log(
|
||||
"[Startup] Started RPC for seeding bootstrap (JS downloader path)"
|
||||
);
|
||||
await DownloadManager.startDownload(downloadToResume).catch((err) => {
|
||||
// If resume fails, just log it - user can manually retry
|
||||
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 {
|
||||
// Use Python RPC for everything (torrent or fallback)
|
||||
await DownloadManager.startRPC(downloadToResume, downloadsToSeed);
|
||||
logger.log(
|
||||
`[Startup] RPC bootstrap complete (resume=${downloadToResume ? levelKeys.game(downloadToResume.shop, downloadToResume.objectId) : "none"}, js=${useJsDownloader})`
|
||||
);
|
||||
}
|
||||
|
||||
startMainLoop();
|
||||
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
VikingFileApi,
|
||||
RootzApi,
|
||||
} from "../hosters";
|
||||
import { NativeAddon } from "../native-addon";
|
||||
import { LibtorrentStatus } from "./types";
|
||||
import { PythonRPC } from "../python-rpc";
|
||||
import {
|
||||
LibtorrentPayload,
|
||||
LibtorrentStatus,
|
||||
PauseDownloadPayload,
|
||||
} from "./types";
|
||||
import { calculateETA, getDirSize } from "./helpers";
|
||||
import { RealDebridClient } from "./real-debrid";
|
||||
import path from "node:path";
|
||||
@@ -54,7 +58,6 @@ export class DownloadManager {
|
||||
private static isPreparingDownload = false;
|
||||
private static allDebridBatch: AllDebridBatchState | null = null;
|
||||
private static maxDownloadSpeedBytesPerSecond: number | null = null;
|
||||
private static readonly TORRENT_START_HANDSHAKE_TIMEOUT_MS = 1_500;
|
||||
|
||||
public static hasActiveDownload() {
|
||||
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 {
|
||||
return downloader !== Downloader.Torrent;
|
||||
}
|
||||
@@ -218,32 +217,25 @@ export class DownloadManager {
|
||||
this.maxDownloadSpeedBytesPerSecond = normalizedLimit;
|
||||
this.jsDownloader?.setMaxDownloadSpeedBytesPerSecond(normalizedLimit);
|
||||
|
||||
try {
|
||||
NativeAddon.setTorrentDownloadLimit(normalizedLimit);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[DownloadManager] Failed to update torrent 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)}`);
|
||||
await PythonRPC.rpc
|
||||
.post("/action", {
|
||||
action: "set_download_limit",
|
||||
max_download_speed_bytes_per_second: normalizedLimit,
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
"[DownloadManager] Failed to update RPC download speed limit:",
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public static async startRPC(
|
||||
download?: Download,
|
||||
downloadsToSeed?: Download[]
|
||||
) {
|
||||
await PythonRPC.spawn();
|
||||
|
||||
if (downloadsToSeed?.length) {
|
||||
for (const seedDownload of downloadsToSeed) {
|
||||
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) => {
|
||||
logger.error("[DownloadManager] Failed to resume download", error);
|
||||
});
|
||||
@@ -275,7 +267,6 @@ export class DownloadManager {
|
||||
return {
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
estimatedSeeds: 0,
|
||||
downloadSpeed: 0,
|
||||
timeRemaining: -1,
|
||||
isDownloadingMetadata: true, // Use this to indicate "preparing"
|
||||
@@ -374,7 +365,6 @@ export class DownloadManager {
|
||||
return {
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
estimatedSeeds: 0,
|
||||
downloadSpeed,
|
||||
timeRemaining: calculateETA(
|
||||
effectiveFileSize ?? 0,
|
||||
@@ -396,31 +386,29 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> {
|
||||
if (!this.downloadingGameId) return null;
|
||||
const downloadId = this.downloadingGameId;
|
||||
let response: { data: LibtorrentPayload | null };
|
||||
|
||||
let response: ReturnType<typeof NativeAddon.getTorrentStatus> = null;
|
||||
try {
|
||||
response = NativeAddon.getTorrentStatus(downloadId);
|
||||
response = await PythonRPC.rpc.get<LibtorrentPayload | null>("/status");
|
||||
} catch (error) {
|
||||
logger.error("[DownloadManager] Native status poll failed", error);
|
||||
logger.error("[DownloadManager] RPC status poll failed", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response === null) return null;
|
||||
if (response.data === null || !this.downloadingGameId) return null;
|
||||
const downloadId = this.downloadingGameId;
|
||||
|
||||
try {
|
||||
const {
|
||||
progress,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
estimatedSeeds,
|
||||
downloadSpeed,
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
folderName,
|
||||
status,
|
||||
} = response;
|
||||
} = response.data;
|
||||
|
||||
const isDownloadingMetadata =
|
||||
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 {
|
||||
numPeers: shouldSuppressLiveMetrics ? 0 : numPeers,
|
||||
numSeeds: shouldSuppressLiveMetrics ? 0 : numSeeds,
|
||||
estimatedSeeds,
|
||||
downloadSpeed: shouldSuppressLiveMetrics ? 0 : downloadSpeed,
|
||||
timeRemaining: shouldSuppressLiveMetrics
|
||||
? -1
|
||||
: calculateETA(
|
||||
effectiveFileSize,
|
||||
reportedBytesDownloaded,
|
||||
downloadSpeed
|
||||
),
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
timeRemaining: calculateETA(
|
||||
fileSize > 0
|
||||
? fileSize
|
||||
: (download?.selectedFilesSize ?? download?.fileSize ?? 0),
|
||||
bytesDownloaded,
|
||||
downloadSpeed
|
||||
),
|
||||
isDownloadingMetadata,
|
||||
isCheckingFiles,
|
||||
progress: reportedProgress,
|
||||
progress,
|
||||
gameId: downloadId,
|
||||
download,
|
||||
} as DownloadProgress;
|
||||
@@ -635,7 +595,6 @@ export class DownloadManager {
|
||||
queued: false,
|
||||
extracting: shouldExtract,
|
||||
});
|
||||
|
||||
await this.cancelDownload(gameId);
|
||||
|
||||
return false;
|
||||
@@ -649,7 +608,7 @@ export class DownloadManager {
|
||||
: null;
|
||||
|
||||
if (!extractionPath || !fs.existsSync(extractionPath)) {
|
||||
gameFilesManager
|
||||
await gameFilesManager
|
||||
.failExtraction(new Error("No downloaded archive was found to extract"))
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
@@ -683,19 +642,17 @@ export class DownloadManager {
|
||||
} else if (extractionStats.isDirectory()) {
|
||||
await gameFilesManager
|
||||
.extractFilesInDirectory(extractionPath)
|
||||
.then((success) => {
|
||||
.then(async (success) => {
|
||||
if (success) {
|
||||
return gameFilesManager.setExtractionComplete();
|
||||
await gameFilesManager.setExtractionComplete();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
"[DownloadManager] Failed to extract files in directory",
|
||||
error
|
||||
);
|
||||
gameFilesManager.failExtraction(error).catch((failError) => {
|
||||
return gameFilesManager.failExtraction(error).catch((failError) => {
|
||||
logger.error(
|
||||
"[DownloadManager] Failed to persist extraction failure state",
|
||||
failError
|
||||
@@ -703,7 +660,7 @@ export class DownloadManager {
|
||||
});
|
||||
});
|
||||
} else {
|
||||
gameFilesManager
|
||||
await gameFilesManager
|
||||
.failExtraction(
|
||||
new Error(
|
||||
`Invalid extraction source type for "${download.folderName ?? "unknown"}"`
|
||||
@@ -743,12 +700,14 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
public static async getSeedStatus() {
|
||||
let seedStatus: ReturnType<typeof NativeAddon.getTorrentSeedStatus> = [];
|
||||
let seedStatus: LibtorrentPayload[] = [];
|
||||
|
||||
try {
|
||||
seedStatus = NativeAddon.getTorrentSeedStatus();
|
||||
seedStatus = await PythonRPC.rpc
|
||||
.get<LibtorrentPayload[] | []>("/seed-status")
|
||||
.then((res) => res.data);
|
||||
} 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", []);
|
||||
return;
|
||||
}
|
||||
@@ -774,9 +733,8 @@ export class DownloadManager {
|
||||
|
||||
await downloadsSublevel.put(status.gameId, {
|
||||
...download,
|
||||
status: "error",
|
||||
status: "paused",
|
||||
shouldSeed: false,
|
||||
queued: false,
|
||||
progress:
|
||||
status.fileSize > 0
|
||||
? Math.min(totalSize / status.fileSize, 1)
|
||||
@@ -795,11 +753,12 @@ export class DownloadManager {
|
||||
logger.log("[DownloadManager] Pausing JS download");
|
||||
this.jsDownloader.pauseDownload();
|
||||
} else if (downloadKey) {
|
||||
try {
|
||||
NativeAddon.pauseTorrentDownload(downloadKey);
|
||||
} catch {
|
||||
// ignore pause failures
|
||||
}
|
||||
await PythonRPC.rpc
|
||||
.post("/action", {
|
||||
action: "pause",
|
||||
game_id: downloadKey,
|
||||
} as PauseDownloadPayload)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (downloadKey === this.downloadingGameId) {
|
||||
@@ -822,12 +781,10 @@ export class DownloadManager {
|
||||
this.jsDownloader = null;
|
||||
this.usingJsDownloader = false;
|
||||
this.allDebridBatch = null;
|
||||
} else if (!this.isPreparingDownload) {
|
||||
try {
|
||||
NativeAddon.cancelTorrentDownload(downloadKey!);
|
||||
} catch (err) {
|
||||
logger.error("Failed to cancel game download", err);
|
||||
}
|
||||
} else {
|
||||
await PythonRPC.rpc
|
||||
.post("/action", { action: "cancel", game_id: downloadKey })
|
||||
.catch((err) => logger.error("Failed to cancel game download", err));
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
@@ -836,20 +793,27 @@ export class DownloadManager {
|
||||
this.isPreparingDownload = false;
|
||||
this.usingJsDownloader = false;
|
||||
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) {
|
||||
NativeAddon.resumeTorrentSeeding({
|
||||
gameId: levelKeys.game(download.shop, download.objectId),
|
||||
await PythonRPC.rpc.post("/action", {
|
||||
action: "resume_seeding",
|
||||
game_id: levelKeys.game(download.shop, download.objectId),
|
||||
url: download.uri,
|
||||
savePath: download.downloadPath,
|
||||
folderName: download.folderName ?? undefined,
|
||||
save_path: download.downloadPath,
|
||||
});
|
||||
}
|
||||
|
||||
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<{
|
||||
@@ -1314,12 +1278,19 @@ export class DownloadManager {
|
||||
};
|
||||
}
|
||||
case Downloader.Torrent:
|
||||
const hasSelectedFileIndices =
|
||||
Array.isArray(download.fileIndices) &&
|
||||
download.fileIndices.length > 0;
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
url: download.uri,
|
||||
save_path: download.downloadPath,
|
||||
file_indices: download.fileIndices,
|
||||
file_indices: hasSelectedFileIndices
|
||||
? download.fileIndices
|
||||
: undefined,
|
||||
metadata_timeout_ms: hasSelectedFileIndices ? 60_000 : undefined,
|
||||
};
|
||||
case Downloader.RealDebrid: {
|
||||
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
||||
@@ -1417,25 +1388,21 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
static async validateDownloadUrl(download: Download): Promise<void> {
|
||||
const useJsDownloader = await this.shouldUseJsDownloader();
|
||||
const isHttp = this.isHttpDownloader(download.downloader);
|
||||
|
||||
if (useJsDownloader && isHttp) {
|
||||
if (isHttp) {
|
||||
const options = await this.getJsDownloadOptions(download);
|
||||
if (!options) {
|
||||
throw new Error("Failed to validate download URL");
|
||||
}
|
||||
} else if (isHttp) {
|
||||
await this.getDownloadPayload(download);
|
||||
}
|
||||
}
|
||||
|
||||
static async startDownload(download: Download) {
|
||||
const useJsDownloader = await this.shouldUseJsDownloader();
|
||||
const isHttp = this.isHttpDownloader(download.downloader);
|
||||
const downloadId = levelKeys.game(download.shop, download.objectId);
|
||||
|
||||
if (useJsDownloader && isHttp) {
|
||||
if (isHttp) {
|
||||
logger.log("[DownloadManager] Using JS HTTP downloader");
|
||||
|
||||
// Set preparing state immediately so UI knows download is starting
|
||||
@@ -1511,7 +1478,8 @@ export class DownloadManager {
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
logger.log("[DownloadManager] Using native torrent downloader");
|
||||
logger.log("[DownloadManager] Using Python RPC downloader");
|
||||
const payload = await this.getDownloadPayload(download);
|
||||
const isSelectiveTorrentStart =
|
||||
download.downloader === Downloader.Torrent &&
|
||||
Array.isArray(download.fileIndices) &&
|
||||
@@ -1527,48 +1495,41 @@ export class DownloadManager {
|
||||
this.usingJsDownloader = false;
|
||||
this.allDebridBatch = null;
|
||||
|
||||
const startPromise = NativeAddon.startTorrentDownload({
|
||||
gameId: downloadId,
|
||||
url: download.uri,
|
||||
savePath: download.downloadPath,
|
||||
folderName: download.folderName ?? undefined,
|
||||
fileIndices: download.fileIndices,
|
||||
timeoutMs: isSelectiveTorrentStart ? 60_000 : 10_000,
|
||||
});
|
||||
if (payload?.url) {
|
||||
this.logResolvedUrl(payload.url);
|
||||
}
|
||||
|
||||
void startPromise
|
||||
.then(() => {
|
||||
const downloadWasCancelledOrReplaced =
|
||||
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);
|
||||
try {
|
||||
await PythonRPC.rpc.post("/action", payload, {
|
||||
timeout: isSelectiveTorrentStart ? 60_000 : 10_000,
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
startPromise,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, this.TORRENT_START_HANDSHAKE_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
const downloadWasCancelledOrReplaced =
|
||||
this.downloadingGameId !== downloadId;
|
||||
|
||||
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;
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
estimatedSeeds: number;
|
||||
status: "active" | "paused" | "complete" | "error";
|
||||
bytesDownloaded: number;
|
||||
}
|
||||
@@ -600,7 +599,6 @@ export class JsHttpDownloader {
|
||||
downloadSpeed: this.downloadSpeed,
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
estimatedSeeds: 0,
|
||||
status: this.status,
|
||||
bytesDownloaded: this.bytesDownloaded,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface LibtorrentPayload {
|
||||
progress: number;
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
estimatedSeeds: number;
|
||||
downloadSpeed: number;
|
||||
uploadSpeed: number;
|
||||
bytesDownloaded: number;
|
||||
|
||||
@@ -3,8 +3,7 @@ import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { app } from "electron";
|
||||
import type { ProcessPayload, LibtorrentPayload } from "./download/types";
|
||||
import type { TorrentFilesResponse } from "@types";
|
||||
import type { ProcessPayload } from "./download/types";
|
||||
|
||||
import { logger } from "./logger";
|
||||
|
||||
@@ -21,33 +20,6 @@ type HydraNativeModule = {
|
||||
targetExtension?: string
|
||||
) => NativeProcessProfileImageResponse;
|
||||
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 {
|
||||
@@ -84,28 +56,6 @@ export class NativeAddon {
|
||||
const require = createRequire(import.meta.url);
|
||||
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;
|
||||
|
||||
return nativeModule;
|
||||
@@ -158,59 +108,4 @@ export class NativeAddon {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
const lastPacket = useAppSelector((state) => state.download.lastPacket);
|
||||
const hasActiveDownload =
|
||||
lastPacket !== null &&
|
||||
lastPacket.progress < 1 &&
|
||||
!lastPacket.isDownloadingMetadata;
|
||||
|
||||
const formatLimitInputValue = (
|
||||
value: number,
|
||||
useMegabytes: boolean
|
||||
@@ -50,7 +44,6 @@ export function SettingsContextDownloads() {
|
||||
};
|
||||
|
||||
const [form, setForm] = useState({
|
||||
useNativeHttpDownloader: true,
|
||||
seedAfterDownloadComplete: false,
|
||||
showDownloadSpeedInMegabytes: false,
|
||||
extractFilesByDefault: true,
|
||||
@@ -62,7 +55,6 @@ export function SettingsContextDownloads() {
|
||||
if (!userPreferences) return;
|
||||
|
||||
setForm({
|
||||
useNativeHttpDownloader: userPreferences.useNativeHttpDownloader ?? true,
|
||||
seedAfterDownloadComplete:
|
||||
userPreferences.seedAfterDownloadComplete ?? false,
|
||||
showDownloadSpeedInMegabytes:
|
||||
@@ -141,17 +133,6 @@ export function SettingsContextDownloads() {
|
||||
<div className="settings-context-panel__group">
|
||||
<h3>{t("download_behavior")}</h3>
|
||||
|
||||
<CheckboxField
|
||||
label={t("use_native_http_downloader")}
|
||||
checked={form.useNativeHttpDownloader}
|
||||
disabled={hasActiveDownload}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
useNativeHttpDownloader: !form.useNativeHttpDownloader,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -175,12 +156,6 @@ export function SettingsContextDownloads() {
|
||||
placeholder={t("max_download_speed_unlimited")}
|
||||
/>
|
||||
|
||||
{hasActiveDownload && (
|
||||
<p className="settings-general__disabled-hint">
|
||||
{t("cannot_change_downloader_while_downloading")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<CheckboxField
|
||||
label={t("seed_after_download_complete")}
|
||||
checked={form.seedAfterDownloadComplete}
|
||||
|
||||
@@ -37,12 +37,6 @@ export function SettingsGeneral() {
|
||||
(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 [installingCommonRedist, setInstallingCommonRedist] = useState(false);
|
||||
|
||||
@@ -59,7 +53,6 @@ export function SettingsGeneral() {
|
||||
achievementSoundVolume: 15,
|
||||
language: "",
|
||||
customStyles: window.localStorage.getItem("customStyles") || "",
|
||||
useNativeHttpDownloader: true,
|
||||
});
|
||||
|
||||
const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]);
|
||||
@@ -138,8 +131,6 @@ export function SettingsGeneral() {
|
||||
friendStartGameNotificationsEnabled:
|
||||
userPreferences.friendStartGameNotificationsEnabled ?? true,
|
||||
language: language ?? "en",
|
||||
useNativeHttpDownloader:
|
||||
userPreferences.useNativeHttpDownloader ?? true,
|
||||
}));
|
||||
}
|
||||
}, [userPreferences, defaultDownloadsPath]);
|
||||
@@ -259,23 +250,6 @@ export function SettingsGeneral() {
|
||||
|
||||
<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>
|
||||
|
||||
<CheckboxField
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface DownloadProgress {
|
||||
timeRemaining: number;
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
estimatedSeeds: number;
|
||||
isDownloadingMetadata: boolean;
|
||||
isCheckingFiles: boolean;
|
||||
progress: number;
|
||||
|
||||
@@ -139,7 +139,6 @@ export interface UserPreferences {
|
||||
autoplayGameTrailers?: boolean;
|
||||
hideToTrayOnGameStart?: boolean;
|
||||
enableNewDownloadOptionsBadges?: boolean;
|
||||
useNativeHttpDownloader?: boolean;
|
||||
createStartMenuShortcut?: boolean;
|
||||
maxDownloadSpeedBytesPerSecond?: number | null;
|
||||
defaultProtonPath?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user