chore: merging with main

This commit is contained in:
Chubby Granny Chaser 2024-12-23 19:27:57 +00:00
commit 87bcfe90fe
No known key found for this signature in database
66 changed files with 1829 additions and 852 deletions

View File

@ -1,3 +1,2 @@
MAIN_VITE_API_URL=API_URL
MAIN_VITE_AUTH_URL=AUTH_URL
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY

View File

@ -3,3 +3,4 @@ dist
out
.gitignore
migration.stub
hydra-python-rpc/

View File

@ -31,7 +31,7 @@ jobs:
run: pip install -r requirements.txt
- name: Build with cx_Freeze
run: python torrent-client/setup.py build
run: python python_rpc/setup.py build
- name: Build Linux
if: matrix.os == 'ubuntu-latest'

4
.gitignore vendored
View File

@ -1,7 +1,5 @@
.vscode/
node_modules/
hydra-download-manager/
fastlist.exe
__pycache__
dist
out
@ -10,3 +8,5 @@ out
.env
.vite
ludusavi/
hydra-python-rpc/
aria2/

View File

@ -3,8 +3,9 @@ productName: Hydra
directories:
buildResources: build
extraResources:
- aria2
- ludusavi
- hydra-download-manager
- hydra-python-rpc
- seeds
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
- from: resources/achievement.wav

View File

@ -37,6 +37,7 @@
"@fontsource/noto-sans": "^5.1.0",
"@hookform/resolvers": "^3.9.1",
"@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@reduxjs/toolkit": "^2.2.3",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/dynamic": "^2.1.2",

View 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
View 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))

View File

@ -15,8 +15,8 @@ class ProfileImageProcessor:
mime_type = image.get_format_mimetype()
return image_path, mime_type
else:
newUUID = str(uuid.uuid4())
new_image_path = os.path.join(tempfile.gettempdir(), newUUID) + ".webp"
new_uuid = str(uuid.uuid4())
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + ".webp"
image.save(new_image_path)
new_image = Image.open(new_image_path)

View File

@ -3,18 +3,18 @@ from cx_Freeze import setup, Executable
# Dependencies are automatically detected, but it might need fine tuning.
build_exe_options = {
"packages": ["libtorrent"],
"build_exe": "hydra-download-manager",
"build_exe": "hydra-python-rpc",
"include_msvcr": True
}
setup(
name="hydra-download-manager",
name="hydra-python-rpc",
version="0.1",
description="Hydra",
options={"build_exe": build_exe_options},
executables=[Executable(
"torrent-client/main.py",
target_name="hydra-download-manager",
"python_rpc/main.py",
target_name="hydra-python-rpc",
icon="build/icon.ico"
)]
)

View File

