mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-02 16:23:48 +03:00
Merge pull request #1223 from hydralauncher/feature/seed-completed-downloads
Feature/seed completed downloads
This commit is contained in:
commit
1479c15312
@ -1,3 +1,2 @@
|
|||||||
MAIN_VITE_API_URL=API_URL
|
MAIN_VITE_API_URL=API_URL
|
||||||
MAIN_VITE_AUTH_URL=AUTH_URL
|
MAIN_VITE_AUTH_URL=AUTH_URL
|
||||||
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
|
|
||||||
|
@ -3,3 +3,4 @@ dist
|
|||||||
out
|
out
|
||||||
.gitignore
|
.gitignore
|
||||||
migration.stub
|
migration.stub
|
||||||
|
hydra-python-rpc/
|
||||||
|
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
|||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Build with cx_Freeze
|
- name: Build with cx_Freeze
|
||||||
run: python torrent-client/setup.py build
|
run: python python_rpc/setup.py build
|
||||||
|
|
||||||
- name: Build Linux
|
- name: Build Linux
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,7 +1,5 @@
|
|||||||
.vscode/
|
.vscode/
|
||||||
node_modules/
|
node_modules/
|
||||||
hydra-download-manager/
|
|
||||||
fastlist.exe
|
|
||||||
__pycache__
|
__pycache__
|
||||||
dist
|
dist
|
||||||
out
|
out
|
||||||
@ -9,4 +7,6 @@ out
|
|||||||
*.log*
|
*.log*
|
||||||
.env
|
.env
|
||||||
.vite
|
.vite
|
||||||
ludusavi/
|
ludusavi/
|
||||||
|
hydra-python-rpc/
|
||||||
|
aria2/
|
||||||
|
@ -3,8 +3,9 @@ productName: Hydra
|
|||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
extraResources:
|
extraResources:
|
||||||
|
- aria2
|
||||||
- ludusavi
|
- ludusavi
|
||||||
- hydra-download-manager
|
- hydra-python-rpc
|
||||||
- seeds
|
- seeds
|
||||||
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
||||||
- from: resources/achievement.wav
|
- from: resources/achievement.wav
|
||||||
|
@ -37,6 +37,7 @@
|
|||||||
"@fontsource/noto-sans": "^5.1.0",
|
"@fontsource/noto-sans": "^5.1.0",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@primer/octicons-react": "^19.9.0",
|
"@primer/octicons-react": "^19.9.0",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"@reduxjs/toolkit": "^2.2.3",
|
"@reduxjs/toolkit": "^2.2.3",
|
||||||
"@vanilla-extract/css": "^1.14.2",
|
"@vanilla-extract/css": "^1.14.2",
|
||||||
"@vanilla-extract/dynamic": "^2.1.2",
|
"@vanilla-extract/dynamic": "^2.1.2",
|
||||||
|
47
python_rpc/http_downloader.py
Normal file
47
python_rpc/http_downloader.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import aria2p
|
||||||
|
|
||||||
|
class HttpDownloader:
|
||||||
|
def __init__(self):
|
||||||
|
self.download = None
|
||||||
|
self.aria2 = aria2p.API(
|
||||||
|
aria2p.Client(
|
||||||
|
host="http://localhost",
|
||||||
|
port=6800,
|
||||||
|
secret=""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_download(self, url: str, save_path: str, header: str):
|
||||||
|
if self.download:
|
||||||
|
self.aria2.resume([self.download])
|
||||||
|
else:
|
||||||
|
downloads = self.aria2.add(url, options={"header": header, "dir": save_path})
|
||||||
|
self.download = downloads[0]
|
||||||
|
|
||||||
|
def pause_download(self):
|
||||||
|
if self.download:
|
||||||
|
self.aria2.pause([self.download])
|
||||||
|
|
||||||
|
def cancel_download(self):
|
||||||
|
if self.download:
|
||||||
|
self.aria2.remove([self.download])
|
||||||
|
self.download = None
|
||||||
|
|
||||||
|
def get_download_status(self):
|
||||||
|
if self.download == None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
download = self.aria2.get_download(self.download.gid)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
'folderName': str(download.dir) + "/" + download.name,
|
||||||
|
'fileSize': download.total_length,
|
||||||
|
'progress': download.completed_length / download.total_length if download.total_length else 0,
|
||||||
|
'downloadSpeed': download.download_speed,
|
||||||
|
'numPeers': 0,
|
||||||
|
'numSeeds': 0,
|
||||||
|
'status': download.status,
|
||||||
|
'bytesDownloaded': download.completed_length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
176
python_rpc/main.py
Normal file
176
python_rpc/main.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
from flask import Flask, request, jsonify
|
||||||
|
import sys, json, urllib.parse, psutil
|
||||||
|
from torrent_downloader import TorrentDownloader
|
||||||
|
from http_downloader import HttpDownloader
|
||||||
|
from profile_image_processor import ProfileImageProcessor
|
||||||
|
import libtorrent as lt
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Retrieve command line arguments
|
||||||
|
torrent_port = sys.argv[1]
|
||||||
|
http_port = sys.argv[2]
|
||||||
|
rpc_password = sys.argv[3]
|
||||||
|
start_download_payload = sys.argv[4]
|
||||||
|
start_seeding_payload = sys.argv[5]
|
||||||
|
|
||||||
|
downloads = {}
|
||||||
|
# This can be streamed down from Node
|
||||||
|
downloading_game_id = -1
|
||||||
|
|
||||||
|
torrent_session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=torrent_port)})
|
||||||
|
|
||||||
|
if start_download_payload:
|
||||||
|
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
|
||||||
|
downloading_game_id = initial_download['game_id']
|
||||||
|
|
||||||
|
if initial_download['url'].startswith('magnet'):
|
||||||
|
torrent_downloader = TorrentDownloader(torrent_session)
|
||||||
|
downloads[initial_download['game_id']] = torrent_downloader
|
||||||
|
torrent_downloader.start_download(initial_download['url'], initial_download['save_path'], "")
|
||||||
|
else:
|
||||||
|
http_downloader = HttpDownloader()
|
||||||
|
downloads[initial_download['game_id']] = http_downloader
|
||||||
|
http_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'))
|
||||||
|
|
||||||
|
if start_seeding_payload:
|
||||||
|
initial_seeding = json.loads(urllib.parse.unquote(start_seeding_payload))
|
||||||
|
for seed in initial_seeding:
|
||||||
|
torrent_downloader = TorrentDownloader(torrent_session)
|
||||||
|
downloads[seed['game_id']] = torrent_downloader
|
||||||
|
torrent_downloader.start_download(seed['url'], seed['save_path'], "")
|
||||||
|
|
||||||
|
def validate_rpc_password():
|
||||||
|
"""Middleware to validate RPC password."""
|
||||||
|
header_password = request.headers.get('x-hydra-rpc-password')
|
||||||
|
if header_password != rpc_password:
|
||||||
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
|
|
||||||
|
@app.route("/status", methods=["GET"])
|
||||||
|
def status():
|
||||||
|
auth_error = validate_rpc_password()
|
||||||
|
if auth_error:
|
||||||
|
return auth_error
|
||||||
|
|
||||||
|
downloader = downloads.get(downloading_game_id)
|
||||||
|
if downloader:
|
||||||
|
status = downloads.get(downloading_game_id).get_download_status()
|
||||||
|
return jsonify(status), 200
|
||||||
|
else:
|
||||||
|
return jsonify(None)
|
||||||
|
|
||||||
|
@app.route("/seed-status", methods=["GET"])
|
||||||
|
def seed_status():
|
||||||
|
auth_error = validate_rpc_password()
|
||||||
|
if auth_error:
|
||||||
|
return auth_error
|
||||||
|
|
||||||
|
seed_status = []
|
||||||
|
|
||||||
|
for game_id, downloader in downloads.items():
|
||||||
|
if not downloader:
|
||||||
|
continue
|
||||||
|
|
||||||
|
response = downloader.get_download_status()
|
||||||
|
if response is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.get('status') == 5:
|
||||||
|
seed_status.append({
|
||||||
|
'gameId': game_id,
|
||||||
|
**response,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(seed_status), 200
|
||||||
|
|
||||||
|
@app.route("/healthcheck", methods=["GET"])
|
||||||
|
def healthcheck():
|
||||||
|
return "", 200
|
||||||
|
|
||||||
|
@app.route("/process-list", methods=["GET"])
|
||||||
|
def process_list():
|
||||||
|
auth_error = validate_rpc_password()
|
||||||
|
if auth_error:
|
||||||
|
return auth_error
|
||||||
|
|
||||||
|
process_list = [proc.info for proc in psutil.process_iter(['exe', 'pid', 'name'])]
|
||||||
|
return jsonify(process_list), 200
|
||||||
|
|
||||||
|
@app.route("/profile-image", methods=["POST"])
|
||||||
|
def profile_image():
|
||||||
|
auth_error = validate_rpc_password()
|
||||||
|
if auth_error:
|
||||||
|
return auth_error
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
image_path = data.get('image_path')
|
||||||
|
|
||||||
|
try:
|
||||||
|
processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path)
|
||||||
|
return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@app.route("/action", methods=["POST"])
|
||||||
|
def action():
|
||||||
|
global torrent_session
|
||||||
|
global downloading_game_id
|
||||||
|
|
||||||
|
auth_error = validate_rpc_password()
|
||||||
|
if auth_error:
|
||||||
|
return auth_error
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
action = data.get('action')
|
||||||
|
game_id = data.get('game_id')
|
||||||
|
|
||||||
|
print(data)
|
||||||
|
|
||||||
|
if action == 'start':
|
||||||
|
url = data.get('url')
|
||||||
|
|
||||||
|
existing_downloader = downloads.get(game_id)
|
||||||
|
|
||||||
|
if url.startswith('magnet'):
|
||||||
|
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
|
||||||
|
existing_downloader.start_download(url, data['save_path'], "")
|
||||||
|
else:
|
||||||
|
torrent_downloader = TorrentDownloader(torrent_session)
|
||||||
|
downloads[game_id] = torrent_downloader
|
||||||
|
torrent_downloader.start_download(url, data['save_path'], "")
|
||||||
|
else:
|
||||||
|
if existing_downloader and isinstance(existing_downloader, HttpDownloader):
|
||||||
|
existing_downloader.start_download(url, data['save_path'], data.get('header'))
|
||||||
|
else:
|
||||||
|
http_downloader = HttpDownloader()
|
||||||
|
downloads[game_id] = http_downloader
|
||||||
|
http_downloader.start_download(url, data['save_path'], data.get('header'))
|
||||||
|
|
||||||
|
downloading_game_id = game_id
|
||||||
|
|
||||||
|
elif action == 'pause':
|
||||||
|
downloader = downloads.get(game_id)
|
||||||
|
if downloader:
|
||||||
|
downloader.pause_download()
|
||||||
|
downloading_game_id = -1
|
||||||
|
elif action == 'cancel':
|
||||||
|
downloader = downloads.get(game_id)
|
||||||
|
if downloader:
|
||||||
|
downloader.cancel_download()
|
||||||
|
elif action == 'resume_seeding':
|
||||||
|
torrent_downloader = TorrentDownloader(torrent_session)
|
||||||
|
downloads[game_id] = torrent_downloader
|
||||||
|
torrent_downloader.start_download(data['url'], data['save_path'], "")
|
||||||
|
elif action == 'pause_seeding':
|
||||||
|
downloader = downloads.get(game_id)
|
||||||
|
if downloader:
|
||||||
|
downloader.cancel_download()
|
||||||
|
|
||||||
|
else:
|
||||||
|
return jsonify({"error": "Invalid action"}), 400
|
||||||
|
|
||||||
|
return "", 200
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=int(http_port))
|
||||||
|
|
@ -15,8 +15,8 @@ class ProfileImageProcessor:
|
|||||||
mime_type = image.get_format_mimetype()
|
mime_type = image.get_format_mimetype()
|
||||||
return image_path, mime_type
|
return image_path, mime_type
|
||||||
else:
|
else:
|
||||||
newUUID = str(uuid.uuid4())
|
new_uuid = str(uuid.uuid4())
|
||||||
new_image_path = os.path.join(tempfile.gettempdir(), newUUID) + ".webp"
|
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + ".webp"
|
||||||
image.save(new_image_path)
|
image.save(new_image_path)
|
||||||
|
|
||||||
new_image = Image.open(new_image_path)
|
new_image = Image.open(new_image_path)
|
@ -3,18 +3,18 @@ from cx_Freeze import setup, Executable
|
|||||||
# Dependencies are automatically detected, but it might need fine tuning.
|
# Dependencies are automatically detected, but it might need fine tuning.
|
||||||
build_exe_options = {
|
build_exe_options = {
|
||||||
"packages": ["libtorrent"],
|
"packages": ["libtorrent"],
|
||||||
"build_exe": "hydra-download-manager",
|
"build_exe": "hydra-python-rpc",
|
||||||
"include_msvcr": True
|
"include_msvcr": True
|
||||||
}
|
}
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="hydra-download-manager",
|
name="hydra-python-rpc",
|
||||||
version="0.1",
|
version="0.1",
|
||||||
description="Hydra",
|
description="Hydra",
|
||||||
options={"build_exe": build_exe_options},
|
options={"build_exe": build_exe_options},
|
||||||
executables=[Executable(
|
executables=[Executable(
|
||||||
"torrent-client/main.py",
|
"python_rpc/main.py",
|
||||||
target_name="hydra-download-manager",
|
target_name="hydra-python-rpc",
|
||||||
icon="build/icon.ico"
|
icon="build/icon.ico"
|
||||||
)]
|
)]
|
||||||
)
|
)
|
@ -1,10 +1,9 @@
|
|||||||
import libtorrent as lt
|
import libtorrent as lt
|
||||||
|
|
||||||
class TorrentDownloader:
|
class TorrentDownloader:
|
||||||
def __init__(self, port: str):
|
def __init__(self, torrent_session):
|
||||||
self.torrent_handles = {}
|
self.torrent_handle = None
|
||||||
self.downloading_game_id = -1
|
self.session = torrent_session
|
||||||
self.session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=port)})
|
|
||||||
self.trackers = [
|
self.trackers = [
|
||||||
"udp://tracker.opentrackr.org:1337/announce",
|
"udp://tracker.opentrackr.org:1337/announce",
|
||||||
"http://tracker.opentrackr.org:1337/announce",
|
"http://tracker.opentrackr.org:1337/announce",
|
||||||
@ -102,64 +101,49 @@ class TorrentDownloader:
|
|||||||
"http://bvarf.tracker.sh:2086/announce",
|
"http://bvarf.tracker.sh:2086/announce",
|
||||||
]
|
]
|
||||||
|
|
||||||
def start_download(self, game_id: int, magnet: str, save_path: str):
|
def start_download(self, magnet: str, save_path: str, header: str):
|
||||||
params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers}
|
params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers}
|
||||||
torrent_handle = self.session.add_torrent(params)
|
self.torrent_handle = self.session.add_torrent(params)
|
||||||
self.torrent_handles[game_id] = torrent_handle
|
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
||||||
torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
self.torrent_handle.resume()
|
||||||
torrent_handle.resume()
|
|
||||||
|
|
||||||
self.downloading_game_id = game_id
|
def pause_download(self):
|
||||||
|
if self.torrent_handle:
|
||||||
|
self.torrent_handle.pause()
|
||||||
|
self.torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
|
||||||
|
|
||||||
def pause_download(self, game_id: int):
|
def cancel_download(self):
|
||||||
torrent_handle = self.torrent_handles.get(game_id)
|
if self.torrent_handle:
|
||||||
if torrent_handle:
|
self.torrent_handle.pause()
|
||||||
torrent_handle.pause()
|
self.session.remove_torrent(self.torrent_handle)
|
||||||
torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
|
self.torrent_handle = None
|
||||||
self.downloading_game_id = -1
|
|
||||||
|
|
||||||
def cancel_download(self, game_id: int):
|
|
||||||
torrent_handle = self.torrent_handles.get(game_id)
|
|
||||||
if torrent_handle:
|
|
||||||
torrent_handle.pause()
|
|
||||||
self.session.remove_torrent(torrent_handle)
|
|
||||||
self.torrent_handles[game_id] = None
|
|
||||||
self.downloading_game_id = -1
|
|
||||||
|
|
||||||
def abort_session(self):
|
def abort_session(self):
|
||||||
for game_id in self.torrent_handles:
|
for game_id in self.torrent_handles:
|
||||||
torrent_handle = self.torrent_handles[game_id]
|
self.torrent_handle = self.torrent_handles[game_id]
|
||||||
torrent_handle.pause()
|
self.torrent_handle.pause()
|
||||||
self.session.remove_torrent(torrent_handle)
|
self.session.remove_torrent(self.torrent_handle)
|
||||||
|
|
||||||
self.session.abort()
|
self.session.abort()
|
||||||
self.torrent_handles = {}
|
self.torrent_handle = None
|
||||||
self.downloading_game_id = -1
|
|
||||||
|
|
||||||
def get_download_status(self):
|
def get_download_status(self):
|
||||||
if self.downloading_game_id == -1:
|
if self.torrent_handle is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
torrent_handle = self.torrent_handles.get(self.downloading_game_id)
|
status = self.torrent_handle.status()
|
||||||
|
info = self.torrent_handle.get_torrent_info()
|
||||||
status = torrent_handle.status()
|
|
||||||
info = torrent_handle.get_torrent_info()
|
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
'folderName': info.name() if info else "",
|
'folderName': info.name() if info else "",
|
||||||
'fileSize': info.total_size() if info else 0,
|
'fileSize': info.total_size() if info else 0,
|
||||||
'gameId': self.downloading_game_id,
|
|
||||||
'progress': status.progress,
|
'progress': status.progress,
|
||||||
'downloadSpeed': status.download_rate,
|
'downloadSpeed': status.download_rate,
|
||||||
|
'uploadSpeed': status.upload_rate,
|
||||||
'numPeers': status.num_peers,
|
'numPeers': status.num_peers,
|
||||||
'numSeeds': status.num_seeds,
|
'numSeeds': status.num_seeds,
|
||||||
'status': status.state,
|
'status': status.state,
|
||||||
'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download,
|
'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download,
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.progress == 1:
|
|
||||||
torrent_handle.pause()
|
|
||||||
self.session.remove_torrent(torrent_handle)
|
|
||||||
self.downloading_game_id = -1
|
|
||||||
|
|
||||||
return response
|
return response
|
@ -4,3 +4,5 @@ cx_Logging; sys_platform == 'win32'
|
|||||||
pywin32; sys_platform == 'win32'
|
pywin32; sys_platform == 'win32'
|
||||||
psutil
|
psutil
|
||||||
Pillow
|
Pillow
|
||||||
|
flask
|
||||||
|
aria2p
|
||||||
|
@ -2,6 +2,7 @@ const { default: axios } = require("axios");
|
|||||||
const util = require("node:util");
|
const util = require("node:util");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
const exec = util.promisify(require("node:child_process").exec);
|
const exec = util.promisify(require("node:child_process").exec);
|
||||||
|
|
||||||
@ -46,4 +47,74 @@ const downloadLudusavi = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadAria2WindowsAndLinux = async () => {
|
||||||
|
if (fs.existsSync("aria2")) {
|
||||||
|
console.log("Aria2 already exists, skipping download...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file =
|
||||||
|
process.platform === "win32"
|
||||||
|
? "aria2-1.37.0-win-64bit-build1.zip"
|
||||||
|
: "aria2-1.37.0-1-x86_64.pkg.tar.zst";
|
||||||
|
|
||||||
|
const downloadUrl =
|
||||||
|
process.platform === "win32"
|
||||||
|
? `https://github.com/aria2/aria2/releases/download/release-1.37.0/${file}`
|
||||||
|
: "https://archlinux.org/packages/extra/x86_64/aria2/download/";
|
||||||
|
|
||||||
|
console.log(`Downloading ${file}...`);
|
||||||
|
|
||||||
|
const response = await axios.get(downloadUrl, { responseType: "stream" });
|
||||||
|
|
||||||
|
const stream = response.data.pipe(fs.createWriteStream(file));
|
||||||
|
|
||||||
|
stream.on("finish", async () => {
|
||||||
|
console.log(`Downloaded ${file}, extracting...`);
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
await exec(`npx extract-zip ${file}`);
|
||||||
|
console.log("Extracted. Renaming folder...");
|
||||||
|
|
||||||
|
fs.mkdirSync("aria2");
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(file.replace(".zip", ""), "aria2c.exe"),
|
||||||
|
"aria2/aria2c.exe"
|
||||||
|
);
|
||||||
|
fs.rmSync(file.replace(".zip", ""), { recursive: true });
|
||||||
|
} else {
|
||||||
|
await exec(`tar --zstd -xvf ${file} usr/bin/aria2c`);
|
||||||
|
console.log("Extracted. Copying binary file...");
|
||||||
|
fs.mkdirSync("aria2");
|
||||||
|
fs.copyFileSync("usr/bin/aria2c", "aria2/aria2c");
|
||||||
|
fs.rmSync("usr", { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Extracted ${file}, removing compressed downloaded file...`);
|
||||||
|
fs.rmSync(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAria2Macos = async () => {
|
||||||
|
console.log("Checking if aria2 is installed...");
|
||||||
|
|
||||||
|
const isAria2Installed = spawnSync("which", ["aria2c"]).status;
|
||||||
|
|
||||||
|
if (isAria2Installed != 0) {
|
||||||
|
console.log("Please install aria2");
|
||||||
|
console.log("brew install aria2");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Copying aria2 binary...");
|
||||||
|
fs.mkdirSync("aria2");
|
||||||
|
await exec(`cp $(which aria2c) aria2/aria2c`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.platform == "darwin") {
|
||||||
|
copyAria2Macos();
|
||||||
|
} else {
|
||||||
|
downloadAria2WindowsAndLinux();
|
||||||
|
}
|
||||||
|
|
||||||
downloadLudusavi();
|
downloadLudusavi();
|
||||||
|
File diff suppressed because one or more lines are too long
@ -201,7 +201,11 @@
|
|||||||
"queued": "Queued",
|
"queued": "Queued",
|
||||||
"no_downloads_title": "Such empty",
|
"no_downloads_title": "Such empty",
|
||||||
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start.",
|
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start.",
|
||||||
"checking_files": "Checking files…"
|
"checking_files": "Checking files…",
|
||||||
|
"seeding": "Seeding",
|
||||||
|
"stop_seeding": "Stop seeding",
|
||||||
|
"resume_seeding": "Resume seeding",
|
||||||
|
"options": "Manage"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Downloads path",
|
"downloads_path": "Downloads path",
|
||||||
@ -259,6 +263,7 @@
|
|||||||
"enable_achievement_notifications": "When an achievement is unlocked",
|
"enable_achievement_notifications": "When an achievement is unlocked",
|
||||||
"launch_minimized": "Launch Hydra minimized",
|
"launch_minimized": "Launch Hydra minimized",
|
||||||
"disable_nsfw_alert": "Disable NSFW alert",
|
"disable_nsfw_alert": "Disable NSFW alert",
|
||||||
|
"seed_after_download_complete": "Seed after download complete",
|
||||||
"show_hidden_achievement_description": "Show hidden achievements description before unlocking them"
|
"show_hidden_achievement_description": "Show hidden achievements description before unlocking them"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
@ -197,7 +197,11 @@
|
|||||||
"queued": "Na fila",
|
"queued": "Na fila",
|
||||||
"no_downloads_title": "Nada por aqui…",
|
"no_downloads_title": "Nada por aqui…",
|
||||||
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar.",
|
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar.",
|
||||||
"checking_files": "Verificando arquivos…"
|
"checking_files": "Verificando arquivos…",
|
||||||
|
"seeding": "Semeando",
|
||||||
|
"stop_seeding": "Parar de semear",
|
||||||
|
"resume_seeding": "Semear",
|
||||||
|
"options": "Gerenciar"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Diretório dos downloads",
|
"downloads_path": "Diretório dos downloads",
|
||||||
@ -255,6 +259,7 @@
|
|||||||
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
|
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
|
||||||
"launch_minimized": "Iniciar o Hydra minimizado",
|
"launch_minimized": "Iniciar o Hydra minimizado",
|
||||||
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
|
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
|
||||||
|
"seed_after_download_complete": "Semear após a conclusão do download",
|
||||||
"show_hidden_achievement_description": "Mostrar descrição de conquistas ocultas antes de debloqueá-las"
|
"show_hidden_achievement_description": "Mostrar descrição de conquistas ocultas antes de debloqueá-las"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
@ -85,6 +85,9 @@ export class Game {
|
|||||||
@Column("boolean", { default: false })
|
@Column("boolean", { default: false })
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
|
@Column("boolean", { default: false })
|
||||||
|
shouldSeed: boolean;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ -41,6 +41,9 @@ export class UserPreferences {
|
|||||||
@Column("boolean", { default: false })
|
@Column("boolean", { default: false })
|
||||||
disableNsfwAlert: boolean;
|
disableNsfwAlert: boolean;
|
||||||
|
|
||||||
|
@Column("boolean", { default: true })
|
||||||
|
seedAfterDownloadComplete: boolean;
|
||||||
|
|
||||||
@Column("boolean", { default: false })
|
@Column("boolean", { default: false })
|
||||||
showHiddenAchievementsDescription: boolean;
|
showHiddenAchievementsDescription: boolean;
|
||||||
|
|
||||||
|
@ -1,12 +1,8 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import {
|
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
|
||||||
DownloadManager,
|
|
||||||
HydraApi,
|
|
||||||
PythonInstance,
|
|
||||||
gamesPlaytime,
|
|
||||||
} from "@main/services";
|
|
||||||
import { dataSource } from "@main/data-source";
|
import { dataSource } from "@main/data-source";
|
||||||
import { DownloadQueue, Game, UserAuth, UserSubscription } from "@main/entity";
|
import { DownloadQueue, Game, UserAuth, UserSubscription } from "@main/entity";
|
||||||
|
import { PythonRPC } from "@main/services/python-rpc";
|
||||||
|
|
||||||
const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
|
const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||||
const databaseOperations = dataSource
|
const databaseOperations = dataSource
|
||||||
@ -32,7 +28,7 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
|
|||||||
DownloadManager.cancelDownload();
|
DownloadManager.cancelDownload();
|
||||||
|
|
||||||
/* Disconnects libtorrent */
|
/* Disconnects libtorrent */
|
||||||
PythonInstance.killTorrent();
|
PythonRPC.kill();
|
||||||
|
|
||||||
HydraApi.handleSignOut();
|
HydraApi.handleSignOut();
|
||||||
|
|
||||||
|
@ -89,7 +89,7 @@ const uploadSaveGame = async (
|
|||||||
"Content-Type": "application/tar",
|
"Content-Type": "application/tar",
|
||||||
},
|
},
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
console.log(progressEvent);
|
logger.log(progressEvent);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -32,6 +32,8 @@ import "./torrenting/cancel-game-download";
|
|||||||
import "./torrenting/pause-game-download";
|
import "./torrenting/pause-game-download";
|
||||||
import "./torrenting/resume-game-download";
|
import "./torrenting/resume-game-download";
|
||||||
import "./torrenting/start-game-download";
|
import "./torrenting/start-game-download";
|
||||||
|
import "./torrenting/pause-game-seed";
|
||||||
|
import "./torrenting/resume-game-seed";
|
||||||
import "./user-preferences/get-user-preferences";
|
import "./user-preferences/get-user-preferences";
|
||||||
import "./user-preferences/update-user-preferences";
|
import "./user-preferences/update-user-preferences";
|
||||||
import "./user-preferences/auto-launch";
|
import "./user-preferences/auto-launch";
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { PythonInstance, logger } from "@main/services";
|
import { logger } from "@main/services";
|
||||||
import sudo from "sudo-prompt";
|
import sudo from "sudo-prompt";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
|
import { PythonRPC } from "@main/services/python-rpc";
|
||||||
|
import { ProcessPayload } from "@main/services/download/types";
|
||||||
|
|
||||||
const getKillCommand = (pid: number) => {
|
const getKillCommand = (pid: number) => {
|
||||||
if (process.platform == "win32") {
|
if (process.platform == "win32") {
|
||||||
@ -16,7 +18,10 @@ const closeGame = async (
|
|||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
gameId: number
|
gameId: number
|
||||||
) => {
|
) => {
|
||||||
const processes = await PythonInstance.getProcessList();
|
const processes =
|
||||||
|
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
|
||||||
|
[];
|
||||||
|
|
||||||
const game = await gameRepository.findOne({
|
const game = await gameRepository.findOne({
|
||||||
where: { id: gameId, isDeleted: false },
|
where: { id: gameId, isDeleted: false },
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { PythonInstance } from "@main/services";
|
import { PythonRPC } from "@main/services/python-rpc";
|
||||||
|
|
||||||
const processProfileImage = async (
|
const processProfileImage = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
path: string
|
path: string
|
||||||
) => {
|
) => {
|
||||||
return PythonInstance.processProfileImage(path);
|
return PythonRPC.rpc
|
||||||
|
.post<{
|
||||||
|
imagePath: string;
|
||||||
|
mimeType: string;
|
||||||
|
}>("/profile-image", { image_path: path })
|
||||||
|
.then((response) => response.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("processProfileImage", processProfileImage);
|
registerEvent("processProfileImage", processProfileImage);
|
||||||
|
17
src/main/events/torrenting/pause-game-seed.ts
Normal file
17
src/main/events/torrenting/pause-game-seed.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { DownloadManager } from "@main/services";
|
||||||
|
import { gameRepository } from "@main/repository";
|
||||||
|
|
||||||
|
const pauseGameSeed = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
gameId: number
|
||||||
|
) => {
|
||||||
|
await gameRepository.update(gameId, {
|
||||||
|
status: "complete",
|
||||||
|
shouldSeed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await DownloadManager.pauseSeeding(gameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("pauseGameSeed", pauseGameSeed);
|
28
src/main/events/torrenting/resume-game-seed.ts
Normal file
28
src/main/events/torrenting/resume-game-seed.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { gameRepository } from "../../repository";
|
||||||
|
import { DownloadManager } from "@main/services";
|
||||||
|
|
||||||
|
const resumeGameSeed = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
gameId: number
|
||||||
|
) => {
|
||||||
|
const game = await gameRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: gameId,
|
||||||
|
isDeleted: false,
|
||||||
|
downloader: 1,
|
||||||
|
progress: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
await gameRepository.update(gameId, {
|
||||||
|
status: "seeding",
|
||||||
|
shouldSeed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await DownloadManager.resumeSeeding(game);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("resumeGameSeed", resumeGameSeed);
|
@ -1,4 +1,4 @@
|
|||||||
import { RealDebridClient } from "@main/services/real-debrid";
|
import { RealDebridClient } from "@main/services/download/real-debrid";
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
const authenticateRealDebrid = async (
|
const authenticateRealDebrid = async (
|
||||||
|
@ -5,12 +5,14 @@ import path from "node:path";
|
|||||||
import url from "node:url";
|
import url from "node:url";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
||||||
import { logger, PythonInstance, WindowManager } from "@main/services";
|
import { logger, WindowManager } from "@main/services";
|
||||||
import { dataSource } from "@main/data-source";
|
import { dataSource } from "@main/data-source";
|
||||||
import resources from "@locales";
|
import resources from "@locales";
|
||||||
import { userPreferencesRepository } from "@main/repository";
|
import { userPreferencesRepository } from "@main/repository";
|
||||||
import { knexClient, migrationConfig } from "./knex-client";
|
import { knexClient, migrationConfig } from "./knex-client";
|
||||||
import { databaseDirectory } from "./constants";
|
import { databaseDirectory } from "./constants";
|
||||||
|
import { PythonRPC } from "./services/python-rpc";
|
||||||
|
import { Aria2 } from "./services/aria2";
|
||||||
|
|
||||||
const { autoUpdater } = updater;
|
const { autoUpdater } = updater;
|
||||||
|
|
||||||
@ -146,7 +148,8 @@ app.on("window-all-closed", () => {
|
|||||||
|
|
||||||
app.on("before-quit", () => {
|
app.on("before-quit", () => {
|
||||||
/* Disconnects libtorrent */
|
/* Disconnects libtorrent */
|
||||||
PythonInstance.kill();
|
PythonRPC.kill();
|
||||||
|
Aria2.kill();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
|
@ -13,6 +13,8 @@ import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_backgroun
|
|||||||
import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game";
|
import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game";
|
||||||
import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_column";
|
import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_column";
|
||||||
import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disable_nsfw_alert_column";
|
import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disable_nsfw_alert_column";
|
||||||
|
import { AddShouldSeedColumn } from "./migrations/20241108200154_add_should_seed_colum";
|
||||||
|
import { AddSeedAfterDownloadColumn } from "./migrations/20241108201806_add_seed_after_download";
|
||||||
import { AddHiddenAchievementDescriptionColumn } from "./migrations/20241216140633_add_hidden_achievement_description_column ";
|
import { AddHiddenAchievementDescriptionColumn } from "./migrations/20241216140633_add_hidden_achievement_description_column ";
|
||||||
|
|
||||||
export type HydraMigration = Knex.Migration & { name: string };
|
export type HydraMigration = Knex.Migration & { name: string };
|
||||||
@ -32,6 +34,8 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
|||||||
AddWinePrefixToGame,
|
AddWinePrefixToGame,
|
||||||
AddStartMinimizedColumn,
|
AddStartMinimizedColumn,
|
||||||
AddDisableNsfwAlertColumn,
|
AddDisableNsfwAlertColumn,
|
||||||
|
AddShouldSeedColumn,
|
||||||
|
AddSeedAfterDownloadColumn,
|
||||||
AddHiddenAchievementDescriptionColumn,
|
AddHiddenAchievementDescriptionColumn,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
import {
|
import { DownloadManager, Ludusavi, startMainLoop } from "./services";
|
||||||
DownloadManager,
|
|
||||||
Ludusavi,
|
|
||||||
PythonInstance,
|
|
||||||
startMainLoop,
|
|
||||||
} from "./services";
|
|
||||||
import {
|
import {
|
||||||
downloadQueueRepository,
|
downloadQueueRepository,
|
||||||
|
gameRepository,
|
||||||
userPreferencesRepository,
|
userPreferencesRepository,
|
||||||
} from "./repository";
|
} from "./repository";
|
||||||
import { UserPreferences } from "./entity";
|
import { UserPreferences } from "./entity";
|
||||||
import { RealDebridClient } from "./services/real-debrid";
|
import { RealDebridClient } from "./services/download/real-debrid";
|
||||||
import { HydraApi } from "./services/hydra-api";
|
import { HydraApi } from "./services/hydra-api";
|
||||||
import { uploadGamesBatch } from "./services/library-sync";
|
import { uploadGamesBatch } from "./services/library-sync";
|
||||||
|
import { Aria2 } from "./services/aria2";
|
||||||
|
import { PythonRPC } from "./services/python-rpc";
|
||||||
|
|
||||||
const loadState = async (userPreferences: UserPreferences | null) => {
|
const loadState = async (userPreferences: UserPreferences | null) => {
|
||||||
import("./events");
|
import("./events");
|
||||||
|
|
||||||
|
Aria2.spawn();
|
||||||
|
|
||||||
if (userPreferences?.realDebridApiToken) {
|
if (userPreferences?.realDebridApiToken) {
|
||||||
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
||||||
}
|
}
|
||||||
@ -35,10 +35,18 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const seedList = await gameRepository.find({
|
||||||
|
where: {
|
||||||
|
shouldSeed: true,
|
||||||
|
downloader: 1,
|
||||||
|
progress: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (nextQueueItem?.game.status === "active") {
|
if (nextQueueItem?.game.status === "active") {
|
||||||
DownloadManager.startDownload(nextQueueItem.game);
|
DownloadManager.startRPC(nextQueueItem.game, seedList);
|
||||||
} else {
|
} else {
|
||||||
PythonInstance.spawn();
|
PythonRPC.spawn();
|
||||||
}
|
}
|
||||||
|
|
||||||
startMainLoop();
|
startMainLoop();
|
||||||
|
17
src/main/migrations/20241108200154_add_should_seed_colum.ts
Normal file
17
src/main/migrations/20241108200154_add_should_seed_colum.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { HydraMigration } from "@main/knex-client";
|
||||||
|
import type { Knex } from "knex";
|
||||||
|
|
||||||
|
export const AddShouldSeedColumn: HydraMigration = {
|
||||||
|
name: "AddShouldSeedColumn",
|
||||||
|
up: (knex: Knex) => {
|
||||||
|
return knex.schema.alterTable("game", (table) => {
|
||||||
|
return table.boolean("shouldSeed").notNullable().defaultTo(true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (knex: Knex) => {
|
||||||
|
return knex.schema.alterTable("game", (table) => {
|
||||||
|
return table.dropColumn("shouldSeed");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
@ -0,0 +1,20 @@
|
|||||||
|
import type { HydraMigration } from "@main/knex-client";
|
||||||
|
import type { Knex } from "knex";
|
||||||
|
|
||||||
|
export const AddSeedAfterDownloadColumn: HydraMigration = {
|
||||||
|
name: "AddSeedAfterDownloadColumn",
|
||||||
|
up: (knex: Knex) => {
|
||||||
|
return knex.schema.alterTable("user_preferences", (table) => {
|
||||||
|
return table
|
||||||
|
.boolean("seedAfterDownloadComplete")
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo(true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (knex: Knex) => {
|
||||||
|
return knex.schema.alterTable("user_preferences", (table) => {
|
||||||
|
return table.dropColumn("seedAfterDownloadComplete");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
30
src/main/services/aria2.ts
Normal file
30
src/main/services/aria2.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import cp from "node:child_process";
|
||||||
|
import { app } from "electron";
|
||||||
|
|
||||||
|
export const startAria2 = () => {};
|
||||||
|
|
||||||
|
export class Aria2 {
|
||||||
|
private static process: cp.ChildProcess | null = null;
|
||||||
|
|
||||||
|
public static spawn() {
|
||||||
|
const binaryPath = app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, "aria2", "aria2c")
|
||||||
|
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
||||||
|
|
||||||
|
this.process = cp.spawn(
|
||||||
|
binaryPath,
|
||||||
|
[
|
||||||
|
"--enable-rpc",
|
||||||
|
"--rpc-listen-all",
|
||||||
|
"--file-allocation=none",
|
||||||
|
"--allow-overwrite=true",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit", windowsHide: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static kill() {
|
||||||
|
this.process?.kill();
|
||||||
|
}
|
||||||
|
}
|
@ -1,39 +1,122 @@
|
|||||||
import { Game } from "@main/entity";
|
import { Game } from "@main/entity";
|
||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
import { PythonInstance } from "./python-instance";
|
|
||||||
import { WindowManager } from "../window-manager";
|
import { WindowManager } from "../window-manager";
|
||||||
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
import {
|
||||||
|
downloadQueueRepository,
|
||||||
|
gameRepository,
|
||||||
|
userPreferencesRepository,
|
||||||
|
} from "@main/repository";
|
||||||
import { publishDownloadCompleteNotification } from "../notifications";
|
import { publishDownloadCompleteNotification } from "../notifications";
|
||||||
import { RealDebridDownloader } from "./real-debrid-downloader";
|
|
||||||
import type { DownloadProgress } from "@types";
|
import type { DownloadProgress } from "@types";
|
||||||
import { GofileApi, QiwiApi } from "../hosters";
|
import { GofileApi, QiwiApi } from "../hosters";
|
||||||
import { GenericHttpDownloader } from "./generic-http-downloader";
|
import { PythonRPC } from "../python-rpc";
|
||||||
|
import {
|
||||||
|
LibtorrentPayload,
|
||||||
|
LibtorrentStatus,
|
||||||
|
PauseDownloadPayload,
|
||||||
|
} from "./types";
|
||||||
|
import { calculateETA, getDirSize } from "./helpers";
|
||||||
|
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||||
|
import { RealDebridClient } from "./real-debrid";
|
||||||
|
import path from "path";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
private static currentDownloader: Downloader | null = null;
|
|
||||||
private static downloadingGameId: number | null = null;
|
private static downloadingGameId: number | null = null;
|
||||||
|
|
||||||
public static async watchDownloads() {
|
public static startRPC(game: Game, initialSeeding?: Game[]) {
|
||||||
let status: DownloadProgress | null = null;
|
if (game && game.status === "active") {
|
||||||
|
PythonRPC.spawn(
|
||||||
|
{
|
||||||
|
game_id: game.id,
|
||||||
|
url: game.uri!,
|
||||||
|
save_path: game.downloadPath!,
|
||||||
|
},
|
||||||
|
initialSeeding?.map((game) => ({
|
||||||
|
game_id: game.id,
|
||||||
|
url: game.uri!,
|
||||||
|
save_path: game.downloadPath!,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
if (this.currentDownloader === Downloader.Torrent) {
|
this.downloadingGameId = game.id;
|
||||||
status = await PythonInstance.getStatus();
|
|
||||||
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
|
||||||
status = await RealDebridDownloader.getStatus();
|
|
||||||
} else {
|
|
||||||
status = await GenericHttpDownloader.getStatus();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getDownloadStatus() {
|
||||||
|
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
||||||
|
"/status"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data === null || !this.downloadingGameId) return null;
|
||||||
|
|
||||||
|
const gameId = this.downloadingGameId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
progress,
|
||||||
|
numPeers,
|
||||||
|
numSeeds,
|
||||||
|
downloadSpeed,
|
||||||
|
bytesDownloaded,
|
||||||
|
fileSize,
|
||||||
|
folderName,
|
||||||
|
status,
|
||||||
|
} = response.data;
|
||||||
|
|
||||||
|
const isDownloadingMetadata =
|
||||||
|
status === LibtorrentStatus.DownloadingMetadata;
|
||||||
|
|
||||||
|
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
||||||
|
|
||||||
|
if (!isDownloadingMetadata && !isCheckingFiles) {
|
||||||
|
const update: QueryDeepPartialEntity<Game> = {
|
||||||
|
bytesDownloaded,
|
||||||
|
fileSize,
|
||||||
|
progress,
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
|
||||||
|
await gameRepository.update(
|
||||||
|
{ id: gameId },
|
||||||
|
{
|
||||||
|
...update,
|
||||||
|
folderName,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
numPeers,
|
||||||
|
numSeeds,
|
||||||
|
downloadSpeed,
|
||||||
|
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
|
||||||
|
isDownloadingMetadata,
|
||||||
|
isCheckingFiles,
|
||||||
|
progress,
|
||||||
|
gameId,
|
||||||
|
} as DownloadProgress;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async watchDownloads() {
|
||||||
|
const status = await this.getDownloadStatus();
|
||||||
|
|
||||||
|
// status = await RealDebridDownloader.getStatus();
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
const { gameId, progress } = status;
|
const { gameId, progress } = status;
|
||||||
|
|
||||||
const game = await gameRepository.findOne({
|
const game = await gameRepository.findOne({
|
||||||
where: { id: gameId, isDeleted: false },
|
where: { id: gameId, isDeleted: false },
|
||||||
});
|
});
|
||||||
|
const userPreferences = await userPreferencesRepository.findOneBy({
|
||||||
|
id: 1,
|
||||||
|
});
|
||||||
|
|
||||||
if (WindowManager.mainWindow && game) {
|
if (WindowManager.mainWindow && game) {
|
||||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||||
|
|
||||||
WindowManager.mainWindow.webContents.send(
|
WindowManager.mainWindow.webContents.send(
|
||||||
"on-download-progress",
|
"on-download-progress",
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
@ -44,12 +127,27 @@ export class DownloadManager {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progress === 1 && game) {
|
if (progress === 1 && game) {
|
||||||
publishDownloadCompleteNotification(game);
|
publishDownloadCompleteNotification(game);
|
||||||
|
|
||||||
await downloadQueueRepository.delete({ game });
|
if (
|
||||||
|
userPreferences?.seedAfterDownloadComplete &&
|
||||||
|
game.downloader === Downloader.Torrent
|
||||||
|
) {
|
||||||
|
gameRepository.update(
|
||||||
|
{ id: gameId },
|
||||||
|
{ status: "seeding", shouldSeed: true }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
gameRepository.update(
|
||||||
|
{ id: gameId },
|
||||||
|
{ status: "complete", shouldSeed: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
this.cancelDownload(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await downloadQueueRepository.delete({ game });
|
||||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||||
order: {
|
order: {
|
||||||
id: "DESC",
|
id: "DESC",
|
||||||
@ -58,25 +156,61 @@ export class DownloadManager {
|
|||||||
game: true,
|
game: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nextQueueItem) {
|
if (nextQueueItem) {
|
||||||
this.resumeDownload(nextQueueItem.game);
|
this.resumeDownload(nextQueueItem.game);
|
||||||
|
} else {
|
||||||
|
this.downloadingGameId = -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async getSeedStatus() {
|
||||||
|
const seedStatus = await PythonRPC.rpc
|
||||||
|
.get<LibtorrentPayload[] | []>("/seed-status")
|
||||||
|
.then((res) => res.data);
|
||||||
|
|
||||||
|
if (!seedStatus.length) return;
|
||||||
|
|
||||||
|
logger.log(seedStatus);
|
||||||
|
|
||||||
|
seedStatus.forEach(async (status) => {
|
||||||
|
const game = await gameRepository.findOne({
|
||||||
|
where: { id: status.gameId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
const totalSize = await getDirSize(
|
||||||
|
path.join(game.downloadPath!, status.folderName)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (totalSize < status.fileSize) {
|
||||||
|
await this.cancelDownload(game.id);
|
||||||
|
|
||||||
|
await gameRepository.update(game.id, {
|
||||||
|
status: "paused",
|
||||||
|
shouldSeed: false,
|
||||||
|
progress: totalSize / status.fileSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
WindowManager.mainWindow?.webContents.send("on-hard-delete");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
WindowManager.mainWindow?.webContents.send("on-seeding-status", seedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
static async pauseDownload() {
|
static async pauseDownload() {
|
||||||
if (this.currentDownloader === Downloader.Torrent) {
|
await PythonRPC.rpc
|
||||||
await PythonInstance.pauseDownload();
|
.post("/action", {
|
||||||
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
action: "pause",
|
||||||
await RealDebridDownloader.pauseDownload();
|
game_id: this.downloadingGameId,
|
||||||
} else {
|
} as PauseDownloadPayload)
|
||||||
await GenericHttpDownloader.pauseDownload();
|
.catch(() => {});
|
||||||
}
|
|
||||||
|
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
this.currentDownloader = null;
|
|
||||||
this.downloadingGameId = null;
|
this.downloadingGameId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,19 +219,32 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async cancelDownload(gameId = this.downloadingGameId!) {
|
static async cancelDownload(gameId = this.downloadingGameId!) {
|
||||||
if (this.currentDownloader === Downloader.Torrent) {
|
await PythonRPC.rpc.post("/action", {
|
||||||
PythonInstance.cancelDownload(gameId);
|
action: "cancel",
|
||||||
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
game_id: gameId,
|
||||||
RealDebridDownloader.cancelDownload(gameId);
|
});
|
||||||
} else {
|
|
||||||
GenericHttpDownloader.cancelDownload(gameId);
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
this.currentDownloader = null;
|
|
||||||
this.downloadingGameId = null;
|
this.downloadingGameId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async resumeSeeding(game: Game) {
|
||||||
|
await PythonRPC.rpc.post("/action", {
|
||||||
|
action: "resume_seeding",
|
||||||
|
game_id: game.id,
|
||||||
|
url: game.uri,
|
||||||
|
save_path: game.downloadPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async pauseSeeding(gameId: number) {
|
||||||
|
await PythonRPC.rpc.post("/action", {
|
||||||
|
action: "pause_seeding",
|
||||||
|
game_id: gameId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static async startDownload(game: Game) {
|
static async startDownload(game: Game) {
|
||||||
switch (game.downloader) {
|
switch (game.downloader) {
|
||||||
case Downloader.Gofile: {
|
case Downloader.Gofile: {
|
||||||
@ -106,34 +253,57 @@ export class DownloadManager {
|
|||||||
const token = await GofileApi.authorize();
|
const token = await GofileApi.authorize();
|
||||||
const downloadLink = await GofileApi.getDownloadLink(id!);
|
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||||
|
|
||||||
GenericHttpDownloader.startDownload(game, downloadLink, {
|
await PythonRPC.rpc.post("/action", {
|
||||||
Cookie: `accountToken=${token}`,
|
action: "start",
|
||||||
|
game_id: game.id,
|
||||||
|
url: downloadLink,
|
||||||
|
save_path: game.downloadPath,
|
||||||
|
header: `Cookie: accountToken=${token}`,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Downloader.PixelDrain: {
|
case Downloader.PixelDrain: {
|
||||||
const id = game!.uri!.split("/").pop();
|
const id = game!.uri!.split("/").pop();
|
||||||
|
|
||||||
await GenericHttpDownloader.startDownload(
|
await PythonRPC.rpc.post("/action", {
|
||||||
game,
|
action: "start",
|
||||||
`https://pixeldrain.com/api/file/${id}?download`
|
game_id: game.id,
|
||||||
);
|
url: `https://pixeldrain.com/api/file/${id}?download`,
|
||||||
|
save_path: game.downloadPath,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Downloader.Qiwi: {
|
case Downloader.Qiwi: {
|
||||||
const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!);
|
const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!);
|
||||||
|
|
||||||
await GenericHttpDownloader.startDownload(game, downloadUrl);
|
await PythonRPC.rpc.post("/action", {
|
||||||
|
action: "start",
|
||||||
|
game_id: game.id,
|
||||||
|
url: downloadUrl,
|
||||||
|
save_path: game.downloadPath,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Downloader.Torrent:
|
case Downloader.Torrent:
|
||||||
PythonInstance.startDownload(game);
|
await PythonRPC.rpc.post("/action", {
|
||||||
|
action: "start",
|
||||||
|
game_id: game.id,
|
||||||
|
url: game.uri,
|
||||||
|
save_path: game.downloadPath,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case Downloader.RealDebrid:
|
case Downloader.RealDebrid: {
|
||||||
RealDebridDownloader.startDownload(game);
|
const downloadUrl = await RealDebridClient.getDownloadUrl(game.uri!);
|
||||||
|
|
||||||
|
await PythonRPC.rpc.post("/action", {
|
||||||
|
action: "start",
|
||||||
|
game_id: game.id,
|
||||||
|
url: downloadUrl,
|
||||||
|
save_path: game.downloadPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentDownloader = game.downloader;
|
|
||||||
this.downloadingGameId = game.id;
|
this.downloadingGameId = game.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,109 +0,0 @@
|
|||||||
import { Game } from "@main/entity";
|
|
||||||
import { gameRepository } from "@main/repository";
|
|
||||||
import { calculateETA } from "./helpers";
|
|
||||||
import { DownloadProgress } from "@types";
|
|
||||||
import { HttpDownload } from "./http-download";
|
|
||||||
|
|
||||||
export class GenericHttpDownloader {
|
|
||||||
public static downloads = new Map<number, HttpDownload>();
|
|
||||||
public static downloadingGame: Game | null = null;
|
|
||||||
|
|
||||||
public static async getStatus() {
|
|
||||||
if (this.downloadingGame) {
|
|
||||||
const download = this.downloads.get(this.downloadingGame.id)!;
|
|
||||||
const status = download.getStatus();
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
const progress =
|
|
||||||
Number(status.completedLength) / Number(status.totalLength);
|
|
||||||
|
|
||||||
await gameRepository.update(
|
|
||||||
{ id: this.downloadingGame!.id },
|
|
||||||
{
|
|
||||||
bytesDownloaded: Number(status.completedLength),
|
|
||||||
fileSize: Number(status.totalLength),
|
|
||||||
progress,
|
|
||||||
status: "active",
|
|
||||||
folderName: status.folderName,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
numPeers: 0,
|
|
||||||
numSeeds: 0,
|
|
||||||
downloadSpeed: status.downloadSpeed,
|
|
||||||
timeRemaining: calculateETA(
|
|
||||||
status.totalLength,
|
|
||||||
status.completedLength,
|
|
||||||
status.downloadSpeed
|
|
||||||
),
|
|
||||||
isDownloadingMetadata: false,
|
|
||||||
isCheckingFiles: false,
|
|
||||||
progress,
|
|
||||||
gameId: this.downloadingGame!.id,
|
|
||||||
} as DownloadProgress;
|
|
||||||
|
|
||||||
if (progress === 1) {
|
|
||||||
this.downloads.delete(this.downloadingGame.id);
|
|
||||||
this.downloadingGame = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async pauseDownload() {
|
|
||||||
if (this.downloadingGame) {
|
|
||||||
const httpDownload = this.downloads.get(this.downloadingGame!.id!);
|
|
||||||
|
|
||||||
if (httpDownload) {
|
|
||||||
await httpDownload.pauseDownload();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.downloadingGame = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async startDownload(
|
|
||||||
game: Game,
|
|
||||||
downloadUrl: string,
|
|
||||||
headers?: Record<string, string>
|
|
||||||
) {
|
|
||||||
this.downloadingGame = game;
|
|
||||||
|
|
||||||
if (this.downloads.has(game.id)) {
|
|
||||||
await this.resumeDownload(game.id!);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpDownload = new HttpDownload(
|
|
||||||
game.downloadPath!,
|
|
||||||
downloadUrl,
|
|
||||||
headers
|
|
||||||
);
|
|
||||||
|
|
||||||
httpDownload.startDownload();
|
|
||||||
|
|
||||||
this.downloads.set(game.id!, httpDownload);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async cancelDownload(gameId: number) {
|
|
||||||
const httpDownload = this.downloads.get(gameId);
|
|
||||||
|
|
||||||
if (httpDownload) {
|
|
||||||
await httpDownload.cancelDownload();
|
|
||||||
this.downloads.delete(gameId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async resumeDownload(gameId: number) {
|
|
||||||
const httpDownload = this.downloads.get(gameId);
|
|
||||||
|
|
||||||
if (httpDownload) {
|
|
||||||
await httpDownload.resumeDownload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,3 +1,7 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
export const calculateETA = (
|
export const calculateETA = (
|
||||||
totalLength: number,
|
totalLength: number,
|
||||||
completedLength: number,
|
completedLength: number,
|
||||||
@ -11,3 +15,26 @@ export const calculateETA = (
|
|||||||
|
|
||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDirSize = async (dir: string): Promise<number> => {
|
||||||
|
const getItemSize = async (filePath: string): Promise<number> => {
|
||||||
|
const stat = await fs.promises.stat(filePath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
return getDirSize(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stat.size;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.promises.readdir(dir);
|
||||||
|
const filePaths = files.map((file) => path.join(dir, file));
|
||||||
|
const sizes = await Promise.all(filePaths.map(getItemSize));
|
||||||
|
|
||||||
|
return sizes.reduce((total, size) => total + size, 0);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -1,54 +0,0 @@
|
|||||||
import { WindowManager } from "../window-manager";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
export class HttpDownload {
|
|
||||||
private downloadItem: Electron.DownloadItem;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private downloadPath: string,
|
|
||||||
private downloadUrl: string,
|
|
||||||
private headers?: Record<string, string>
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public getStatus() {
|
|
||||||
return {
|
|
||||||
completedLength: this.downloadItem.getReceivedBytes(),
|
|
||||||
totalLength: this.downloadItem.getTotalBytes(),
|
|
||||||
downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(),
|
|
||||||
folderName: this.downloadItem.getFilename(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancelDownload() {
|
|
||||||
this.downloadItem.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
async pauseDownload() {
|
|
||||||
this.downloadItem.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
async resumeDownload() {
|
|
||||||
this.downloadItem.resume();
|
|
||||||
}
|
|
||||||
|
|
||||||
async startDownload() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const options = this.headers ? { headers: this.headers } : {};
|
|
||||||
WindowManager.mainWindow?.webContents.downloadURL(
|
|
||||||
this.downloadUrl,
|
|
||||||
options
|
|
||||||
);
|
|
||||||
|
|
||||||
WindowManager.mainWindow?.webContents.session.once(
|
|
||||||
"will-download",
|
|
||||||
(_event, item, _webContents) => {
|
|
||||||
this.downloadItem = item;
|
|
||||||
|
|
||||||
item.setSavePath(path.join(this.downloadPath, item.getFilename()));
|
|
||||||
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,2 +1 @@
|
|||||||
export * from "./download-manager";
|
export * from "./download-manager";
|
||||||
export * from "./python-instance";
|
|
||||||
|
@ -1,188 +0,0 @@
|
|||||||
import cp from "node:child_process";
|
|
||||||
|
|
||||||
import { Game } from "@main/entity";
|
|
||||||
import {
|
|
||||||
RPC_PASSWORD,
|
|
||||||
RPC_PORT,
|
|
||||||
startTorrentClient as startRPCClient,
|
|
||||||
} from "./torrent-client";
|
|
||||||
import { gameRepository } from "@main/repository";
|
|
||||||
import type { DownloadProgress } from "@types";
|
|
||||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
|
||||||
import { calculateETA } from "./helpers";
|
|
||||||
import axios from "axios";
|
|
||||||
import {
|
|
||||||
CancelDownloadPayload,
|
|
||||||
StartDownloadPayload,
|
|
||||||
PauseDownloadPayload,
|
|
||||||
LibtorrentStatus,
|
|
||||||
LibtorrentPayload,
|
|
||||||
ProcessPayload,
|
|
||||||
} from "./types";
|
|
||||||
import { pythonInstanceLogger as logger } from "../logger";
|
|
||||||
|
|
||||||
export class PythonInstance {
|
|
||||||
private static pythonProcess: cp.ChildProcess | null = null;
|
|
||||||
private static downloadingGameId = -1;
|
|
||||||
|
|
||||||
private static rpc = axios.create({
|
|
||||||
baseURL: `http://localhost:${RPC_PORT}`,
|
|
||||||
headers: {
|
|
||||||
"x-hydra-rpc-password": RPC_PASSWORD,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
public static spawn(args?: StartDownloadPayload) {
|
|
||||||
logger.log("spawning python process with args:", args);
|
|
||||||
this.pythonProcess = startRPCClient(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static kill() {
|
|
||||||
if (this.pythonProcess) {
|
|
||||||
logger.log("killing python process");
|
|
||||||
this.pythonProcess.kill();
|
|
||||||
this.pythonProcess = null;
|
|
||||||
this.downloadingGameId = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static killTorrent() {
|
|
||||||
if (this.pythonProcess) {
|
|
||||||
logger.log("killing torrent in python process");
|
|
||||||
this.rpc.post("/action", { action: "kill-torrent" });
|
|
||||||
this.downloadingGameId = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async getProcessList() {
|
|
||||||
return (
|
|
||||||
(await this.rpc.get<ProcessPayload[] | null>("/process-list")).data || []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async getStatus() {
|
|
||||||
if (this.downloadingGameId === -1) return null;
|
|
||||||
|
|
||||||
const response = await this.rpc.get<LibtorrentPayload | null>("/status");
|
|
||||||
|
|
||||||
if (response.data === null) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
progress,
|
|
||||||
numPeers,
|
|
||||||
numSeeds,
|
|
||||||
downloadSpeed,
|
|
||||||
bytesDownloaded,
|
|
||||||
fileSize,
|
|
||||||
folderName,
|
|
||||||
status,
|
|
||||||
gameId,
|
|
||||||
} = response.data;
|
|
||||||
|
|
||||||
this.downloadingGameId = gameId;
|
|
||||||
|
|
||||||
const isDownloadingMetadata =
|
|
||||||
status === LibtorrentStatus.DownloadingMetadata;
|
|
||||||
|
|
||||||
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
|
||||||
|
|
||||||
if (!isDownloadingMetadata && !isCheckingFiles) {
|
|
||||||
const update: QueryDeepPartialEntity<Game> = {
|
|
||||||
bytesDownloaded,
|
|
||||||
fileSize,
|
|
||||||
progress,
|
|
||||||
status: "active",
|
|
||||||
};
|
|
||||||
|
|
||||||
await gameRepository.update(
|
|
||||||
{ id: gameId },
|
|
||||||
{
|
|
||||||
...update,
|
|
||||||
folderName,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress === 1 && !isCheckingFiles) {
|
|
||||||
this.downloadingGameId = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
numPeers,
|
|
||||||
numSeeds,
|
|
||||||
downloadSpeed,
|
|
||||||
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
|
|
||||||
isDownloadingMetadata,
|
|
||||||
isCheckingFiles,
|
|
||||||
progress,
|
|
||||||
gameId,
|
|
||||||
} as DownloadProgress;
|
|
||||||
} catch (err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async pauseDownload() {
|
|
||||||
await this.rpc
|
|
||||||
.post("/action", {
|
|
||||||
action: "pause",
|
|
||||||
game_id: this.downloadingGameId,
|
|
||||||
} as PauseDownloadPayload)
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
this.downloadingGameId = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async startDownload(game: Game) {
|
|
||||||
if (!this.pythonProcess) {
|
|
||||||
this.spawn({
|
|
||||||
game_id: game.id,
|
|
||||||
magnet: game.uri!,
|
|
||||||
save_path: game.downloadPath!,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this.rpc
|
|
||||||
.post("/action", {
|
|
||||||
action: "start",
|
|
||||||
game_id: game.id,
|
|
||||||
magnet: game.uri,
|
|
||||||
save_path: game.downloadPath,
|
|
||||||
} as StartDownloadPayload)
|
|
||||||
.catch(this.handleRpcError);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.downloadingGameId = game.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async cancelDownload(gameId: number) {
|
|
||||||
await this.rpc
|
|
||||||
.post("/action", {
|
|
||||||
action: "cancel",
|
|
||||||
game_id: gameId,
|
|
||||||
} as CancelDownloadPayload)
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
this.downloadingGameId = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async processProfileImage(imagePath: string) {
|
|
||||||
return this.rpc
|
|
||||||
.post<{ imagePath: string; mimeType: string }>("/profile-image", {
|
|
||||||
image_path: imagePath,
|
|
||||||
})
|
|
||||||
.then((response) => response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async handleRpcError(error: unknown) {
|
|
||||||
logger.error(error);
|
|
||||||
|
|
||||||
return this.rpc.get("/healthcheck").catch(() => {
|
|
||||||
logger.error(
|
|
||||||
"RPC healthcheck failed. Killing process and starting again"
|
|
||||||
);
|
|
||||||
this.kill();
|
|
||||||
this.spawn();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,72 +0,0 @@
|
|||||||
import { Game } from "@main/entity";
|
|
||||||
import { RealDebridClient } from "../real-debrid";
|
|
||||||
import { HttpDownload } from "./http-download";
|
|
||||||
import { GenericHttpDownloader } from "./generic-http-downloader";
|
|
||||||
|
|
||||||
export class RealDebridDownloader extends GenericHttpDownloader {
|
|
||||||
private static realDebridTorrentId: string | null = null;
|
|
||||||
|
|
||||||
private static async getRealDebridDownloadUrl() {
|
|
||||||
if (this.realDebridTorrentId) {
|
|
||||||
let torrentInfo = await RealDebridClient.getTorrentInfo(
|
|
||||||
this.realDebridTorrentId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (torrentInfo.status === "waiting_files_selection") {
|
|
||||||
await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
|
|
||||||
|
|
||||||
torrentInfo = await RealDebridClient.getTorrentInfo(
|
|
||||||
this.realDebridTorrentId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { links, status } = torrentInfo;
|
|
||||||
|
|
||||||
if (status === "downloaded") {
|
|
||||||
const [link] = links;
|
|
||||||
|
|
||||||
const { download } = await RealDebridClient.unrestrictLink(link);
|
|
||||||
return decodeURIComponent(download);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.downloadingGame?.uri) {
|
|
||||||
const { download } = await RealDebridClient.unrestrictLink(
|
|
||||||
this.downloadingGame?.uri
|
|
||||||
);
|
|
||||||
|
|
||||||
return decodeURIComponent(download);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async startDownload(game: Game) {
|
|
||||||
if (this.downloads.has(game.id)) {
|
|
||||||
await this.resumeDownload(game.id!);
|
|
||||||
this.downloadingGame = game;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (game.uri?.startsWith("magnet:")) {
|
|
||||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
|
|
||||||
game!.uri!
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.downloadingGame = game;
|
|
||||||
|
|
||||||
const downloadUrl = await this.getRealDebridDownloadUrl();
|
|
||||||
|
|
||||||
if (downloadUrl) {
|
|
||||||
this.realDebridTorrentId = null;
|
|
||||||
|
|
||||||
const httpDownload = new HttpDownload(game.downloadPath!, downloadUrl);
|
|
||||||
httpDownload.startDownload();
|
|
||||||
|
|
||||||
this.downloads.set(game.id!, httpDownload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -9,7 +9,7 @@ import type {
|
|||||||
|
|
||||||
export class RealDebridClient {
|
export class RealDebridClient {
|
||||||
private static instance: AxiosInstance;
|
private static instance: AxiosInstance;
|
||||||
private static baseURL = "https://api.real-debrid.com/rest/1.0";
|
private static readonly baseURL = "https://api.real-debrid.com/rest/1.0";
|
||||||
|
|
||||||
static authorize(apiToken: string) {
|
static authorize(apiToken: string) {
|
||||||
this.instance = axios.create({
|
this.instance = axios.create({
|
||||||
@ -83,4 +83,37 @@ export class RealDebridClient {
|
|||||||
const torrent = await RealDebridClient.addMagnet(magnetUri);
|
const torrent = await RealDebridClient.addMagnet(magnetUri);
|
||||||
return torrent.id;
|
return torrent.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async getDownloadUrl(uri: string) {
|
||||||
|
let realDebridTorrentId: string | null = null;
|
||||||
|
|
||||||
|
if (uri.startsWith("magnet:")) {
|
||||||
|
realDebridTorrentId = await this.getTorrentId(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (realDebridTorrentId) {
|
||||||
|
let torrentInfo = await this.getTorrentInfo(realDebridTorrentId);
|
||||||
|
|
||||||
|
if (torrentInfo.status === "waiting_files_selection") {
|
||||||
|
await this.selectAllFiles(realDebridTorrentId);
|
||||||
|
|
||||||
|
torrentInfo = await this.getTorrentInfo(realDebridTorrentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { links, status } = torrentInfo;
|
||||||
|
|
||||||
|
if (status === "downloaded") {
|
||||||
|
const [link] = links;
|
||||||
|
|
||||||
|
const { download } = await this.unrestrictLink(link);
|
||||||
|
return decodeURIComponent(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { download } = await this.unrestrictLink(uri);
|
||||||
|
|
||||||
|
return decodeURIComponent(download);
|
||||||
|
}
|
||||||
}
|
}
|
97
src/main/services/download/torbox.ts
Normal file
97
src/main/services/download/torbox.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
import parseTorrent from "parse-torrent";
|
||||||
|
import type {
|
||||||
|
TorBoxUserRequest,
|
||||||
|
TorBoxTorrentInfoRequest,
|
||||||
|
TorBoxAddTorrentRequest,
|
||||||
|
TorBoxRequestLinkRequest,
|
||||||
|
} from "@types";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
export class TorBoxClient {
|
||||||
|
private static instance: AxiosInstance;
|
||||||
|
private static readonly baseURL = "https://api.torbox.app/v1/api";
|
||||||
|
public static apiToken: string;
|
||||||
|
|
||||||
|
static authorize(apiToken: string) {
|
||||||
|
this.instance = axios.create({
|
||||||
|
baseURL: this.baseURL,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.apiToken = apiToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addMagnet(magnet: string) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("magnet", magnet);
|
||||||
|
|
||||||
|
const response = await this.instance.post<TorBoxAddTorrentRequest>(
|
||||||
|
"/torrents/createtorrent",
|
||||||
|
form
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getTorrentInfo(id: number) {
|
||||||
|
const response =
|
||||||
|
await this.instance.get<TorBoxTorrentInfoRequest>("/torrents/mylist");
|
||||||
|
const data = response.data.data;
|
||||||
|
|
||||||
|
const info = data.find((item) => item.id === id);
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUser() {
|
||||||
|
const response = await this.instance.get<TorBoxUserRequest>(`/user/me`);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async requestLink(id: number) {
|
||||||
|
const searchParams = new URLSearchParams({});
|
||||||
|
|
||||||
|
searchParams.set("token", this.apiToken);
|
||||||
|
searchParams.set("torrent_id", id.toString());
|
||||||
|
searchParams.set("zip_link", "true");
|
||||||
|
|
||||||
|
const response = await this.instance.get<TorBoxRequestLinkRequest>(
|
||||||
|
"/torrents/requestdl?" + searchParams.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
logger.error(response.data.error);
|
||||||
|
logger.error(response.data.detail);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getAllTorrentsFromUser() {
|
||||||
|
const response =
|
||||||
|
await this.instance.get<TorBoxTorrentInfoRequest>("/torrents/mylist");
|
||||||
|
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getTorrentId(magnetUri: string) {
|
||||||
|
const userTorrents = await this.getAllTorrentsFromUser();
|
||||||
|
|
||||||
|
const { infoHash } = await parseTorrent(magnetUri);
|
||||||
|
const userTorrent = userTorrents.find(
|
||||||
|
(userTorrent) => userTorrent.hash === infoHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userTorrent) return userTorrent.id;
|
||||||
|
|
||||||
|
const torrent = await this.addMagnet(magnetUri);
|
||||||
|
return torrent.torrent_id;
|
||||||
|
}
|
||||||
|
}
|
@ -1,77 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import cp from "node:child_process";
|
|
||||||
import crypto from "node:crypto";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import { app, dialog } from "electron";
|
|
||||||
import type { StartDownloadPayload } from "./types";
|
|
||||||
import { Readable } from "node:stream";
|
|
||||||
import { pythonInstanceLogger as logger } from "../logger";
|
|
||||||
|
|
||||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
|
||||||
darwin: "hydra-download-manager",
|
|
||||||
linux: "hydra-download-manager",
|
|
||||||
win32: "hydra-download-manager.exe",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BITTORRENT_PORT = "5881";
|
|
||||||
export const RPC_PORT = "8084";
|
|
||||||
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
|
|
||||||
|
|
||||||
const logStderr = (readable: Readable | null) => {
|
|
||||||
if (!readable) return;
|
|
||||||
|
|
||||||
readable.setEncoding("utf-8");
|
|
||||||
readable.on("data", logger.log);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const startTorrentClient = (args?: StartDownloadPayload) => {
|
|
||||||
const commonArgs = [
|
|
||||||
BITTORRENT_PORT,
|
|
||||||
RPC_PORT,
|
|
||||||
RPC_PASSWORD,
|
|
||||||
args ? encodeURIComponent(JSON.stringify(args)) : "",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (app.isPackaged) {
|
|
||||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
|
||||||
const binaryPath = path.join(
|
|
||||||
process.resourcesPath,
|
|
||||||
"hydra-download-manager",
|
|
||||||
binaryName
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!fs.existsSync(binaryPath)) {
|
|
||||||
dialog.showErrorBox(
|
|
||||||
"Fatal",
|
|
||||||
"Hydra Download Manager binary not found. Please check if it has been removed by Windows Defender."
|
|
||||||
);
|
|
||||||
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
|
||||||
windowsHide: true,
|
|
||||||
stdio: ["inherit", "inherit"],
|
|
||||||
});
|
|
||||||
|
|
||||||
logStderr(childProcess.stderr);
|
|
||||||
|
|
||||||
return childProcess;
|
|
||||||
} else {
|
|
||||||
const scriptPath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"torrent-client",
|
|
||||||
"main.py"
|
|
||||||
);
|
|
||||||
|
|
||||||
const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], {
|
|
||||||
stdio: ["inherit", "inherit"],
|
|
||||||
});
|
|
||||||
|
|
||||||
logStderr(childProcess.stderr);
|
|
||||||
|
|
||||||
return childProcess;
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,9 +1,3 @@
|
|||||||
export interface StartDownloadPayload {
|
|
||||||
game_id: number;
|
|
||||||
magnet: string;
|
|
||||||
save_path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PauseDownloadPayload {
|
export interface PauseDownloadPayload {
|
||||||
game_id: number;
|
game_id: number;
|
||||||
}
|
}
|
||||||
@ -25,6 +19,7 @@ export interface LibtorrentPayload {
|
|||||||
numPeers: number;
|
numPeers: number;
|
||||||
numSeeds: number;
|
numSeeds: number;
|
||||||
downloadSpeed: number;
|
downloadSpeed: number;
|
||||||
|
uploadSpeed: number;
|
||||||
bytesDownloaded: number;
|
bytesDownloaded: number;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
folderName: string;
|
folderName: string;
|
||||||
@ -33,7 +28,15 @@ export interface LibtorrentPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessPayload {
|
export interface ProcessPayload {
|
||||||
exe: string;
|
exe: string | null;
|
||||||
pid: number;
|
pid: number;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PauseSeedingPayload {
|
||||||
|
game_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeSeedingPayload {
|
||||||
|
game_id: number;
|
||||||
|
}
|
||||||
|
@ -10,6 +10,7 @@ export const startMainLoop = async () => {
|
|||||||
watchProcesses(),
|
watchProcesses(),
|
||||||
DownloadManager.watchDownloads(),
|
DownloadManager.watchDownloads(),
|
||||||
AchievementWatcherManager.watchAchievements(),
|
AchievementWatcherManager.watchAchievements(),
|
||||||
|
DownloadManager.getSeedStatus(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
|
@ -2,10 +2,11 @@ import { gameRepository } from "@main/repository";
|
|||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import { createGame, updateGamePlaytime } from "./library-sync";
|
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||||
import type { GameRunning } from "@types";
|
import type { GameRunning } from "@types";
|
||||||
import { PythonInstance } from "./download";
|
import { PythonRPC } from "./python-rpc";
|
||||||
import { Game } from "@main/entity";
|
import { Game } from "@main/entity";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
|
import { ProcessPayload } from "./download/types";
|
||||||
|
|
||||||
const commands = {
|
const commands = {
|
||||||
findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`,
|
findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`,
|
||||||
@ -88,12 +89,14 @@ const findGamePathByProcess = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSystemProcessMap = async () => {
|
const getSystemProcessMap = async () => {
|
||||||
const processes = await PythonInstance.getProcessList();
|
const processes =
|
||||||
|
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
|
||||||
|
[];
|
||||||
|
|
||||||
const map = new Map<string, Set<string>>();
|
const map = new Map<string, Set<string>>();
|
||||||
|
|
||||||
processes.forEach((process) => {
|
processes.forEach((process) => {
|
||||||
const key = process.name.toLowerCase();
|
const key = process.name?.toLowerCase();
|
||||||
const value = process.exe;
|
const value = process.exe;
|
||||||
|
|
||||||
if (!key || !value) return;
|
if (!key || !value) return;
|
||||||
|
108
src/main/services/python-rpc.ts
Normal file
108
src/main/services/python-rpc.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import cp from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import { logger } from "./logger";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { app, dialog } from "electron";
|
||||||
|
|
||||||
|
interface GamePayload {
|
||||||
|
game_id: number;
|
||||||
|
url: string;
|
||||||
|
save_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||||
|
darwin: "hydra-python-rpc",
|
||||||
|
linux: "hydra-python-rpc",
|
||||||
|
win32: "hydra-python-rpc.exe",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PythonRPC {
|
||||||
|
public static readonly BITTORRENT_PORT = "5881";
|
||||||
|
public static readonly RPC_PORT = "8084";
|
||||||
|
private static readonly RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
private static pythonProcess: cp.ChildProcess | null = null;
|
||||||
|
|
||||||
|
public static readonly rpc = axios.create({
|
||||||
|
baseURL: `http://localhost:${this.RPC_PORT}`,
|
||||||
|
headers: {
|
||||||
|
"x-hydra-rpc-password": this.RPC_PASSWORD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
private static logStderr(readable: Readable | null) {
|
||||||
|
if (!readable) return;
|
||||||
|
|
||||||
|
readable.setEncoding("utf-8");
|
||||||
|
readable.on("data", logger.log);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static spawn(
|
||||||
|
initialDownload?: GamePayload,
|
||||||
|
initialSeeding?: GamePayload[]
|
||||||
|
) {
|
||||||
|
const commonArgs = [
|
||||||
|
this.BITTORRENT_PORT,
|
||||||
|
this.RPC_PORT,
|
||||||
|
this.RPC_PASSWORD,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ["inherit", "inherit"],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logStderr(childProcess.stderr);
|
||||||
|
|
||||||
|
this.pythonProcess = childProcess;
|
||||||
|
} else {
|
||||||
|
const scriptPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"python_rpc",
|
||||||
|
"main.py"
|
||||||
|
);
|
||||||
|
|
||||||
|
const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||||
|
stdio: ["inherit", "inherit"],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logStderr(childProcess.stderr);
|
||||||
|
|
||||||
|
this.pythonProcess = childProcess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static kill() {
|
||||||
|
if (this.pythonProcess) {
|
||||||
|
logger.log("Killing python process");
|
||||||
|
this.pythonProcess.kill();
|
||||||
|
this.pythonProcess = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
@ -1,7 +1,6 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
|
|
||||||
readonly MAIN_VITE_API_URL: string;
|
readonly MAIN_VITE_API_URL: string;
|
||||||
readonly MAIN_VITE_ANALYTICS_API_URL: string;
|
readonly MAIN_VITE_ANALYTICS_API_URL: string;
|
||||||
readonly MAIN_VITE_AUTH_URL: string;
|
readonly MAIN_VITE_AUTH_URL: string;
|
||||||
|
@ -11,6 +11,7 @@ import type {
|
|||||||
GameRunning,
|
GameRunning,
|
||||||
FriendRequestAction,
|
FriendRequestAction,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
|
SeedingStatus,
|
||||||
GameAchievement,
|
GameAchievement,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { CatalogueCategory } from "@shared";
|
import type { CatalogueCategory } from "@shared";
|
||||||
@ -26,6 +27,10 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("pauseGameDownload", gameId),
|
ipcRenderer.invoke("pauseGameDownload", gameId),
|
||||||
resumeGameDownload: (gameId: number) =>
|
resumeGameDownload: (gameId: number) =>
|
||||||
ipcRenderer.invoke("resumeGameDownload", gameId),
|
ipcRenderer.invoke("resumeGameDownload", gameId),
|
||||||
|
pauseGameSeed: (gameId: number) =>
|
||||||
|
ipcRenderer.invoke("pauseGameSeed", gameId),
|
||||||
|
resumeGameSeed: (gameId: number) =>
|
||||||
|
ipcRenderer.invoke("resumeGameSeed", gameId),
|
||||||
onDownloadProgress: (cb: (value: DownloadProgress) => void) => {
|
onDownloadProgress: (cb: (value: DownloadProgress) => void) => {
|
||||||
const listener = (
|
const listener = (
|
||||||
_event: Electron.IpcRendererEvent,
|
_event: Electron.IpcRendererEvent,
|
||||||
@ -34,6 +39,19 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.on("on-download-progress", listener);
|
ipcRenderer.on("on-download-progress", listener);
|
||||||
return () => ipcRenderer.removeListener("on-download-progress", listener);
|
return () => ipcRenderer.removeListener("on-download-progress", listener);
|
||||||
},
|
},
|
||||||
|
onHardDelete: (cb: () => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||||
|
ipcRenderer.on("on-hard-delete", listener);
|
||||||
|
return () => ipcRenderer.removeListener("on-hard-delete", listener);
|
||||||
|
},
|
||||||
|
onSeedingStatus: (cb: (value: SeedingStatus[]) => void) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
value: SeedingStatus[]
|
||||||
|
) => cb(value);
|
||||||
|
ipcRenderer.on("on-seeding-status", listener);
|
||||||
|
return () => ipcRenderer.removeListener("on-seeding-status", listener);
|
||||||
|
},
|
||||||
|
|
||||||
/* Catalogue */
|
/* Catalogue */
|
||||||
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
||||||
|
@ -103,6 +103,14 @@ export function App() {
|
|||||||
};
|
};
|
||||||
}, [clearDownload, setLastPacket, updateLibrary]);
|
}, [clearDownload, setLastPacket, updateLibrary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electron.onHardDelete(() => {
|
||||||
|
updateLibrary();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [updateLibrary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cachedUserDetails = window.localStorage.getItem("userDetails");
|
const cachedUserDetails = window.localStorage.getItem("userDetails");
|
||||||
|
|
||||||
|
68
src/renderer/src/components/dropdown-menu/dropdown-menu.scss
Normal file
68
src/renderer/src/components/dropdown-menu/dropdown-menu.scss
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
&__content {
|
||||||
|
background-color: globals.$dark-background-color;
|
||||||
|
border: 1px solid globals.$border-color;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-width: 200px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__group {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-bar {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: globals.$muted-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__separator {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background-color: globals.$border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s ease-in-out;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item--disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(&__item--disabled) &__item:hover {
|
||||||
|
background-color: globals.$background-color;
|
||||||
|
color: globals.$muted-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item:focus {
|
||||||
|
background-color: globals.$background-color;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
81
src/renderer/src/components/dropdown-menu/dropdown-menu.tsx
Normal file
81
src/renderer/src/components/dropdown-menu/dropdown-menu.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import "./dropdown-menu.scss";
|
||||||
|
|
||||||
|
export interface DropdownMenuItem {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
show?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropdownMenuProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
loop?: boolean;
|
||||||
|
items: DropdownMenuItem[];
|
||||||
|
sideOffset?: number;
|
||||||
|
side?: "top" | "bottom" | "left" | "right";
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
|
alignOffset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenu({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
sideOffset = 5,
|
||||||
|
side = "bottom",
|
||||||
|
loop = true,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
}: DropdownMenuProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Root>
|
||||||
|
<DropdownMenuPrimitive.Trigger asChild>
|
||||||
|
<div aria-label={title}>{children}</div>
|
||||||
|
</DropdownMenuPrimitive.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
side={side}
|
||||||
|
loop={loop}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
className="dropdown-menu__content"
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<DropdownMenuPrimitive.Group className="dropdown-menu__group">
|
||||||
|
<div className="dropdown-menu__title-bar">{title}</div>
|
||||||
|
</DropdownMenuPrimitive.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Separator className="dropdown-menu__separator" />
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Group className="dropdown-menu__group">
|
||||||
|
{items.map(
|
||||||
|
(item) =>
|
||||||
|
item.show !== false && (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
key={item.label}
|
||||||
|
aria-label={item.label}
|
||||||
|
onSelect={item.onClick}
|
||||||
|
className={`dropdown-menu__item ${item.disabled ? "dropdown-menu__item--disabled" : ""}`}
|
||||||
|
disabled={item.disabled}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<div className="dropdown-menu__item-icon">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.label}
|
||||||
|
</DropdownMenuPrimitive.Item>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</DropdownMenuPrimitive.Group>
|
||||||
|
</DropdownMenuPrimitive.Content>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
</DropdownMenuPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
7
src/renderer/src/declaration.d.ts
vendored
7
src/renderer/src/declaration.d.ts
vendored
@ -10,6 +10,7 @@ import type {
|
|||||||
ShopDetails,
|
ShopDetails,
|
||||||
Steam250Game,
|
Steam250Game,
|
||||||
DownloadProgress,
|
DownloadProgress,
|
||||||
|
SeedingStatus,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
StartGameDownloadPayload,
|
StartGameDownloadPayload,
|
||||||
RealDebridUser,
|
RealDebridUser,
|
||||||
@ -46,9 +47,15 @@ declare global {
|
|||||||
cancelGameDownload: (gameId: number) => Promise<void>;
|
cancelGameDownload: (gameId: number) => Promise<void>;
|
||||||
pauseGameDownload: (gameId: number) => Promise<void>;
|
pauseGameDownload: (gameId: number) => Promise<void>;
|
||||||
resumeGameDownload: (gameId: number) => Promise<void>;
|
resumeGameDownload: (gameId: number) => Promise<void>;
|
||||||
|
pauseGameSeed: (gameId: number) => Promise<void>;
|
||||||
|
resumeGameSeed: (gameId: number) => Promise<void>;
|
||||||
onDownloadProgress: (
|
onDownloadProgress: (
|
||||||
cb: (value: DownloadProgress) => void
|
cb: (value: DownloadProgress) => void
|
||||||
) => () => Electron.IpcRenderer;
|
) => () => Electron.IpcRenderer;
|
||||||
|
onSeedingStatus: (
|
||||||
|
cb: (value: SeedingStatus[]) => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
|
onHardDelete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||||
|
|
||||||
/* Catalogue */
|
/* Catalogue */
|
||||||
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
||||||
|
@ -66,6 +66,16 @@ export function useDownload() {
|
|||||||
updateLibrary();
|
updateLibrary();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pauseSeeding = async (gameId: number) => {
|
||||||
|
await window.electron.pauseGameSeed(gameId);
|
||||||
|
await updateLibrary();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeSeeding = async (gameId: number) => {
|
||||||
|
await window.electron.resumeGameSeed(gameId);
|
||||||
|
await updateLibrary();
|
||||||
|
};
|
||||||
|
|
||||||
const calculateETA = () => {
|
const calculateETA = () => {
|
||||||
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
|
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
|
||||||
|
|
||||||
@ -96,6 +106,8 @@ export function useDownload() {
|
|||||||
removeGameFromLibrary,
|
removeGameFromLibrary,
|
||||||
removeGameInstaller,
|
removeGameInstaller,
|
||||||
isGameDeleting,
|
isGameDeleting,
|
||||||
|
pauseSeeding,
|
||||||
|
resumeSeeding,
|
||||||
clearDownload: () => dispatch(clearDownload()),
|
clearDownload: () => dispatch(clearDownload()),
|
||||||
setLastPacket: (packet: DownloadProgress) =>
|
setLastPacket: (packet: DownloadProgress) =>
|
||||||
dispatch(setLastPacket(packet)),
|
dispatch(setLastPacket(packet)),
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { LibraryGame } from "@types";
|
import type { LibraryGame, SeedingStatus } from "@types";
|
||||||
|
|
||||||
import { Badge, Button } from "@renderer/components";
|
import { Badge, Button } from "@renderer/components";
|
||||||
import {
|
import {
|
||||||
@ -15,12 +15,28 @@ import { useAppSelector, useDownload } from "@renderer/hooks";
|
|||||||
import * as styles from "./download-group.css";
|
import * as styles from "./download-group.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@renderer/components/dropdown-menu/dropdown-menu";
|
||||||
|
import {
|
||||||
|
ColumnsIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
LinkIcon,
|
||||||
|
PlayIcon,
|
||||||
|
ThreeBarsIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UnlinkIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from "@primer/octicons-react";
|
||||||
|
|
||||||
export interface DownloadGroupProps {
|
export interface DownloadGroupProps {
|
||||||
library: LibraryGame[];
|
library: LibraryGame[];
|
||||||
title: string;
|
title: string;
|
||||||
openDeleteGameModal: (gameId: number) => void;
|
openDeleteGameModal: (gameId: number) => void;
|
||||||
openGameInstaller: (gameId: number) => void;
|
openGameInstaller: (gameId: number) => void;
|
||||||
|
seedingStatus: SeedingStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadGroup({
|
export function DownloadGroup({
|
||||||
@ -28,6 +44,7 @@ export function DownloadGroup({
|
|||||||
title,
|
title,
|
||||||
openDeleteGameModal,
|
openDeleteGameModal,
|
||||||
openGameInstaller,
|
openGameInstaller,
|
||||||
|
seedingStatus,
|
||||||
}: DownloadGroupProps) {
|
}: DownloadGroupProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -44,6 +61,8 @@ export function DownloadGroup({
|
|||||||
resumeDownload,
|
resumeDownload,
|
||||||
cancelDownload,
|
cancelDownload,
|
||||||
isGameDeleting,
|
isGameDeleting,
|
||||||
|
pauseSeeding,
|
||||||
|
resumeSeeding,
|
||||||
} = useDownload();
|
} = useDownload();
|
||||||
|
|
||||||
const getFinalDownloadSize = (game: LibraryGame) => {
|
const getFinalDownloadSize = (game: LibraryGame) => {
|
||||||
@ -57,9 +76,20 @@ export function DownloadGroup({
|
|||||||
return "N/A";
|
return "N/A";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const seedingMap = useMemo(() => {
|
||||||
|
const map = new Map<number, SeedingStatus>();
|
||||||
|
|
||||||
|
seedingStatus.forEach((seed) => {
|
||||||
|
map.set(seed.gameId, seed);
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [seedingStatus]);
|
||||||
|
|
||||||
const getGameInfo = (game: LibraryGame) => {
|
const getGameInfo = (game: LibraryGame) => {
|
||||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||||
const finalDownloadSize = getFinalDownloadSize(game);
|
const finalDownloadSize = getFinalDownloadSize(game);
|
||||||
|
const seedingStatus = seedingMap.get(game.id);
|
||||||
|
|
||||||
if (isGameDeleting(game.id)) {
|
if (isGameDeleting(game.id)) {
|
||||||
return <p>{t("deleting")}</p>;
|
return <p>{t("deleting")}</p>;
|
||||||
@ -98,7 +128,17 @@ export function DownloadGroup({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (game.progress === 1) {
|
if (game.progress === 1) {
|
||||||
return <p>{t("completed")}</p>;
|
const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0);
|
||||||
|
|
||||||
|
return game.status === "seeding" &&
|
||||||
|
game.downloader === Downloader.Torrent ? (
|
||||||
|
<>
|
||||||
|
<p>{t("seeding")}</p>
|
||||||
|
{uploadSpeed && <p>{uploadSpeed}/s</p>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>{t("completed")}</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game.status === "paused") {
|
if (game.status === "paused") {
|
||||||
@ -125,59 +165,74 @@ export function DownloadGroup({
|
|||||||
return <p>{t(game.status as string)}</p>;
|
return <p>{t(game.status as string)}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGameActions = (game: LibraryGame) => {
|
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
|
||||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||||
|
|
||||||
const deleting = isGameDeleting(game.id);
|
const deleting = isGameDeleting(game.id);
|
||||||
|
|
||||||
if (game.progress === 1) {
|
if (game.progress === 1) {
|
||||||
return (
|
return [
|
||||||
<>
|
{
|
||||||
<Button
|
label: t("install"),
|
||||||
onClick={() => openGameInstaller(game.id)}
|
disabled: deleting,
|
||||||
theme="outline"
|
onClick: () => openGameInstaller(game.id),
|
||||||
disabled={deleting}
|
icon: <DownloadIcon />,
|
||||||
>
|
},
|
||||||
{t("install")}
|
{
|
||||||
</Button>
|
label: t("stop_seeding"),
|
||||||
|
disabled: deleting,
|
||||||
<Button onClick={() => openDeleteGameModal(game.id)} theme="outline">
|
icon: <UnlinkIcon />,
|
||||||
{t("delete")}
|
show:
|
||||||
</Button>
|
game.status === "seeding" && game.downloader === Downloader.Torrent,
|
||||||
</>
|
onClick: () => pauseSeeding(game.id),
|
||||||
);
|
},
|
||||||
|
{
|
||||||
|
label: t("resume_seeding"),
|
||||||
|
disabled: deleting,
|
||||||
|
icon: <LinkIcon />,
|
||||||
|
show:
|
||||||
|
game.status !== "seeding" && game.downloader === Downloader.Torrent,
|
||||||
|
onClick: () => resumeSeeding(game.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("delete"),
|
||||||
|
disabled: deleting,
|
||||||
|
icon: <TrashIcon />,
|
||||||
|
onClick: () => openDeleteGameModal(game.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGameDownloading || game.status === "active") {
|
if (isGameDownloading || game.status === "active") {
|
||||||
return (
|
return [
|
||||||
<>
|
{
|
||||||
<Button onClick={() => pauseDownload(game.id)} theme="outline">
|
label: t("pause"),
|
||||||
{t("pause")}
|
onClick: () => pauseDownload(game.id),
|
||||||
</Button>
|
icon: <ColumnsIcon />,
|
||||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
},
|
||||||
{t("cancel")}
|
{
|
||||||
</Button>
|
label: t("cancel"),
|
||||||
</>
|
onClick: () => cancelDownload(game.id),
|
||||||
);
|
icon: <XCircleIcon />,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return [
|
||||||
<>
|
{
|
||||||
<Button
|
label: t("resume"),
|
||||||
onClick={() => resumeDownload(game.id)}
|
disabled:
|
||||||
theme="outline"
|
game.downloader === Downloader.RealDebrid &&
|
||||||
disabled={
|
!userPreferences?.realDebridApiToken,
|
||||||
game.downloader === Downloader.RealDebrid &&
|
onClick: () => resumeDownload(game.id),
|
||||||
!userPreferences?.realDebridApiToken
|
icon: <PlayIcon />,
|
||||||
}
|
},
|
||||||
>
|
{
|
||||||
{t("resume")}
|
label: t("cancel"),
|
||||||
</Button>
|
onClick: () => cancelDownload(game.id),
|
||||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
icon: <XCircleIcon />,
|
||||||
{t("cancel")}
|
},
|
||||||
</Button>
|
];
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!library.length) return null;
|
if (!library.length) return null;
|
||||||
@ -207,7 +262,11 @@ export function DownloadGroup({
|
|||||||
<ul className={styles.downloads}>
|
<ul className={styles.downloads}>
|
||||||
{library.map((game) => {
|
{library.map((game) => {
|
||||||
return (
|
return (
|
||||||
<li key={game.id} className={styles.download}>
|
<li
|
||||||
|
key={game.id}
|
||||||
|
className={styles.download}
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
>
|
||||||
<div className={styles.downloadCover}>
|
<div className={styles.downloadCover}>
|
||||||
<div className={styles.downloadCoverBackdrop}>
|
<div className={styles.downloadCoverBackdrop}>
|
||||||
<img
|
<img
|
||||||
@ -243,9 +302,28 @@ export function DownloadGroup({
|
|||||||
{getGameInfo(game)}
|
{getGameInfo(game)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.downloadActions}>
|
{getGameActions(game) !== null && (
|
||||||
{getGameActions(game)}
|
<DropdownMenu
|
||||||
</div>
|
align="end"
|
||||||
|
items={getGameActions(game)}
|
||||||
|
sideOffset={-75}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "12px",
|
||||||
|
right: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "none",
|
||||||
|
padding: "8px",
|
||||||
|
minHeight: "unset",
|
||||||
|
}}
|
||||||
|
theme="outline"
|
||||||
|
>
|
||||||
|
<ThreeBarsIcon />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
@ -2,12 +2,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||||
import * as styles from "./downloads.css";
|
import * as styles from "./downloads.css";
|
||||||
import { DeleteGameModal } from "./delete-game-modal";
|
import { DeleteGameModal } from "./delete-game-modal";
|
||||||
import { DownloadGroup } from "./download-group";
|
import { DownloadGroup } from "./download-group";
|
||||||
import type { LibraryGame } from "@types";
|
import type { LibraryGame, SeedingStatus } from "@types";
|
||||||
import { orderBy } from "lodash-es";
|
import { orderBy } from "lodash-es";
|
||||||
import { ArrowDownIcon } from "@primer/octicons-react";
|
import { ArrowDownIcon } from "@primer/octicons-react";
|
||||||
|
|
||||||
@ -21,15 +21,23 @@ export default function Downloads() {
|
|||||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
|
||||||
const { removeGameInstaller } = useDownload();
|
const { removeGameInstaller, pauseSeeding } = useDownload();
|
||||||
|
|
||||||
const handleDeleteGame = async () => {
|
const handleDeleteGame = async () => {
|
||||||
if (gameToBeDeleted.current)
|
if (gameToBeDeleted.current) {
|
||||||
|
await pauseSeeding(gameToBeDeleted.current);
|
||||||
await removeGameInstaller(gameToBeDeleted.current);
|
await removeGameInstaller(gameToBeDeleted.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { lastPacket } = useDownload();
|
const { lastPacket } = useDownload();
|
||||||
|
|
||||||
|
const [seedingStatus, setSeedingStatus] = useState<SeedingStatus[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleOpenGameInstaller = (gameId: number) =>
|
const handleOpenGameInstaller = (gameId: number) =>
|
||||||
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
|
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
|
||||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||||
@ -119,9 +127,10 @@ export default function Downloads() {
|
|||||||
<DownloadGroup
|
<DownloadGroup
|
||||||
key={group.title}
|
key={group.title}
|
||||||
title={group.title}
|
title={group.title}
|
||||||
library={group.library}
|
library={orderBy(group.library, ["updatedAt"], ["desc"])}
|
||||||
openDeleteGameModal={handleOpenDeleteGameModal}
|
openDeleteGameModal={handleOpenDeleteGameModal}
|
||||||
openGameInstaller={handleOpenGameInstaller}
|
openGameInstaller={handleOpenGameInstaller}
|
||||||
|
seedingStatus={seedingStatus}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -179,7 +179,7 @@ export function ProfileContent() {
|
|||||||
game.achievementCount > 0 && (
|
game.achievementCount > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
color: "white",
|
color: "#fff",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@ -235,6 +235,8 @@ export function ProfileContent() {
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
minWidth: "100%",
|
||||||
|
minHeight: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
@ -19,6 +19,7 @@ export function SettingsBehavior() {
|
|||||||
runAtStartup: false,
|
runAtStartup: false,
|
||||||
startMinimized: false,
|
startMinimized: false,
|
||||||
disableNsfwAlert: false,
|
disableNsfwAlert: false,
|
||||||
|
seedAfterDownloadComplete: false,
|
||||||
showHiddenAchievementsDescription: false,
|
showHiddenAchievementsDescription: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -31,6 +32,7 @@ export function SettingsBehavior() {
|
|||||||
runAtStartup: userPreferences.runAtStartup,
|
runAtStartup: userPreferences.runAtStartup,
|
||||||
startMinimized: userPreferences.startMinimized,
|
startMinimized: userPreferences.startMinimized,
|
||||||
disableNsfwAlert: userPreferences.disableNsfwAlert,
|
disableNsfwAlert: userPreferences.disableNsfwAlert,
|
||||||
|
seedAfterDownloadComplete: userPreferences.seedAfterDownloadComplete,
|
||||||
showHiddenAchievementsDescription:
|
showHiddenAchievementsDescription:
|
||||||
userPreferences.showHiddenAchievementsDescription,
|
userPreferences.showHiddenAchievementsDescription,
|
||||||
});
|
});
|
||||||
@ -100,6 +102,16 @@ export function SettingsBehavior() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CheckboxField
|
||||||
|
label={t("seed_after_download_complete")}
|
||||||
|
checked={form.seedAfterDownloadComplete}
|
||||||
|
onChange={() =>
|
||||||
|
handleChange({
|
||||||
|
seedAfterDownloadComplete: !form.seedAfterDownloadComplete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
label={t("show_hidden_achievement_description")}
|
label={t("show_hidden_achievement_description")}
|
||||||
checked={form.showHiddenAchievementsDescription}
|
checked={form.showHiddenAchievementsDescription}
|
||||||
|
@ -3,12 +3,3 @@ export interface HowLongToBeatCategory {
|
|||||||
duration: string;
|
duration: string;
|
||||||
accuracy: string;
|
accuracy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HowLongToBeatResult {
|
|
||||||
game_id: number;
|
|
||||||
game_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HowLongToBeatSearchResponse {
|
|
||||||
data: HowLongToBeatResult[];
|
|
||||||
}
|
|
||||||
|
@ -7,6 +7,7 @@ export type GameStatus =
|
|||||||
| "paused"
|
| "paused"
|
||||||
| "error"
|
| "error"
|
||||||
| "complete"
|
| "complete"
|
||||||
|
| "seeding"
|
||||||
| "removed";
|
| "removed";
|
||||||
|
|
||||||
export type GameShop = "steam" | "epic";
|
export type GameShop = "steam" | "epic";
|
||||||
@ -124,6 +125,7 @@ export interface Game {
|
|||||||
objectID: string;
|
objectID: string;
|
||||||
shop: GameShop;
|
shop: GameShop;
|
||||||
downloadQueue: DownloadQueue | null;
|
downloadQueue: DownloadQueue | null;
|
||||||
|
shouldSeed: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@ -151,6 +153,12 @@ export interface DownloadProgress {
|
|||||||
game: LibraryGame;
|
game: LibraryGame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SeedingStatus {
|
||||||
|
gameId: number;
|
||||||
|
status: GameStatus;
|
||||||
|
uploadSpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
downloadsPath: string | null;
|
downloadsPath: string | null;
|
||||||
language: string;
|
language: string;
|
||||||
@ -162,6 +170,7 @@ export interface UserPreferences {
|
|||||||
runAtStartup: boolean;
|
runAtStartup: boolean;
|
||||||
startMinimized: boolean;
|
startMinimized: boolean;
|
||||||
disableNsfwAlert: boolean;
|
disableNsfwAlert: boolean;
|
||||||
|
seedAfterDownloadComplete: boolean;
|
||||||
showHiddenAchievementsDescription: boolean;
|
showHiddenAchievementsDescription: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,3 +388,4 @@ export * from "./steam.types";
|
|||||||
export * from "./real-debrid.types";
|
export * from "./real-debrid.types";
|
||||||
export * from "./ludusavi.types";
|
export * from "./ludusavi.types";
|
||||||
export * from "./how-long-to-beat.types";
|
export * from "./how-long-to-beat.types";
|
||||||
|
export * from "./torbox.types";
|
||||||
|
77
src/types/torbox.types.ts
Normal file
77
src/types/torbox.types.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
export interface TorBoxUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
plan: string;
|
||||||
|
expiration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorBoxUserRequest {
|
||||||
|
success: boolean;
|
||||||
|
detail: string;
|
||||||
|
error: string;
|
||||||
|
data: TorBoxUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorBoxFile {
|
||||||
|
id: number;
|
||||||
|
md5: string;
|
||||||
|
s3_path: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mimetype: string;
|
||||||
|
short_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorBoxTorrentInfo {
|
||||||
|
id: number;
|
||||||
|
hash: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
magnet: string;
|
||||||
|
size: number;
|
||||||
|
active: boolean;
|
||||||
|
cached: boolean;
|
||||||
|
auth_id: string;
|
||||||
|
download_state:
|
||||||
|
| "downloading"
|
||||||
|
| "uploading"
|
||||||
|
| "stalled (no seeds)"
|
||||||
|
| "paused"
|
||||||
|
| "completed"
|
||||||
|
| "cached"
|
||||||
|
| "metaDL"
|
||||||
|
| "checkingResumeData";
|
||||||
|
seeds: number;
|
||||||
|
ratio: number;
|
||||||
|
progress: number;
|
||||||
|
download_speed: number;
|
||||||
|
upload_speed: number;
|
||||||
|
name: string;
|
||||||
|
eta: number;
|
||||||
|
files: TorBoxFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorBoxTorrentInfoRequest {
|
||||||
|
success: boolean;
|
||||||
|
detail: string;
|
||||||
|
error: string;
|
||||||
|
data: TorBoxTorrentInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorBoxAddTorrentRequest {
|
||||||
|
success: boolean;
|
||||||
|
detail: string;
|
||||||
|
error: string;
|
||||||
|
data: {
|
||||||
|
torrent_id: number;
|
||||||
|
name: string;
|
||||||
|
hash: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorBoxRequestLinkRequest {
|
||||||
|
success: boolean;
|
||||||
|
detail: string;
|
||||||
|
error: string;
|
||||||
|
data: string;
|
||||||
|
}
|
@ -1,121 +0,0 @@
|
|||||||
import sys, json, urllib.parse, psutil
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
from torrent_downloader import TorrentDownloader
|
|
||||||
from profile_image_processor import ProfileImageProcessor
|
|
||||||
|
|
||||||
torrent_port = sys.argv[1]
|
|
||||||
http_port = sys.argv[2]
|
|
||||||
rpc_password = sys.argv[3]
|
|
||||||
start_download_payload = sys.argv[4]
|
|
||||||
|
|
||||||
torrent_downloader = None
|
|
||||||
|
|
||||||
if start_download_payload:
|
|
||||||
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
|
|
||||||
torrent_downloader = TorrentDownloader(torrent_port)
|
|
||||||
torrent_downloader.start_download(initial_download['game_id'], initial_download['magnet'], initial_download['save_path'])
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
rpc_password_header = 'x-hydra-rpc-password'
|
|
||||||
|
|
||||||
skip_log_routes = [
|
|
||||||
"process-list",
|
|
||||||
"status"
|
|
||||||
]
|
|
||||||
|
|
||||||
def log_error(self, format, *args):
|
|
||||||
sys.stderr.write("%s - - [%s] %s\n" %
|
|
||||||
(self.address_string(),
|
|
||||||
self.log_date_time_string(),
|
|
||||||
format%args))
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
for route in self.skip_log_routes:
|
|
||||||
if route in args[0]: return
|
|
||||||
|
|
||||||
super().log_message(format, *args)
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
if self.path == "/status":
|
|
||||||
if self.headers.get(self.rpc_password_header) != rpc_password:
|
|
||||||
self.send_response(401)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
status = torrent_downloader.get_download_status()
|
|
||||||
|
|
||||||
self.wfile.write(json.dumps(status).encode('utf-8'))
|
|
||||||
|
|
||||||
elif self.path == "/healthcheck":
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
elif self.path == "/process-list":
|
|
||||||
if self.headers.get(self.rpc_password_header) != rpc_password:
|
|
||||||
self.send_response(401)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
|
|
||||||
process_list = [proc.info for proc in psutil.process_iter(['exe', 'pid', 'name'])]
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
self.wfile.write(json.dumps(process_list).encode('utf-8'))
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
global torrent_downloader
|
|
||||||
|
|
||||||
if self.headers.get(self.rpc_password_header) != rpc_password:
|
|
||||||
self.send_response(401)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
|
|
||||||
content_length = int(self.headers['Content-Length'])
|
|
||||||
post_data = self.rfile.read(content_length)
|
|
||||||
data = json.loads(post_data.decode('utf-8'))
|
|
||||||
|
|
||||||
if self.path == "/profile-image":
|
|
||||||
parsed_image_path = data['image_path']
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed_image_path, mime_type = ProfileImageProcessor.process_image(parsed_image_path)
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
self.wfile.write(json.dumps({'imagePath': parsed_image_path, 'mimeType': mime_type}).encode('utf-8'))
|
|
||||||
except:
|
|
||||||
self.send_response(400)
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
elif self.path == "/action":
|
|
||||||
if torrent_downloader is None:
|
|
||||||
torrent_downloader = TorrentDownloader(torrent_port)
|
|
||||||
|
|
||||||
if data['action'] == 'start':
|
|
||||||
torrent_downloader.start_download(data['game_id'], data['magnet'], data['save_path'])
|
|
||||||
elif data['action'] == 'pause':
|
|
||||||
torrent_downloader.pause_download(data['game_id'])
|
|
||||||
elif data['action'] == 'cancel':
|
|
||||||
torrent_downloader.cancel_download(data['game_id'])
|
|
||||||
elif data['action'] == 'kill-torrent':
|
|
||||||
torrent_downloader.abort_session()
|
|
||||||
torrent_downloader = None
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
else:
|
|
||||||
self.send_response(404)
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
httpd = HTTPServer(("", int(http_port)), Handler)
|
|
||||||
httpd.serve_forever()
|
|
321
yarn.lock
321
yarn.lock
@ -1634,6 +1634,33 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
|
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
|
||||||
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
|
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
|
||||||
|
|
||||||
|
"@floating-ui/core@^1.6.0":
|
||||||
|
version "1.6.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.8.tgz#aa43561be075815879305965020f492cdb43da12"
|
||||||
|
integrity sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/utils" "^0.2.8"
|
||||||
|
|
||||||
|
"@floating-ui/dom@^1.0.0":
|
||||||
|
version "1.6.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.12.tgz#6333dcb5a8ead3b2bf82f33d6bc410e95f54e556"
|
||||||
|
integrity sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/core" "^1.6.0"
|
||||||
|
"@floating-ui/utils" "^0.2.8"
|
||||||
|
|
||||||
|
"@floating-ui/react-dom@^2.0.0":
|
||||||
|
version "2.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31"
|
||||||
|
integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/dom" "^1.0.0"
|
||||||
|
|
||||||
|
"@floating-ui/utils@^0.2.8":
|
||||||
|
version "0.2.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
|
||||||
|
integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==
|
||||||
|
|
||||||
"@fontsource/noto-sans@^5.1.0":
|
"@fontsource/noto-sans@^5.1.0":
|
||||||
version "5.1.0"
|
version "5.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@fontsource/noto-sans/-/noto-sans-5.1.0.tgz#54a5edd1b2b8c8e17bec6a85d4ee3a53b4b89c1f"
|
resolved "https://registry.yarnpkg.com/@fontsource/noto-sans/-/noto-sans-5.1.0.tgz#54a5edd1b2b8c8e17bec6a85d4ee3a53b4b89c1f"
|
||||||
@ -1903,6 +1930,221 @@
|
|||||||
resolved "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.9.0.tgz"
|
resolved "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.9.0.tgz"
|
||||||
integrity sha512-Uk4XrHyfylyfzZN9d8VFjF8FpfYHEyT4sabw+9+oP+GWAJHhPvNPTz6gXvUzJZmoblAvgcTrDslIPjz8zMh76w==
|
integrity sha512-Uk4XrHyfylyfzZN9d8VFjF8FpfYHEyT4sabw+9+oP+GWAJHhPvNPTz6gXvUzJZmoblAvgcTrDslIPjz8zMh76w==
|
||||||
|
|
||||||
|
"@radix-ui/primitive@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.0.tgz#42ef83b3b56dccad5d703ae8c42919a68798bbe2"
|
||||||
|
integrity sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==
|
||||||
|
|
||||||
|
"@radix-ui/react-arrow@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz#744f388182d360b86285217e43b6c63633f39e7a"
|
||||||
|
integrity sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-collection@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.0.tgz#f18af78e46454a2360d103c2251773028b7724ed"
|
||||||
|
integrity sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-compose-refs" "1.1.0"
|
||||||
|
"@radix-ui/react-context" "1.1.0"
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
"@radix-ui/react-slot" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-compose-refs@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz#656432461fc8283d7b591dcf0d79152fae9ecc74"
|
||||||
|
integrity sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==
|
||||||
|
|
||||||
|
"@radix-ui/react-context@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.0.tgz#6df8d983546cfd1999c8512f3a8ad85a6e7fcee8"
|
||||||
|
integrity sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==
|
||||||
|
|
||||||
|
"@radix-ui/react-context@1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.1.tgz#82074aa83a472353bb22e86f11bcbd1c61c4c71a"
|
||||||
|
integrity sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==
|
||||||
|
|
||||||
|
"@radix-ui/react-direction@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz#a7d39855f4d077adc2a1922f9c353c5977a09cdc"
|
||||||
|
integrity sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer@1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz#cbdcb739c5403382bdde5f9243042ba643883396"
|
||||||
|
integrity sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/primitive" "1.1.0"
|
||||||
|
"@radix-ui/react-compose-refs" "1.1.0"
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
"@radix-ui/react-use-callback-ref" "1.1.0"
|
||||||
|
"@radix-ui/react-use-escape-keydown" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-dropdown-menu@^2.1.2":
|
||||||
|
version "2.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz#acc49577130e3c875ef0133bd1e271ea3392d924"
|
||||||
|
integrity sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/primitive" "1.1.0"
|
||||||
|
"@radix-ui/react-compose-refs" "1.1.0"
|
||||||
|
"@radix-ui/react-context" "1.1.1"
|
||||||
|
"@radix-ui/react-id" "1.1.0"
|
||||||
|
"@radix-ui/react-menu" "2.1.2"
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
"@radix-ui/react-use-controllable-state" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-guards@1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe"
|
||||||
|
integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz#ebe2891a298e0a33ad34daab2aad8dea31caf0b2"
|
||||||
|
integrity sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-compose-refs" "1.1.0"
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
"@radix-ui/react-use-callback-ref" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-id@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.0.tgz#de47339656594ad722eb87f94a6b25f9cffae0ed"
|
||||||
|
integrity sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-use-layout-effect" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-menu@2.1.2":
|
||||||
|
version "2.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.1.2.tgz#91f6815845a4298dde775563ed2d80b7ad667899"
|
||||||
|
integrity sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/primitive" "1.1.0"
|
||||||
|
"@radix-ui/react-collection" "1.1.0"
|
||||||
|
"@radix-ui/react-compose-refs" "1.1.0"
|
||||||
|
"@radix-ui/react-context" "1.1.1"
|
||||||
|
"@radix-ui/react-direction" "1.1.0"
|
||||||
|
"@radix-ui/react-dismissable-layer" "1.1.1"
|
||||||
|
"@radix-ui/react-focus-guards" "1.1.1"
|
||||||
|
"@radix-ui/react-focus-scope" "1.1.0"
|
||||||
|
"@radix-ui/react-id" "1.1.0"
|
||||||
|
"@radix-ui/react-popper" "1.2.0"
|
||||||
|
"@radix-ui/react-portal" "1.1.2"
|
||||||
|
"@radix-ui/react-presence" "1.1.1"
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
"@radix-ui/react-roving-focus" "1.1.0"
|
||||||
|
"@radix-ui/react-slot" "1.1.0"
|
||||||
|
"@radix-ui/react-use-callback-ref" "1.1.0"
|
||||||
|
aria-hidden "^1.1.1"
|
||||||
|
react-remove-scroll "2.6.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-popper@1.2.0":
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz#a3e500193d144fe2d8f5d5e60e393d64111f2a7a"
|
||||||
|
integrity sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/react-dom" "^2.0.0"
|
||||||
|
"@radix-ui/react-arrow" "1.1.0"
|
||||||
|
"@radix-ui/react-compose-refs" "1.1.0"
|
||||||
|
"@radix-ui/react-context" "1.1.0"
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
"@radix-ui/react-use-callback-ref" "1.1.0"
|
||||||
|
"@radix-ui/react-use-layout-effect" "1.1.0"
|
||||||
|
"@radix-ui/react-use-rect" "1.1.0"
|
||||||
|
"@radix-ui/react-use-size" "1.1.0"
|
||||||
|
"@radix-ui/rect" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-portal@1.1.2":
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.2.tgz#51eb46dae7505074b306ebcb985bf65cc547d74e"
|
||||||
|
integrity sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
"@radix-ui/react-use-layout-effect" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-presence@1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.1.tgz#98aba423dba5e0c687a782c0669dcd99de17f9b1"
|
||||||
|
integrity sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-compose-refs" "1.1.0"
|
||||||
|
"@radix-ui/react-use-layout-effect" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive@2.0.0":
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz#fe05715faa9203a223ccc0be15dc44b9f9822884"
|
||||||
|
integrity sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-slot" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-roving-focus@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz#b30c59daf7e714c748805bfe11c76f96caaac35e"
|
||||||
|
integrity sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/primitive" "1.1.0"
|
||||||
|
"@radix-ui/react-collection" "1.1.0"
|
||||||
|
"@radix-ui/react-compose-refs" "1.1.0"
|
||||||
|
"@radix-ui/react-context" "1.1.0"
|
||||||
|
"@radix-ui/react-direction" "1.1.0"
|
||||||
|
"@radix-ui/react-id" "1.1.0"
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
"@radix-ui/react-use-callback-ref" "1.1.0"
|
||||||
|
"@radix-ui/react-use-controllable-state" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-slot@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.0.tgz#7c5e48c36ef5496d97b08f1357bb26ed7c714b84"
|
||||||
|
integrity sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-compose-refs" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-use-callback-ref@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1"
|
||||||
|
integrity sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==
|
||||||
|
|
||||||
|
"@radix-ui/react-use-controllable-state@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz#1321446857bb786917df54c0d4d084877aab04b0"
|
||||||
|
integrity sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-use-callback-ref" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-use-escape-keydown@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz#31a5b87c3b726504b74e05dac1edce7437b98754"
|
||||||
|
integrity sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-use-callback-ref" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-use-layout-effect@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27"
|
||||||
|
integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==
|
||||||
|
|
||||||
|
"@radix-ui/react-use-rect@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88"
|
||||||
|
integrity sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/rect" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-use-size@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz#b4dba7fbd3882ee09e8d2a44a3eed3a7e555246b"
|
||||||
|
integrity sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-use-layout-effect" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/rect@1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438"
|
||||||
|
integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==
|
||||||
|
|
||||||
"@reduxjs/toolkit@^2.2.3":
|
"@reduxjs/toolkit@^2.2.3":
|
||||||
version "2.2.5"
|
version "2.2.5"
|
||||||
resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz"
|
resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz"
|
||||||
@ -3366,6 +3608,13 @@ argparse@^2.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
|
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
|
||||||
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
||||||
|
|
||||||
|
aria-hidden@^1.1.1:
|
||||||
|
version "1.2.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522"
|
||||||
|
integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.0.0"
|
||||||
|
|
||||||
aria-query@^5.3.2:
|
aria-query@^5.3.2:
|
||||||
version "5.3.2"
|
version "5.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59"
|
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59"
|
||||||
@ -4312,6 +4561,11 @@ detect-libc@^2.0.0, detect-libc@^2.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700"
|
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700"
|
||||||
integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==
|
integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==
|
||||||
|
|
||||||
|
detect-node-es@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
|
||||||
|
integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
|
||||||
|
|
||||||
detect-node@^2.0.4:
|
detect-node@^2.0.4:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
|
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
|
||||||
@ -5295,6 +5549,11 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@
|
|||||||
has-symbols "^1.0.3"
|
has-symbols "^1.0.3"
|
||||||
hasown "^2.0.0"
|
hasown "^2.0.0"
|
||||||
|
|
||||||
|
get-nonce@^1.0.0:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
|
||||||
|
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
|
||||||
|
|
||||||
get-intrinsic@^1.2.5, get-intrinsic@^1.2.6:
|
get-intrinsic@^1.2.5, get-intrinsic@^1.2.6:
|
||||||
version "1.2.6"
|
version "1.2.6"
|
||||||
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.6.tgz#43dd3dd0e7b49b82b2dfcad10dc824bf7fc265d5"
|
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.6.tgz#43dd3dd0e7b49b82b2dfcad10dc824bf7fc265d5"
|
||||||
@ -5768,6 +6027,13 @@ interpret@^2.2.0:
|
|||||||
resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
|
resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
|
||||||
integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
|
integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
|
||||||
|
|
||||||
|
invariant@^2.2.4:
|
||||||
|
version "2.2.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||||
|
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
|
||||||
|
dependencies:
|
||||||
|
loose-envify "^1.0.0"
|
||||||
|
|
||||||
ip-address@^9.0.5:
|
ip-address@^9.0.5:
|
||||||
version "9.0.5"
|
version "9.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a"
|
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a"
|
||||||
@ -6460,7 +6726,7 @@ log-symbols@^4.1.0:
|
|||||||
chalk "^4.1.0"
|
chalk "^4.1.0"
|
||||||
is-unicode-supported "^0.1.0"
|
is-unicode-supported "^0.1.0"
|
||||||
|
|
||||||
loose-envify@^1.1.0, loose-envify@^1.4.0:
|
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||||
@ -7359,6 +7625,25 @@ react-refresh@^0.14.0:
|
|||||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
|
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
|
||||||
integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==
|
integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==
|
||||||
|
|
||||||
|
react-remove-scroll-bar@^2.3.6:
|
||||||
|
version "2.3.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c"
|
||||||
|
integrity sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==
|
||||||
|
dependencies:
|
||||||
|
react-style-singleton "^2.2.1"
|
||||||
|
tslib "^2.0.0"
|
||||||
|
|
||||||
|
react-remove-scroll@2.6.0:
|
||||||
|
version "2.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz#fb03a0845d7768a4f1519a99fdb84983b793dc07"
|
||||||
|
integrity sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==
|
||||||
|
dependencies:
|
||||||
|
react-remove-scroll-bar "^2.3.6"
|
||||||
|
react-style-singleton "^2.2.1"
|
||||||
|
tslib "^2.1.0"
|
||||||
|
use-callback-ref "^1.3.0"
|
||||||
|
use-sidecar "^1.1.2"
|
||||||
|
|
||||||
react-router-dom@^6.22.3:
|
react-router-dom@^6.22.3:
|
||||||
version "6.26.2"
|
version "6.26.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.26.2.tgz#a6e3b0cbd6bfd508e42b9342099d015a0ac59680"
|
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.26.2.tgz#a6e3b0cbd6bfd508e42b9342099d015a0ac59680"
|
||||||
@ -7374,6 +7659,15 @@ react-router@6.26.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@remix-run/router" "1.19.2"
|
"@remix-run/router" "1.19.2"
|
||||||
|
|
||||||
|
react-style-singleton@^2.2.1:
|
||||||
|
version "2.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
|
||||||
|
integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==
|
||||||
|
dependencies:
|
||||||
|
get-nonce "^1.0.0"
|
||||||
|
invariant "^2.2.4"
|
||||||
|
tslib "^2.0.0"
|
||||||
|
|
||||||
react@^18.2.0:
|
react@^18.2.0:
|
||||||
version "18.3.1"
|
version "18.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
|
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
|
||||||
@ -8411,16 +8705,16 @@ ts-node@^10.9.2:
|
|||||||
v8-compile-cache-lib "^3.0.1"
|
v8-compile-cache-lib "^3.0.1"
|
||||||
yn "3.1.1"
|
yn "3.1.1"
|
||||||
|
|
||||||
|
tslib@^2.0.0, tslib@^2.1.0:
|
||||||
|
version "2.8.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||||
|
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||||
|
|
||||||
tslib@^2.0.3, tslib@^2.5.0, tslib@^2.6.2:
|
tslib@^2.0.3, tslib@^2.5.0, tslib@^2.6.2:
|
||||||
version "2.7.0"
|
version "2.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01"
|
||||||
integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==
|
integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==
|
||||||
|
|
||||||
tslib@^2.1.0:
|
|
||||||
version "2.8.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
|
||||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
|
||||||
|
|
||||||
tunnel-agent@^0.6.0:
|
tunnel-agent@^0.6.0:
|
||||||
version "0.6.0"
|
version "0.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
|
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
|
||||||
@ -8614,6 +8908,21 @@ url-parse@^1.5.3:
|
|||||||
querystringify "^2.1.1"
|
querystringify "^2.1.1"
|
||||||
requires-port "^1.0.0"
|
requires-port "^1.0.0"
|
||||||
|
|
||||||
|
use-callback-ref@^1.3.0:
|
||||||
|
version "1.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.2.tgz#6134c7f6ff76e2be0b56c809b17a650c942b1693"
|
||||||
|
integrity sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.0.0"
|
||||||
|
|
||||||
|
use-sidecar@^1.1.2:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
|
||||||
|
integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==
|
||||||
|
dependencies:
|
||||||
|
detect-node-es "^1.1.0"
|
||||||
|
tslib "^2.0.0"
|
||||||
|
|
||||||
use-sync-external-store@^1.0.0:
|
use-sync-external-store@^1.0.0:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9"
|
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9"
|
||||||
|
Loading…
Reference in New Issue
Block a user