@ -1,10 +1,9 @@
import libtorrent as lt
class TorrentDownloader:
def __init__(self, port: str):
self.torrent_handles = {}
self.downloading_game_id = -1
self.session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=port)})
def __init__(self, torrent_session):
self.torrent_handle = None
self.session = torrent_session
self.trackers = [
"udp://tracker.opentrackr.org:1337/announce",
"http://tracker.opentrackr.org:1337/announce",
@ -102,64 +101,49 @@ class TorrentDownloader:
"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}
torrent_handle = self.session.add_torrent(params)
self.torrent_handles[game_id] = torrent_handle
torrent_handle.set_flags(lt.torrent_flags.auto_managed)
torrent_handle.resume()
self.torrent_handle = self.session.add_torrent(params)
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
self.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):
torrent_handle = self.torrent_handles.get(game_id)
if torrent_handle:
torrent_handle.pause()
torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
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 cancel_download(self):
if self.torrent_handle:
self.torrent_handle.pause()
self.session.remove_torrent(self.torrent_handle)
self.torrent_handle = None
def abort_session(self):
for game_id in self.torrent_handles:
torrent_handle = self.torrent_handles[game_id]
torrent_handle.pause()
self.session.remove_torrent(torrent_handle)
self.torrent_handle = self.torrent_handles[game_id]
self.torrent_handle.pause()
self.session.remove_torrent(self.torrent_handle)
self.session.abort()
self.torrent_handles = {}
self.downloading_game_id = -1
self.torrent_handle = None
def get_download_status(self):
if self.downloading_game_id == -1:
if self.torrent_handle is None:
return None
torrent_handle = self.torrent_handles.get(self.downloading_game_id)
status = torrent_handle.status()
info = torrent_handle.get_torrent_info()
status = self.torrent_handle.status()
info = self.torrent_handle.get_torrent_info()
response = {
'folderName': info.name() if info else "",
'fileSize': info.total_size() if info else 0,
'gameId': self.downloading_game_id,
'progress': status.progress,
'downloadSpeed': status.download_rate,
'uploadSpeed': status.upload_rate,
'numPeers': status.num_peers,
'numSeeds': status.num_seeds,
'status': status.state,
'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

View File

@ -4,3 +4,5 @@ cx_Logging; sys_platform == 'win32'
pywin32; sys_platform == 'win32'
psutil
Pillow
flask
aria2p

View File

@ -2,6 +2,7 @@ const { default: axios } = require("axios");
const util = require("node:util");
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
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();

File diff suppressed because one or more lines are too long

View File

@ -201,7 +201,11 @@
"queued": "Queued",
"no_downloads_title": "Such empty",
"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": {
"downloads_path": "Downloads path",
@ -259,6 +263,7 @@
"enable_achievement_notifications": "When an achievement is unlocked",
"launch_minimized": "Launch Hydra minimized",
"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"
},
"notifications": {

View File

@ -197,7 +197,11 @@
"queued": "Na fila",
"no_downloads_title": "Nada por aqui…",
"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": {
"downloads_path": "Diretório dos downloads",
@ -255,6 +259,7 @@
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
"launch_minimized": "Iniciar o Hydra minimizado",
"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"
},
"notifications": {

View File

@ -105,6 +105,7 @@
"open_folder": "Открыть папку",
"open_download_location": "Просмотреть папку загрузок",
"create_shortcut": "Создать ярлык на рабочем столе",
"clear": "Очистить",
"remove_files": "Удалить файлы",
"remove_from_library_title": "Вы уверены?",
"remove_from_library_description": "{{game}} будет удалена из вашей библиотеки.",
@ -114,7 +115,7 @@
"downloads_secion_title": "Загрузки",
"downloads_section_description": "Проверить наличие обновлений или других версий игры",
"danger_zone_section_title": "Опасная зона",
"danger_zone_section_description": "Удалить эту игру из вашей библиотеки или файлы скачанные Hydra",
"danger_zone_section_description": "Вы можете удалить эту игру из вашей библиотеки или файлы скачанные из Hydra",
"download_in_progress": "Идёт загрузка",
"download_paused": "Загрузка приостановлена",
"last_downloaded_option": "Последний вариант загрузки",
@ -166,7 +167,8 @@
"manage_files_description": "Управляйте файлами, которые будут сохраняться и восстанавливаться",
"select_folder": "Выбрать папку",
"backup_from": "Резервная копия от {{date}}",
"custom_backup_location_set": "Установлено настраиваемое местоположение резервной копии"
"custom_backup_location_set": "Установлено настраиваемое местоположение резервной копии",
"no_directory_selected": "Не выбран каталог"
},
"activation": {
"title": "Активировать Hydra",
@ -199,7 +201,11 @@
"queued": "В очереди",
"no_downloads_title": "Здесь так пусто...",
"no_downloads_description": "Вы ещё ничего не скачали через Hydra, но никогда не поздно начать.",
"checking_files": "Проверка файлов…"
"checking_files": "Проверка файлов…",
"seeding": "Раздача",
"stop_seeding": "Остановить раздачу",
"resume_seeding": "Продолжить раздачу",
"options": "Управлять"
},
"settings": {
"downloads_path": "Путь загрузок",
@ -257,6 +263,7 @@
"required_field": "Это поле обязательно к заполнению",
"source_already_exists": "Этот источник уже добавлен",
"user_unblocked": "Пользователь разблокирован",
"seed_after_download_complete": "Раздавать после завершения загрузки",
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением"
},
"notifications": {
@ -294,7 +301,7 @@
"last_time_played": "Последняя игра {{period}}",
"activity": "Недавняя активность",
"library": "Библиотека",
"total_play_time": "Всего сыграно: {{amount}}",
"total_play_time": "Всего сыграно",
"no_recent_activity_title": "Хммм... Тут ничего нет",
"no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!",
"display_name": "Отображаемое имя",
@ -353,19 +360,36 @@
"report_reason_spam": "Спам",
"report_reason_violence": "Насилие",
"required_field": "Это поле обязательно к заполнению",
"undo_friendship_modal_text": "Это отменит вашу дружбу с {{displayName}}."
"undo_friendship_modal_text": "Это отменит вашу дружбу с {{displayName}}.",
"your_friend_code": "Код вашего друга:",
"upload_banner": "Загрузить баннер",
"uploading_banner": "Загрузка баннера...",
"background_image_updated": "Фоновое изображение обновлено",
"stats": "Статистика",
"games": "Игры",
"top_percentile": "Топ {{percentile}}%",
"ranking_updated_weekly": "Рейтинг обновляется еженедельно",
"playing": "Играет в {{game}}",
"achievements_unlocked": "Достижения разблокированы",
"show_achievements_on_profile": "Покажите свои достижения в профиле",
"show_points_on_profile": "Показывать заработанные очки в своем профиле"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",
"user_achievements": "Достижения {{displayName}}",
"your_achievements": "Ваши достижения",
"unlocked_at": "Разблокировано:",
"unlocked_at": "Разблокировано: {{date}}",
"subscription_needed": "Для просмотра этого содержимого необходима подписка на Hydra Cloud",
"new_achievements_unlocked": "Разблокировано {{achievementCount}} новых достижений из {{gameCount}} игр",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} достижений",
"achievements_unlocked_for_game": "Разблокировано {{achievementCount}} новых достижений для {{gameTitle}}"
"achievements_unlocked_for_game": "Разблокировано {{achievementCount}} новых достижений для {{gameTitle}}",
"hidden_achievement_tooltip": "Это скрытое достижение",
"achievement_earn_points": "Заработайте {{points}} очков с этим достижением",
"earned_points": "Заработано очков:",
"available_points": "Доступные очки:",
"how_to_earn_achievements_points": "Как заработать очки достижений?"
},
"tour": {
"hydra_cloud": {
"subscription_tour_title": "Подписка Hydra Cloud",
"subscribe_now": "Подпишитесь прямо сейчас",
"cloud_saving": "Сохранение в облаке",
@ -373,6 +397,9 @@
"animated_profile_picture": "Анимированные фотографии профиля",
"premium_support": "Премиальная поддержка",
"show_and_compare_achievements": "Показывайте и сравнивайте свои достижения с достижениями других пользователей",
"animated_profile_banner": "Анимированный баннер профиля"
"animated_profile_banner": "Анимированный баннер профиля",
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!",
"learn_more": "Подробнее"
}
}

View File

@ -76,6 +76,9 @@ export class Game {
@Column("boolean", { default: false })
isDeleted: boolean;
@Column("boolean", { default: false })
shouldSeed: boolean;
@CreateDateColumn()
createdAt: Date;

View File

@ -41,6 +41,9 @@ export class UserPreferences {
@Column("boolean", { default: false })
disableNsfwAlert: boolean;
@Column("boolean", { default: true })
seedAfterDownloadComplete: boolean;
@Column("boolean", { default: false })
showHiddenAchievementsDescription: boolean;

View File

@ -1,12 +1,8 @@
import { registerEvent } from "../register-event";
import {
DownloadManager,
HydraApi,
PythonInstance,
gamesPlaytime,
} from "@main/services";
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, UserAuth, UserSubscription } from "@main/entity";
import { PythonRPC } from "@main/services/python-rpc";
const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
const databaseOperations = dataSource
@ -32,7 +28,7 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
DownloadManager.cancelDownload();
/* Disconnects libtorrent */
PythonInstance.killTorrent();
PythonRPC.kill();
HydraApi.handleSignOut();

View File

@ -89,7 +89,7 @@ const uploadSaveGame = async (
"Content-Type": "application/tar",
},
onUploadProgress: (progressEvent) => {
console.log(progressEvent);
logger.log(progressEvent);
},
});

View File

@ -33,6 +33,8 @@ import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-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/update-user-preferences";
import "./user-preferences/auto-launch";

View File

@ -1,8 +1,10 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { PythonInstance, logger } from "@main/services";
import { logger } from "@main/services";
import sudo from "sudo-prompt";
import { app } from "electron";
import { PythonRPC } from "@main/services/python-rpc";
import { ProcessPayload } from "@main/services/download/types";
const getKillCommand = (pid: number) => {
if (process.platform == "win32") {
@ -16,7 +18,10 @@ const closeGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const processes = await PythonInstance.getProcessList();
const processes =
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
[];
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});

View File

@ -1,11 +1,16 @@
import { registerEvent } from "../register-event";
import { PythonInstance } from "@main/services";
import { PythonRPC } from "@main/services/python-rpc";
const processProfileImage = async (
_event: Electron.IpcMainInvokeEvent,
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);

View 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);

View 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);

View File

@ -1,4 +1,4 @@
import { RealDebridClient } from "@main/services/real-debrid";
import { RealDebridClient } from "@main/services/download/real-debrid";
import { registerEvent } from "../register-event";
const authenticateRealDebrid = async (

View File

@ -5,12 +5,14 @@ import path from "node:path";
import url from "node:url";
import fs from "node:fs";
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 resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
import { knexClient, migrationConfig } from "./knex-client";
import { databaseDirectory } from "./constants";
import { PythonRPC } from "./services/python-rpc";
import { Aria2 } from "./services/aria2";
const { autoUpdater } = updater;
@ -146,7 +148,8 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => {
/* Disconnects libtorrent */
PythonInstance.kill();
PythonRPC.kill();
Aria2.kill();
});
app.on("activate", () => {

View File

@ -13,6 +13,8 @@ import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_backgroun
import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game";
import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_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 ";
export type HydraMigration = Knex.Migration & { name: string };
@ -32,6 +34,8 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
AddWinePrefixToGame,
AddStartMinimizedColumn,
AddDisableNsfwAlertColumn,
AddShouldSeedColumn,
AddSeedAfterDownloadColumn,
AddHiddenAchievementDescriptionColumn,
]);
}

View File

@ -1,21 +1,21 @@
import {
DownloadManager,
Ludusavi,
PythonInstance,
startMainLoop,
} from "./services";
import { DownloadManager, Ludusavi, startMainLoop } from "./services";
import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "./repository";
import { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/real-debrid";
import { RealDebridClient } from "./services/download/real-debrid";
import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
import { Aria2 } from "./services/aria2";
import { PythonRPC } from "./services/python-rpc";
const loadState = async (userPreferences: UserPreferences | null) => {
import("./events");
Aria2.spawn();
if (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") {
DownloadManager.startDownload(nextQueueItem.game);
DownloadManager.startRPC(nextQueueItem.game, seedList);
} else {
PythonInstance.spawn();
PythonRPC.spawn();
}
startMainLoop();

View 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");
});
},
};

View File

@ -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");
});
},
};

View 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();
}
}

View File

@ -1,39 +1,122 @@
import { Game } from "@main/entity";
import { Downloader } from "@shared";
import { PythonInstance } from "./python-instance";
import { WindowManager } from "../window-manager";
import { downloadQueueRepository, gameRepository } from "@main/repository";
import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications";
import { RealDebridDownloader } from "./real-debrid-downloader";
import type { DownloadProgress } from "@types";
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 {
private static currentDownloader: Downloader | null = null;
private static downloadingGameId: number | null = null;
public static async watchDownloads() {
let status: DownloadProgress | null = null;
public static startRPC(game: Game, initialSeeding?: Game[]) {
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) {
status = await PythonInstance.getStatus();
} else if (this.currentDownloader === Downloader.RealDebrid) {
status = await RealDebridDownloader.getStatus();
} else {
status = await GenericHttpDownloader.getStatus();
this.downloadingGameId = game.id;
}
}
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) {
const { gameId, progress } = status;
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
const userPreferences = await userPreferencesRepository.findOneBy({
id: 1,
});
if (WindowManager.mainWindow && game) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(
@ -44,12 +127,27 @@ export class DownloadManager {
)
);
}
if (progress === 1 && 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({
order: {
id: "DESC",
@ -58,25 +156,61 @@ export class DownloadManager {
game: true,
},
});
if (nextQueueItem) {
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() {
if (this.currentDownloader === Downloader.Torrent) {
await PythonInstance.pauseDownload();
} else if (this.currentDownloader === Downloader.RealDebrid) {
await RealDebridDownloader.pauseDownload();
} else {
await GenericHttpDownloader.pauseDownload();
}
await PythonRPC.rpc
.post("/action", {
action: "pause",
game_id: this.downloadingGameId,
} as PauseDownloadPayload)
.catch(() => {});
WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null;
this.downloadingGameId = null;
}
@ -85,19 +219,32 @@ export class DownloadManager {
}
static async cancelDownload(gameId = this.downloadingGameId!) {
if (this.currentDownloader === Downloader.Torrent) {
PythonInstance.cancelDownload(gameId);
} else if (this.currentDownloader === Downloader.RealDebrid) {
RealDebridDownloader.cancelDownload(gameId);
} else {
GenericHttpDownloader.cancelDownload(gameId);
}
await PythonRPC.rpc.post("/action", {
action: "cancel",
game_id: gameId,
});
WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = 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) {
switch (game.downloader) {
case Downloader.Gofile: {
@ -106,34 +253,57 @@ export class DownloadManager {
const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!);
GenericHttpDownloader.startDownload(game, downloadLink, {
Cookie: `accountToken=${token}`,
await PythonRPC.rpc.post("/action", {
action: "start",
game_id: game.id,
url: downloadLink,
save_path: game.downloadPath,
header: `Cookie: accountToken=${token}`,
});
break;
}
case Downloader.PixelDrain: {
const id = game!.uri!.split("/").pop();
await GenericHttpDownloader.startDownload(
game,
`https://pixeldrain.com/api/file/${id}?download`
);
await PythonRPC.rpc.post("/action", {
action: "start",
game_id: game.id,
url: `https://pixeldrain.com/api/file/${id}?download`,
save_path: game.downloadPath,
});
break;
}
case Downloader.Qiwi: {
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;
}
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;
case Downloader.RealDebrid:
RealDebridDownloader.startDownload(game);
case Downloader.RealDebrid: {
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;
}
}

View File

@ -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();
}
}
}

View File

@ -1,3 +1,7 @@
import path from "node:path";
import fs from "node:fs";
import { logger } from "../logger";
export const calculateETA = (
totalLength: number,
completedLength: number,
@ -11,3 +15,26 @@ export const calculateETA = (
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;
}
};

View File

@ -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);
}
);
});
}
}

View File

@ -1,2 +1 @@
export * from "./download-manager";
export * from "./python-instance";

View File

@ -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();
});
}
}

View File

@ -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);
}
}
}

View File

@ -9,7 +9,7 @@ import type {
export class RealDebridClient {
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) {
this.instance = axios.create({
@ -83,4 +83,37 @@ export class RealDebridClient {
const torrent = await RealDebridClient.addMagnet(magnetUri);
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);
}
}

View 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;
}
}

View File

@ -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;
}
};

View File

@ -1,9 +1,3 @@
export interface StartDownloadPayload {
game_id: number;
magnet: string;
save_path: string;
}
export interface PauseDownloadPayload {
game_id: number;
}
@ -25,6 +19,7 @@ export interface LibtorrentPayload {
numPeers: number;
numSeeds: number;
downloadSpeed: number;
uploadSpeed: number;
bytesDownloaded: number;
fileSize: number;
folderName: string;
@ -33,7 +28,15 @@ export interface LibtorrentPayload {
}
export interface ProcessPayload {
exe: string;
exe: string | null;
pid: number;
name: string;
}
export interface PauseSeedingPayload {
game_id: number;
}
export interface ResumeSeedingPayload {
game_id: number;
}

View File

@ -10,6 +10,7 @@ export const startMainLoop = async () => {
watchProcesses(),
DownloadManager.watchDownloads(),
AchievementWatcherManager.watchAchievements(),
DownloadManager.getSeedStatus(),
]);
await sleep(1500);

View File

@ -2,10 +2,11 @@ import { gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
import type { GameRunning } from "@types";
import { PythonInstance } from "./download";
import { PythonRPC } from "./python-rpc";
import { Game } from "@main/entity";
import axios from "axios";
import { exec } from "child_process";
import { ProcessPayload } from "./download/types";
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 ""}'`,
@ -88,12 +89,14 @@ const findGamePathByProcess = (
};
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>>();
processes.forEach((process) => {
const key = process.name.toLowerCase();
const key = process.name?.toLowerCase();
const value = process.exe;
if (!key || !value) return;

View 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;
}
}
}

View File

@ -1,7 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
readonly MAIN_VITE_API_URL: string;
readonly MAIN_VITE_ANALYTICS_API_URL: string;
readonly MAIN_VITE_AUTH_URL: string;

View File

@ -12,6 +12,7 @@ import type {
FriendRequestAction,
UpdateProfileRequest,
CatalogueSearchPayload,
SeedingStatus,
GameAchievement,
} from "@types";
import type { CatalogueCategory } from "@shared";
@ -27,6 +28,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("pauseGameDownload", gameId),
resumeGameDownload: (gameId: number) =>
ipcRenderer.invoke("resumeGameDownload", gameId),
pauseGameSeed: (gameId: number) =>
ipcRenderer.invoke("pauseGameSeed", gameId),
resumeGameSeed: (gameId: number) =>
ipcRenderer.invoke("resumeGameSeed", gameId),
onDownloadProgress: (cb: (value: DownloadProgress) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
@ -35,6 +40,19 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("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 */
searchGames: (payload: CatalogueSearchPayload, take: number, skip: number) =>

View File

@ -97,6 +97,14 @@ export function App() {
};
}, [clearDownload, setLastPacket, updateLibrary]);
useEffect(() => {
const unsubscribe = window.electron.onHardDelete(() => {
updateLibrary();
});
return () => unsubscribe();
}, [updateLibrary]);
useEffect(() => {
const cachedUserDetails = window.localStorage.getItem("userDetails");

View 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;
}
}

View 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>
);
}

View File

@ -8,6 +8,7 @@ import type {
ShopDetails,
Steam250Game,
DownloadProgress,
SeedingStatus,
UserPreferences,
StartGameDownloadPayload,
RealDebridUser,
@ -44,9 +45,15 @@ declare global {
cancelGameDownload: (gameId: number) => Promise<void>;
pauseGameDownload: (gameId: number) => Promise<void>;
resumeGameDownload: (gameId: number) => Promise<void>;
pauseGameSeed: (gameId: number) => Promise<void>;
resumeGameSeed: (gameId: number) => Promise<void>;
onDownloadProgress: (
cb: (value: DownloadProgress) => void
) => () => Electron.IpcRenderer;
onSeedingStatus: (
cb: (value: SeedingStatus[]) => void
) => () => Electron.IpcRenderer;
onHardDelete: (cb: () => void) => () => Electron.IpcRenderer;
/* Catalogue */
searchGames: (

View File

@ -66,6 +66,16 @@ export function useDownload() {
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 = () => {
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
@ -96,6 +106,8 @@ export function useDownload() {
removeGameFromLibrary,
removeGameInstaller,
isGameDeleting,
pauseSeeding,
resumeSeeding,
clearDownload: () => dispatch(clearDownload()),
setLastPacket: (packet: DownloadProgress) =>
dispatch(setLastPacket(packet)),

View File

@ -114,7 +114,7 @@ export default function Catalogue() {
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([key, value]) => ({
label: key,
value: value.toString(),
value: value,
checked: filters.tags.includes(value),
}));
}, [steamUserTags, filters.tags, language]);

View File

@ -8,10 +8,10 @@ export interface FilterSectionProps {
title: string;
items: {
label: string;
value: string;
value: string | number;
checked: boolean;
}[];
onSelect: (value: string) => void;
onSelect: (value: string | number) => void;
color: string;
onClear: () => void;
}

View File

@ -38,12 +38,13 @@ export function Pagination({
<Button
theme="outline"
onClick={() => onPageChange(page - 1)}
style={{ width: 40 }}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
disabled={page === 1}
>
<ChevronLeftIcon />
</Button>
{page > 2 && (
<div
style={{
width: 40,
@ -54,6 +55,7 @@ export function Pagination({
>
<span style={{ fontSize: 16 }}>...</span>
</div>
)}
{/* Page Buttons */}
{Array.from(
@ -63,17 +65,31 @@ export function Pagination({
<Button
theme={page === pageNumber ? "primary" : "outline"}
key={pageNumber}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
onClick={() => onPageChange(pageNumber)}
>
{pageNumber}
</Button>
))}
{page < totalPages - 1 && (
<div
style={{
width: 40,
justifyContent: "center",
display: "flex",
alignItems: "center",
}}
>
<span style={{ fontSize: 16 }}>...</span>
</div>
)}
{/* Next Button */}
<Button
theme="outline"
onClick={() => onPageChange(page + 1)}
style={{ width: 40 }}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
disabled={page === totalPages}
>
<ChevronRightIcon />

View File

@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom";
import type { LibraryGame } from "@types";
import type { LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components";
import {
@ -15,12 +15,28 @@ import { useAppSelector, useDownload } from "@renderer/hooks";
import * as styles from "./download-group.css";
import { useTranslation } from "react-i18next";
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 {
library: LibraryGame[];
title: string;
openDeleteGameModal: (gameId: number) => void;
openGameInstaller: (gameId: number) => void;
seedingStatus: SeedingStatus[];
}
export function DownloadGroup({
@ -28,6 +44,7 @@ export function DownloadGroup({
title,
openDeleteGameModal,
openGameInstaller,
seedingStatus,
}: DownloadGroupProps) {
const navigate = useNavigate();
@ -44,6 +61,8 @@ export function DownloadGroup({
resumeDownload,
cancelDownload,
isGameDeleting,
pauseSeeding,
resumeSeeding,
} = useDownload();
const getFinalDownloadSize = (game: LibraryGame) => {
@ -57,9 +76,20 @@ export function DownloadGroup({
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 isGameDownloading = lastPacket?.game.id === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
const seedingStatus = seedingMap.get(game.id);
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
@ -98,7 +128,17 @@ export function DownloadGroup({
}
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") {
@ -125,59 +165,74 @@ export function DownloadGroup({
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 deleting = isGameDeleting(game.id);
if (game.progress === 1) {
return (
<>
<Button
onClick={() => openGameInstaller(game.id)}
theme="outline"
disabled={deleting}
>
{t("install")}
</Button>
<Button onClick={() => openDeleteGameModal(game.id)} theme="outline">
{t("delete")}
</Button>
</>
);
return [
{
label: t("install"),
disabled: deleting,
onClick: () => openGameInstaller(game.id),
icon: <DownloadIcon />,
},
{
label: t("stop_seeding"),
disabled: deleting,
icon: <UnlinkIcon />,
show:
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") {
return (
<>
<Button onClick={() => pauseDownload(game.id)} theme="outline">
{t("pause")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
return [
{
label: t("pause"),
onClick: () => pauseDownload(game.id),
icon: <ColumnsIcon />,
},
{
label: t("cancel"),
onClick: () => cancelDownload(game.id),
icon: <XCircleIcon />,
},
];
}
return (
<>
<Button
onClick={() => resumeDownload(game.id)}
theme="outline"
disabled={
return [
{
label: t("resume"),
disabled:
game.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
>
{t("resume")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
!userPreferences?.realDebridApiToken,
onClick: () => resumeDownload(game.id),
icon: <PlayIcon />,
},
{
label: t("cancel"),
onClick: () => cancelDownload(game.id),
icon: <XCircleIcon />,
},
];
};
if (!library.length) return null;
@ -207,7 +262,11 @@ export function DownloadGroup({
<ul className={styles.downloads}>
{library.map((game) => {
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.downloadCoverBackdrop}>
<img
@ -243,9 +302,28 @@ export function DownloadGroup({
{getGameInfo(game)}
</div>
<div className={styles.downloadActions}>
{getGameActions(game)}
</div>
{getGameActions(game) !== null && (
<DropdownMenu
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>
</li>
);

View File

@ -2,12 +2,12 @@ import { useTranslation } from "react-i18next";
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 * as styles from "./downloads.css";
import { DeleteGameModal } from "./delete-game-modal";
import { DownloadGroup } from "./download-group";
import type { LibraryGame } from "@types";
import type { LibraryGame, SeedingStatus } from "@types";
import { orderBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react";
@ -21,15 +21,23 @@ export default function Downloads() {
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const { removeGameInstaller } = useDownload();
const { removeGameInstaller, pauseSeeding } = useDownload();
const handleDeleteGame = async () => {
if (gameToBeDeleted.current)
if (gameToBeDeleted.current) {
await pauseSeeding(gameToBeDeleted.current);
await removeGameInstaller(gameToBeDeleted.current);
}
};
const { lastPacket } = useDownload();
const [seedingStatus, setSeedingStatus] = useState<SeedingStatus[]>([]);
useEffect(() => {
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
}, []);
const handleOpenGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
@ -119,9 +127,10 @@ export default function Downloads() {
<DownloadGroup
key={group.title}
title={group.title}
library={group.library}
library={orderBy(group.library, ["updatedAt"], ["desc"])}
openDeleteGameModal={handleOpenDeleteGameModal}
openGameInstaller={handleOpenGameInstaller}
seedingStatus={seedingStatus}
/>
))}
</div>

View File

@ -179,7 +179,7 @@ export function ProfileContent() {
game.achievementCount > 0 && (
<div
style={{
color: "white",
color: "#fff",
width: "100%",
display: "flex",
flexDirection: "column",
@ -235,6 +235,8 @@ export function ProfileContent() {
borderRadius: 4,
width: "100%",
height: "100%",
minWidth: "100%",
minHeight: "100%",
}}
/>
</button>

View File

@ -19,6 +19,7 @@ export function SettingsBehavior() {
runAtStartup: false,
startMinimized: false,
disableNsfwAlert: false,
seedAfterDownloadComplete: false,
showHiddenAchievementsDescription: false,
});
@ -31,6 +32,7 @@ export function SettingsBehavior() {
runAtStartup: userPreferences.runAtStartup,
startMinimized: userPreferences.startMinimized,
disableNsfwAlert: userPreferences.disableNsfwAlert,
seedAfterDownloadComplete: userPreferences.seedAfterDownloadComplete,
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
label={t("show_hidden_achievement_description")}
checked={form.showHiddenAchievementsDescription}

View File

@ -3,12 +3,3 @@ export interface HowLongToBeatCategory {
duration: string;
accuracy: string;
}
export interface HowLongToBeatResult {
game_id: number;
game_name: string;
}
export interface HowLongToBeatSearchResponse {
data: HowLongToBeatResult[];
}

View File

@ -7,6 +7,7 @@ export type GameStatus =
| "paused"
| "error"
| "complete"
| "seeding"
| "removed";
export type GameShop = "steam" | "epic";
@ -112,6 +113,7 @@ export interface Game {
objectID: string;
shop: GameShop;
downloadQueue: DownloadQueue | null;
shouldSeed: boolean;
createdAt: Date;
updatedAt: Date;
}
@ -139,6 +141,12 @@ export interface DownloadProgress {
game: LibraryGame;
}
export interface SeedingStatus {
gameId: number;
status: GameStatus;
uploadSpeed: number;
}
export interface UserPreferences {
downloadsPath: string | null;
language: string;
@ -150,6 +158,7 @@ export interface UserPreferences {
runAtStartup: boolean;
startMinimized: boolean;
disableNsfwAlert: boolean;
seedAfterDownloadComplete: boolean;
showHiddenAchievementsDescription: boolean;
}
@ -384,3 +393,4 @@ export * from "./steam.types";
export * from "./real-debrid.types";
export * from "./ludusavi.types";
export * from "./how-long-to-beat.types";
export * from "./torbox.types";

77
src/types/torbox.types.ts Normal file
View 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;
}

View File

@ -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
View File

@ -1641,6 +1641,33 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
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":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@fontsource/noto-sans/-/noto-sans-5.1.0.tgz#54a5edd1b2b8c8e17bec6a85d4ee3a53b4b89c1f"
@ -1910,6 +1937,221 @@
resolved "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.9.0.tgz"
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":
version "2.2.5"
resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz"
@ -3373,6 +3615,13 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
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:
version "5.3.2"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59"
@ -4319,6 +4568,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"
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:
version "2.1.0"
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
@ -5297,6 +5551,11 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@
has-symbols "^1.0.3"
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:
version "1.2.6"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.6.tgz#43dd3dd0e7b49b82b2dfcad10dc824bf7fc265d5"
@ -5770,6 +6029,13 @@ interpret@^2.2.0:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
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:
version "9.0.5"
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a"
@ -6462,7 +6728,7 @@ log-symbols@^4.1.0:
chalk "^4.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"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@ -7394,6 +7660,25 @@ react-refresh@^0.14.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
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:
version "6.26.2"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.26.2.tgz#a6e3b0cbd6bfd508e42b9342099d015a0ac59680"
@ -7409,6 +7694,15 @@ react-router@6.26.2:
dependencies:
"@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:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
@ -8451,16 +8745,16 @@ ts-node@^10.9.2:
v8-compile-cache-lib "^3.0.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:
version "2.7.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01"
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:
version "0.6.0"
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
@ -8654,6 +8948,21 @@ url-parse@^1.5.3:
querystringify "^2.1.1"
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:
version "1.2.2"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9"