mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Merge branch 'feat/reviews-and-commenting' of https://github.com/hydralauncher/hydra into feat/reviews-and-commenting
This commit is contained in:
151
python_rpc/http_multi_link_downloader.py
Normal file
151
python_rpc/http_multi_link_downloader.py
Normal file
@@ -0,0 +1,151 @@
|
||||
import aria2p
|
||||
from aria2p.client import ClientException as DownloadNotFound
|
||||
|
||||
class HttpMultiLinkDownloader:
|
||||
def __init__(self):
|
||||
self.downloads = []
|
||||
self.completed_downloads = []
|
||||
self.total_size = None
|
||||
self.aria2 = aria2p.API(
|
||||
aria2p.Client(
|
||||
host="http://localhost",
|
||||
port=6800,
|
||||
secret=""
|
||||
)
|
||||
)
|
||||
|
||||
def start_download(self, urls: list[str], save_path: str, header: str = None, out: str = None, total_size: int = None):
|
||||
"""Add multiple URLs to download queue with same options"""
|
||||
options = {"dir": save_path}
|
||||
if header:
|
||||
options["header"] = header
|
||||
if out:
|
||||
options["out"] = out
|
||||
|
||||
# Clear any existing downloads first
|
||||
self.cancel_download()
|
||||
self.completed_downloads = []
|
||||
self.total_size = total_size
|
||||
|
||||
for url in urls:
|
||||
try:
|
||||
added_downloads = self.aria2.add(url, options=options)
|
||||
self.downloads.extend(added_downloads)
|
||||
except Exception as e:
|
||||
print(f"Error adding download for URL {url}: {str(e)}")
|
||||
|
||||
def pause_download(self):
|
||||
"""Pause all active downloads"""
|
||||
if self.downloads:
|
||||
try:
|
||||
self.aria2.pause(self.downloads)
|
||||
except Exception as e:
|
||||
print(f"Error pausing downloads: {str(e)}")
|
||||
|
||||
def cancel_download(self):
|
||||
"""Cancel and remove all downloads"""
|
||||
if self.downloads:
|
||||
try:
|
||||
# First try to stop the downloads
|
||||
self.aria2.remove(self.downloads)
|
||||
except Exception as e:
|
||||
print(f"Error removing downloads: {str(e)}")
|
||||
finally:
|
||||
# Clear the downloads list regardless of success/failure
|
||||
self.downloads = []
|
||||
self.completed_downloads = []
|
||||
|
||||
def get_download_status(self):
|
||||
"""Get status for all tracked downloads, auto-remove completed/failed ones"""
|
||||
if not self.downloads and not self.completed_downloads:
|
||||
return []
|
||||
|
||||
total_completed = 0
|
||||
current_download_speed = 0
|
||||
active_downloads = []
|
||||
to_remove = []
|
||||
|
||||
# First calculate sizes from completed downloads
|
||||
for completed in self.completed_downloads:
|
||||
total_completed += completed['size']
|
||||
|
||||
# Then check active downloads
|
||||
for download in self.downloads:
|
||||
try:
|
||||
current_download = self.aria2.get_download(download.gid)
|
||||
|
||||
# Skip downloads that are not properly initialized
|
||||
if not current_download or not current_download.files:
|
||||
to_remove.append(download)
|
||||
continue
|
||||
|
||||
# Add to completed size and speed calculations
|
||||
total_completed += current_download.completed_length
|
||||
current_download_speed += current_download.download_speed
|
||||
|
||||
# If download is complete, move it to completed_downloads
|
||||
if current_download.status == 'complete':
|
||||
self.completed_downloads.append({
|
||||
'name': current_download.name,
|
||||
'size': current_download.total_length
|
||||
})
|
||||
to_remove.append(download)
|
||||
else:
|
||||
active_downloads.append({
|
||||
'name': current_download.name,
|
||||
'size': current_download.total_length,
|
||||
'completed': current_download.completed_length,
|
||||
'speed': current_download.download_speed
|
||||
})
|
||||
|
||||
except DownloadNotFound:
|
||||
to_remove.append(download)
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Error getting download status: {str(e)}")
|
||||
continue
|
||||
|
||||
# Clean up completed/removed downloads from active list
|
||||
for download in to_remove:
|
||||
try:
|
||||
if download in self.downloads:
|
||||
self.downloads.remove(download)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Return aggregate status
|
||||
if self.total_size or active_downloads or self.completed_downloads:
|
||||
# Use the first active download's name as the folder name, or completed if none active
|
||||
folder_name = None
|
||||
if active_downloads:
|
||||
folder_name = active_downloads[0]['name']
|
||||
elif self.completed_downloads:
|
||||
folder_name = self.completed_downloads[0]['name']
|
||||
|
||||
if folder_name and '/' in folder_name:
|
||||
folder_name = folder_name.split('/')[0]
|
||||
|
||||
# Use provided total size if available, otherwise sum from downloads
|
||||
total_size = self.total_size
|
||||
if not total_size:
|
||||
total_size = sum(d['size'] for d in active_downloads) + sum(d['size'] for d in self.completed_downloads)
|
||||
|
||||
# Calculate completion status based on total downloaded vs total size
|
||||
is_complete = len(active_downloads) == 0 and total_completed >= (total_size * 0.99) # Allow 1% margin for size differences
|
||||
|
||||
# If all downloads are complete, clear the completed_downloads list to prevent status updates
|
||||
if is_complete:
|
||||
self.completed_downloads = []
|
||||
|
||||
return [{
|
||||
'folderName': folder_name,
|
||||
'fileSize': total_size,
|
||||
'progress': total_completed / total_size if total_size > 0 else 0,
|
||||
'downloadSpeed': current_download_speed,
|
||||
'numPeers': 0,
|
||||
'numSeeds': 0,
|
||||
'status': 'complete' if is_complete else 'active',
|
||||
'bytesDownloaded': total_completed,
|
||||
}]
|
||||
|
||||
return []
|
||||
@@ -3,6 +3,7 @@ import sys, json, urllib.parse, psutil
|
||||
from torrent_downloader import TorrentDownloader
|
||||
from http_downloader import HttpDownloader
|
||||
from profile_image_processor import ProfileImageProcessor
|
||||
from http_multi_link_downloader import HttpMultiLinkDownloader
|
||||
import libtorrent as lt
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -24,7 +25,15 @@ 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'):
|
||||
if isinstance(initial_download['url'], list):
|
||||
# Handle multiple URLs using HttpMultiLinkDownloader
|
||||
http_multi_downloader = HttpMultiLinkDownloader()
|
||||
downloads[initial_download['game_id']] = http_multi_downloader
|
||||
try:
|
||||
http_multi_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out"))
|
||||
except Exception as e:
|
||||
print("Error starting multi-link download", e)
|
||||
elif initial_download['url'].startswith('magnet'):
|
||||
torrent_downloader = TorrentDownloader(torrent_session)
|
||||
downloads[initial_download['game_id']] = torrent_downloader
|
||||
try:
|
||||
@@ -62,12 +71,23 @@ def status():
|
||||
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:
|
||||
if not downloader:
|
||||
return jsonify(None)
|
||||
|
||||
status = downloader.get_download_status()
|
||||
if not status:
|
||||
return jsonify(None)
|
||||
|
||||
if isinstance(status, list):
|
||||
if not status: # Empty list
|
||||
return jsonify(None)
|
||||
|
||||
# For multi-link downloader, use the aggregated status
|
||||
# The status will already be aggregated by the HttpMultiLinkDownloader
|
||||
return jsonify(status[0]), 200
|
||||
|
||||
return jsonify(status), 200
|
||||
|
||||
@app.route("/seed-status", methods=["GET"])
|
||||
def seed_status():
|
||||
auth_error = validate_rpc_password()
|
||||
@@ -81,10 +101,24 @@ def seed_status():
|
||||
continue
|
||||
|
||||
response = downloader.get_download_status()
|
||||
if response is None:
|
||||
if not response:
|
||||
continue
|
||||
|
||||
if response.get('status') == 5:
|
||||
if isinstance(response, list):
|
||||
# For multi-link downloader, check if all files are complete
|
||||
if response and all(item['status'] == 'complete' for item in response):
|
||||
seed_status.append({
|
||||
'gameId': game_id,
|
||||
'status': 'complete',
|
||||
'folderName': response[0]['folderName'],
|
||||
'fileSize': sum(item['fileSize'] for item in response),
|
||||
'bytesDownloaded': sum(item['bytesDownloaded'] for item in response),
|
||||
'downloadSpeed': 0,
|
||||
'numPeers': 0,
|
||||
'numSeeds': 0,
|
||||
'progress': 1.0
|
||||
})
|
||||
elif response.get('status') == 5: # Original torrent seeding check
|
||||
seed_status.append({
|
||||
'gameId': game_id,
|
||||
**response,
|
||||
@@ -143,7 +177,15 @@ def action():
|
||||
|
||||
existing_downloader = downloads.get(game_id)
|
||||
|
||||
if url.startswith('magnet'):
|
||||
if isinstance(url, list):
|
||||
# Handle multiple URLs using HttpMultiLinkDownloader
|
||||
if existing_downloader and isinstance(existing_downloader, HttpMultiLinkDownloader):
|
||||
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
||||
else:
|
||||
http_multi_downloader = HttpMultiLinkDownloader()
|
||||
downloads[game_id] = http_multi_downloader
|
||||
http_multi_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
||||
elif url.startswith('magnet'):
|
||||
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
|
||||
existing_downloader.start_download(url, data['save_path'])
|
||||
else:
|
||||
|
||||
@@ -166,11 +166,13 @@
|
||||
"open_folder": "Open folder",
|
||||
"open_download_location": "See downloaded files",
|
||||
"create_shortcut": "Create desktop shortcut",
|
||||
"create_shortcut_simple": "Create shortcut",
|
||||
"clear": "Clear",
|
||||
"remove_files": "Remove files",
|
||||
"remove_from_library_title": "Are you sure?",
|
||||
"remove_from_library_description": "This will remove {{game}} from your library",
|
||||
"options": "Options",
|
||||
"properties": "Properties",
|
||||
"executable_section_title": "Executable",
|
||||
"executable_section_description": "Path of the file that will be executed when \"Play\" is clicked",
|
||||
"downloads_section_title": "Downloads",
|
||||
@@ -184,6 +186,13 @@
|
||||
"create_shortcut_success": "Shortcut created successfully",
|
||||
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
|
||||
"create_shortcut_error": "Error creating shortcut",
|
||||
"add_to_favorites": "Add to favorites",
|
||||
"remove_from_favorites": "Remove from favorites",
|
||||
"failed_update_favorites": "Failed to update favorites",
|
||||
"game_removed_from_library": "Game removed from library",
|
||||
"failed_remove_from_library": "Failed to remove from library",
|
||||
"files_removed_success": "Files removed successfully",
|
||||
"failed_remove_files": "Failed to remove files",
|
||||
"nsfw_content_title": "This game contains inappropriate content",
|
||||
"nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?",
|
||||
"allow_nsfw_content": "Continue",
|
||||
@@ -360,6 +369,7 @@
|
||||
"stop_seeding": "Stop seeding",
|
||||
"resume_seeding": "Resume seeding",
|
||||
"options": "Manage",
|
||||
"alldebrid_size_not_supported": "Download info for AllDebrid is not supported yet",
|
||||
"extract": "Extract files",
|
||||
"extracting": "Extracting files…"
|
||||
},
|
||||
@@ -469,6 +479,17 @@
|
||||
"create_real_debrid_account": "Click here if you don't have a Real-Debrid account yet",
|
||||
"create_torbox_account": "Click here if you don't have a TorBox account yet",
|
||||
"real_debrid_account_linked": "Real-Debrid account linked",
|
||||
"enable_all_debrid": "Enable All-Debrid",
|
||||
"all_debrid_description": "All-Debrid is an unrestricted downloader that allows you to quickly download files from various sources.",
|
||||
"all_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to All-Debrid",
|
||||
"all_debrid_account_linked": "All-Debrid account linked successfully",
|
||||
"alldebrid_missing_key": "Please provide an API key",
|
||||
"alldebrid_invalid_key": "Invalid API key",
|
||||
"alldebrid_blocked": "Your API key is geo-blocked or IP-blocked",
|
||||
"alldebrid_banned": "This account has been banned",
|
||||
"alldebrid_unknown_error": "An unknown error occurred",
|
||||
"alldebrid_invalid_response": "Invalid response from All-Debrid",
|
||||
"alldebrid_network_error": "Network error. Please check your connection",
|
||||
"name_min_length": "Theme name must be at least 3 characters long",
|
||||
"import_theme": "Import theme",
|
||||
"import_theme_description": "You will import {{theme}} from the theme store",
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
"open_download_location": "Lihat file yang diunduh",
|
||||
"create_shortcut": "Buat pintasan desktop",
|
||||
"remove_files": "Hapus file",
|
||||
"remove_from_library_title": "Apa kamu yakin?",
|
||||
"remove_from_library_description": "Ini akan menghapus {{game}} dari perpustakaan kamu",
|
||||
"options": "Opsi",
|
||||
"executable_section_title": "Eksekusi",
|
||||
|
||||
@@ -120,8 +120,10 @@
|
||||
"open_folder": "Abrir pasta",
|
||||
"open_download_location": "Ver arquivos baixados",
|
||||
"create_shortcut": "Criar atalho na área de trabalho",
|
||||
"create_shortcut_simple": "Criar atalho",
|
||||
"remove_files": "Remover arquivos",
|
||||
"options": "Gerenciar",
|
||||
"properties": "Propriedades",
|
||||
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
|
||||
"remove_from_library_title": "Tem certeza?",
|
||||
"executable_section_title": "Executável",
|
||||
@@ -204,6 +206,13 @@
|
||||
"download_error_not_cached_on_hydra": "Este download não está disponível no Nimbus.",
|
||||
"game_removed_from_favorites": "Jogo removido dos favoritos",
|
||||
"game_added_to_favorites": "Jogo adicionado aos favoritos",
|
||||
"add_to_favorites": "Adicionar aos favoritos",
|
||||
"remove_from_favorites": "Remover dos favoritos",
|
||||
"failed_update_favorites": "Falha ao atualizar favoritos",
|
||||
"game_removed_from_library": "Jogo removido da biblioteca",
|
||||
"failed_remove_from_library": "Falha ao remover da biblioteca",
|
||||
"files_removed_success": "Arquivos removidos com sucesso",
|
||||
"failed_remove_files": "Falha ao remover arquivos",
|
||||
"automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados",
|
||||
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
|
||||
"invalid_wine_prefix_path": "Caminho do prefixo Wine inválido",
|
||||
|
||||
@@ -135,7 +135,11 @@
|
||||
"real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid",
|
||||
"debrid_linked_message": "Contul \"{{username}}\" a fost legat",
|
||||
"save_changes": "Salvează modificările",
|
||||
"changes_saved": "Modificările au fost salvate cu succes"
|
||||
"changes_saved": "Modificările au fost salvate cu succes",
|
||||
"enable_all_debrid": "Activează All-Debrid",
|
||||
"all_debrid_description": "All-Debrid este un descărcător fără restricții care îți permite să descarci fișiere din diverse surse.",
|
||||
"all_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la All-Debrid",
|
||||
"all_debrid_account_linked": "Contul All-Debrid a fost conectat cu succes"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Descărcare completă",
|
||||
|
||||
@@ -71,6 +71,7 @@ import "./user-preferences/auto-launch";
|
||||
import "./autoupdater/check-for-updates";
|
||||
import "./autoupdater/restart-and-install-update";
|
||||
import "./user-preferences/authenticate-real-debrid";
|
||||
import "./user-preferences/authenticate-all-debrid";
|
||||
import "./user-preferences/authenticate-torbox";
|
||||
import "./download-sources/put-download-source";
|
||||
import "./auth/sign-out";
|
||||
|
||||
17
src/main/events/user-preferences/authenticate-all-debrid.ts
Normal file
17
src/main/events/user-preferences/authenticate-all-debrid.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AllDebridClient } from "@main/services/download/all-debrid";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const authenticateAllDebrid = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
apiKey: string
|
||||
) => {
|
||||
AllDebridClient.authorize(apiKey);
|
||||
const result = await AllDebridClient.getUser();
|
||||
if ("error_code" in result) {
|
||||
return { error_code: result.error_code };
|
||||
}
|
||||
|
||||
return result.user;
|
||||
};
|
||||
|
||||
registerEvent("authenticateAllDebrid", authenticateAllDebrid);
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CommonRedistManager,
|
||||
TorBoxClient,
|
||||
RealDebridClient,
|
||||
AllDebridClient,
|
||||
Aria2,
|
||||
DownloadManager,
|
||||
HydraApi,
|
||||
@@ -37,6 +38,10 @@ export const loadState = async () => {
|
||||
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
||||
}
|
||||
|
||||
if (userPreferences?.allDebridApiKey) {
|
||||
AllDebridClient.authorize(userPreferences.allDebridApiKey);
|
||||
}
|
||||
|
||||
if (userPreferences?.torBoxApiToken) {
|
||||
TorBoxClient.authorize(userPreferences.torBoxApiToken);
|
||||
}
|
||||
|
||||
315
src/main/services/download/all-debrid.ts
Normal file
315
src/main/services/download/all-debrid.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import type { AllDebridUser } from "@types";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
interface AllDebridMagnetStatus {
|
||||
id: number;
|
||||
filename: string;
|
||||
size: number;
|
||||
status: string;
|
||||
statusCode: number;
|
||||
downloaded: number;
|
||||
uploaded: number;
|
||||
seeders: number;
|
||||
downloadSpeed: number;
|
||||
uploadSpeed: number;
|
||||
uploadDate: number;
|
||||
completionDate: number;
|
||||
links: Array<{
|
||||
link: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface AllDebridError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AllDebridDownloadUrl {
|
||||
link: string;
|
||||
size?: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export class AllDebridClient {
|
||||
private static instance: AxiosInstance;
|
||||
private static readonly baseURL = "https://api.alldebrid.com/v4";
|
||||
|
||||
static authorize(apiKey: string) {
|
||||
logger.info("[AllDebrid] Authorizing with key:", apiKey ? "***" : "empty");
|
||||
this.instance = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
params: {
|
||||
agent: "hydra",
|
||||
apikey: apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async getUser() {
|
||||
try {
|
||||
const response = await this.instance.get<{
|
||||
status: string;
|
||||
data?: { user: AllDebridUser };
|
||||
error?: AllDebridError;
|
||||
}>("/user");
|
||||
|
||||
logger.info("[AllDebrid] API Response:", response.data);
|
||||
|
||||
if (response.data.status === "error") {
|
||||
const error = response.data.error;
|
||||
logger.error("[AllDebrid] API Error:", error);
|
||||
if (error?.code === "AUTH_MISSING_APIKEY") {
|
||||
return { error_code: "alldebrid_missing_key" };
|
||||
}
|
||||
if (error?.code === "AUTH_BAD_APIKEY") {
|
||||
return { error_code: "alldebrid_invalid_key" };
|
||||
}
|
||||
if (error?.code === "AUTH_BLOCKED") {
|
||||
return { error_code: "alldebrid_blocked" };
|
||||
}
|
||||
if (error?.code === "AUTH_USER_BANNED") {
|
||||
return { error_code: "alldebrid_banned" };
|
||||
}
|
||||
return { error_code: "alldebrid_unknown_error" };
|
||||
}
|
||||
|
||||
if (!response.data.data?.user) {
|
||||
logger.error("[AllDebrid] No user data in response");
|
||||
return { error_code: "alldebrid_invalid_response" };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Successfully got user:",
|
||||
response.data.data.user.username
|
||||
);
|
||||
return { user: response.data.data.user };
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Request Error:", error);
|
||||
if (error.response?.data?.error) {
|
||||
return { error_code: "alldebrid_invalid_key" };
|
||||
}
|
||||
return { error_code: "alldebrid_network_error" };
|
||||
}
|
||||
}
|
||||
|
||||
private static async uploadMagnet(magnet: string) {
|
||||
try {
|
||||
logger.info("[AllDebrid] Uploading magnet with params:", { magnet });
|
||||
|
||||
const response = await this.instance.get("/magnet/upload", {
|
||||
params: {
|
||||
magnets: [magnet],
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Upload Magnet Raw Response:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
const magnetInfo = response.data.data.magnets[0];
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet Info:",
|
||||
JSON.stringify(magnetInfo, null, 2)
|
||||
);
|
||||
|
||||
if (magnetInfo.error) {
|
||||
throw new Error(magnetInfo.error.message);
|
||||
}
|
||||
|
||||
return magnetInfo.id;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Upload Magnet Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async checkMagnetStatus(
|
||||
magnetId: number
|
||||
): Promise<AllDebridMagnetStatus> {
|
||||
try {
|
||||
logger.info("[AllDebrid] Checking magnet status for ID:", magnetId);
|
||||
|
||||
const response = await this.instance.get(`/magnet/status`, {
|
||||
params: {
|
||||
id: magnetId,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Check Magnet Status Raw Response:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("No response data received");
|
||||
}
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
// Verificăm noua structură a răspunsului
|
||||
const magnetData = response.data.data?.magnets;
|
||||
if (!magnetData || typeof magnetData !== "object") {
|
||||
logger.error(
|
||||
"[AllDebrid] Invalid response structure:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
throw new Error("Invalid magnet status response format");
|
||||
}
|
||||
|
||||
// Convertim răspunsul în formatul așteptat
|
||||
const magnetStatus: AllDebridMagnetStatus = {
|
||||
id: magnetData.id,
|
||||
filename: magnetData.filename,
|
||||
size: magnetData.size,
|
||||
status: magnetData.status,
|
||||
statusCode: magnetData.statusCode,
|
||||
downloaded: magnetData.downloaded,
|
||||
uploaded: magnetData.uploaded,
|
||||
seeders: magnetData.seeders,
|
||||
downloadSpeed: magnetData.downloadSpeed,
|
||||
uploadSpeed: magnetData.uploadSpeed,
|
||||
uploadDate: magnetData.uploadDate,
|
||||
completionDate: magnetData.completionDate,
|
||||
links: magnetData.links.map((link) => ({
|
||||
link: link.link,
|
||||
filename: link.filename,
|
||||
size: link.size,
|
||||
})),
|
||||
};
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet Status:",
|
||||
JSON.stringify(magnetStatus, null, 2)
|
||||
);
|
||||
|
||||
return magnetStatus;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Check Magnet Status Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async unlockLink(link: string) {
|
||||
try {
|
||||
const response = await this.instance.get<{
|
||||
status: string;
|
||||
data?: { link: string };
|
||||
error?: AllDebridError;
|
||||
}>("/link/unlock", {
|
||||
params: {
|
||||
link,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
const unlockedLink = response.data.data?.link;
|
||||
if (!unlockedLink) {
|
||||
throw new Error("No download link received from AllDebrid");
|
||||
}
|
||||
|
||||
return unlockedLink;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Unlock Link Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getDownloadUrls(
|
||||
uri: string
|
||||
): Promise<AllDebridDownloadUrl[]> {
|
||||
try {
|
||||
logger.info("[AllDebrid] Getting download URLs for URI:", uri);
|
||||
|
||||
if (uri.startsWith("magnet:")) {
|
||||
logger.info("[AllDebrid] Detected magnet link, uploading...");
|
||||
// 1. Upload magnet
|
||||
const magnetId = await this.uploadMagnet(uri);
|
||||
logger.info("[AllDebrid] Magnet uploaded, ID:", magnetId);
|
||||
|
||||
// 2. Verificăm statusul până când avem link-uri
|
||||
let retries = 0;
|
||||
let magnetStatus: AllDebridMagnetStatus;
|
||||
|
||||
do {
|
||||
magnetStatus = await this.checkMagnetStatus(magnetId);
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet status:",
|
||||
magnetStatus.status,
|
||||
"statusCode:",
|
||||
magnetStatus.statusCode
|
||||
);
|
||||
|
||||
if (magnetStatus.statusCode === 4) {
|
||||
// Ready
|
||||
// Deblocăm fiecare link în parte și aruncăm eroare dacă oricare eșuează
|
||||
const unlockedLinks = await Promise.all(
|
||||
magnetStatus.links.map(async (link) => {
|
||||
try {
|
||||
const unlockedLink = await this.unlockLink(link.link);
|
||||
logger.info(
|
||||
"[AllDebrid] Successfully unlocked link:",
|
||||
unlockedLink
|
||||
);
|
||||
|
||||
return {
|
||||
link: unlockedLink,
|
||||
size: link.size,
|
||||
filename: link.filename,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[AllDebrid] Failed to unlock link:",
|
||||
link.link,
|
||||
error
|
||||
);
|
||||
throw new Error("Failed to unlock all links");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Got unlocked download links:",
|
||||
unlockedLinks
|
||||
);
|
||||
console.log("[AllDebrid] FINAL LINKS →", unlockedLinks);
|
||||
return unlockedLinks;
|
||||
}
|
||||
|
||||
if (retries++ > 30) {
|
||||
// Maximum 30 de încercări
|
||||
throw new Error("Timeout waiting for magnet to be ready");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // Așteptăm 2 secunde între verificări
|
||||
} while (magnetStatus.statusCode !== 4);
|
||||
} else {
|
||||
logger.info("[AllDebrid] Regular link, unlocking...");
|
||||
// Pentru link-uri normale, doar debridam link-ul
|
||||
const downloadUrl = await this.unlockLink(uri);
|
||||
logger.info("[AllDebrid] Got unlocked download URL:", downloadUrl);
|
||||
return [
|
||||
{
|
||||
link: downloadUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Get Download URLs Error:", error);
|
||||
throw error;
|
||||
}
|
||||
return []; // Add default return for TypeScript
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./types";
|
||||
import { calculateETA, getDirSize } from "./helpers";
|
||||
import { RealDebridClient } from "./real-debrid";
|
||||
import { AllDebridClient } from "./all-debrid";
|
||||
import path from "path";
|
||||
import { logger } from "../logger";
|
||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
@@ -40,6 +41,7 @@ export class DownloadManager {
|
||||
})
|
||||
: undefined,
|
||||
downloadsToSeed?.map((download) => ({
|
||||
action: "seed",
|
||||
game_id: levelKeys.game(download.shop, download.objectId),
|
||||
url: download.uri,
|
||||
save_path: download.downloadPath,
|
||||
@@ -377,6 +379,27 @@ export class DownloadManager {
|
||||
allow_multiple_connections: true,
|
||||
};
|
||||
}
|
||||
case Downloader.AllDebrid: {
|
||||
const downloadUrls = await AllDebridClient.getDownloadUrls(
|
||||
download.uri
|
||||
);
|
||||
|
||||
if (!downloadUrls.length)
|
||||
throw new Error(DownloadError.NotCachedInAllDebrid);
|
||||
|
||||
const totalSize = downloadUrls.reduce(
|
||||
(total, url) => total + (url.size || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
url: downloadUrls.map((d) => d.link),
|
||||
save_path: download.downloadPath,
|
||||
total_size: totalSize,
|
||||
};
|
||||
}
|
||||
case Downloader.TorBox: {
|
||||
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
||||
|
||||
|
||||
@@ -17,17 +17,24 @@ export const calculateETA = (
|
||||
};
|
||||
|
||||
export const getDirSize = async (dir: string): Promise<number> => {
|
||||
const getItemSize = async (filePath: string): Promise<number> => {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
try {
|
||||
const stat = await fs.promises.stat(dir);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return getDirSize(filePath);
|
||||
// If it's a file, return its size directly
|
||||
if (!stat.isDirectory()) {
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
return stat.size;
|
||||
};
|
||||
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));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./download-manager";
|
||||
export * from "./real-debrid";
|
||||
export * from "./all-debrid";
|
||||
export * from "./torbox";
|
||||
|
||||
@@ -11,9 +11,13 @@ import { app, dialog } from "electron";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
|
||||
interface GamePayload {
|
||||
action: string;
|
||||
game_id: string;
|
||||
url: string;
|
||||
url: string | string[];
|
||||
save_path: string;
|
||||
header?: string;
|
||||
out?: string;
|
||||
total_size?: number;
|
||||
}
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
|
||||
@@ -126,6 +126,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("autoLaunch", autoLaunchProps),
|
||||
authenticateRealDebrid: (apiToken: string) =>
|
||||
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
|
||||
authenticateAllDebrid: (apiKey: string) =>
|
||||
ipcRenderer.invoke("authenticateAllDebrid", apiKey),
|
||||
authenticateTorBox: (apiToken: string) =>
|
||||
ipcRenderer.invoke("authenticateTorBox", apiToken),
|
||||
|
||||
|
||||
11
src/renderer/src/components/confirm-modal/confirm-modal.scss
Normal file
11
src/renderer/src/components/confirm-modal/confirm-modal.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.confirm-modal {
|
||||
&__actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
}
|
||||
48
src/renderer/src/components/confirm-modal/confirm-modal.tsx
Normal file
48
src/renderer/src/components/confirm-modal/confirm-modal.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import "./confirm-modal.scss";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmTheme?: "primary" | "outline" | "danger";
|
||||
confirmDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
visible,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
confirmTheme = "outline",
|
||||
confirmDisabled = false,
|
||||
}: ConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title={title} description={description} onClose={onClose}>
|
||||
<div className="confirm-modal__actions">
|
||||
<Button onClick={handleConfirm} theme={confirmTheme} disabled={confirmDisabled}>
|
||||
{confirmLabel || t("confirm")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{cancelLabel || t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
159
src/renderer/src/components/context-menu/context-menu.scss
Normal file
159
src/renderer/src/components/context-menu/context-menu.scss
Normal file
@@ -0,0 +1,159 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background-color: globals.$background-color;
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__item-container {
|
||||
position: relative;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
&__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: globals.$body-color;
|
||||
cursor: pointer;
|
||||
font-size: globals.$body-font-size;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(&--disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active:not(&--disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
color: globals.$muted-color;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: globals.$danger-color;
|
||||
|
||||
&:hover:not(.context-menu__item--disabled) {
|
||||
background-color: rgba(128, 29, 30, 0.1);
|
||||
}
|
||||
|
||||
.context-menu__item-icon {
|
||||
color: globals.$danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
&--has-submenu {
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__item-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__item-arrow {
|
||||
font-size: 10px;
|
||||
color: globals.$muted-color;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&__submenu {
|
||||
position: absolute;
|
||||
left: calc(100% - 2px);
|
||||
top: 0;
|
||||
background-color: globals.$background-color;
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 0;
|
||||
min-width: 160px;
|
||||
backdrop-filter: blur(8px);
|
||||
margin-left: 0;
|
||||
z-index: 1200;
|
||||
|
||||
pointer-events: auto;
|
||||
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__content {
|
||||
border-top: 1px solid globals.$border-color;
|
||||
padding: 8px 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__item + &__item {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
&__item--danger:first-of-type {
|
||||
border-top: 1px solid globals.$border-color;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&--game-not-installed &__submenu &__item--danger:first-of-type {
|
||||
border-top: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&__separator {
|
||||
height: 1px;
|
||||
background: globals.$border-color;
|
||||
margin: 6px 8px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
animation: contextMenuFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes contextMenuFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
254
src/renderer/src/components/context-menu/context-menu.tsx
Normal file
254
src/renderer/src/components/context-menu/context-menu.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import cn from "classnames";
|
||||
import "./context-menu.scss";
|
||||
|
||||
export interface ContextMenuItemData {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
separator?: boolean;
|
||||
submenu?: ContextMenuItemData[];
|
||||
}
|
||||
|
||||
export interface ContextMenuProps {
|
||||
items: ContextMenuItemData[];
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
items,
|
||||
visible,
|
||||
position,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
}: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
const submenuCloseTimeout = useRef<number | null>(null);
|
||||
const itemRefs = useRef<Record<string, HTMLLIElement | null>>({});
|
||||
const [submenuStyles, setSubmenuStyles] = useState<
|
||||
Record<string, React.CSSProperties>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [visible, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !menuRef.current) return;
|
||||
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let adjustedX = position.x;
|
||||
let adjustedY = position.y;
|
||||
|
||||
if (position.x + rect.width > viewportWidth) {
|
||||
adjustedX = viewportWidth - rect.width - 10;
|
||||
}
|
||||
|
||||
if (position.y + rect.height > viewportHeight) {
|
||||
adjustedY = viewportHeight - rect.height - 10;
|
||||
}
|
||||
|
||||
setAdjustedPosition({ x: adjustedX, y: adjustedY });
|
||||
}, [visible, position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setActiveSubmenu(null);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleItemClick = (item: ContextMenuItemData) => {
|
||||
if (item.disabled) return;
|
||||
|
||||
if (item.submenu) {
|
||||
setActiveSubmenu(activeSubmenu === item.id ? null : item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmenuMouseEnter = (itemId: string) => {
|
||||
if (submenuCloseTimeout.current) {
|
||||
window.clearTimeout(submenuCloseTimeout.current);
|
||||
submenuCloseTimeout.current = null;
|
||||
}
|
||||
setActiveSubmenu(itemId);
|
||||
};
|
||||
|
||||
const handleSubmenuMouseLeave = () => {
|
||||
if (submenuCloseTimeout.current) {
|
||||
window.clearTimeout(submenuCloseTimeout.current);
|
||||
}
|
||||
submenuCloseTimeout.current = window.setTimeout(() => {
|
||||
setActiveSubmenu(null);
|
||||
submenuCloseTimeout.current = null;
|
||||
}, 120);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSubmenu) return;
|
||||
|
||||
const parentEl = itemRefs.current[activeSubmenu];
|
||||
if (!parentEl) return;
|
||||
|
||||
const submenuEl = parentEl.querySelector(
|
||||
".context-menu__submenu"
|
||||
) as HTMLElement | null;
|
||||
if (!submenuEl) return;
|
||||
|
||||
const parentRect = parentEl.getBoundingClientRect();
|
||||
const submenuWidth = submenuEl.offsetWidth;
|
||||
const submenuHeight = submenuEl.offsetHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const styles: React.CSSProperties = {};
|
||||
|
||||
if (parentRect.right + submenuWidth > viewportWidth - 8) {
|
||||
styles.left = "auto";
|
||||
styles.right = "calc(100% - 2px)";
|
||||
} else {
|
||||
styles.left = "calc(100% - 2px)";
|
||||
styles.right = undefined;
|
||||
}
|
||||
|
||||
const overflowBottom = parentRect.top + submenuHeight - viewportHeight;
|
||||
if (overflowBottom > 0) {
|
||||
const topAdjust = Math.min(overflowBottom + 8, parentRect.top - 8);
|
||||
styles.top = `${-topAdjust}px`;
|
||||
} else {
|
||||
styles.top = undefined;
|
||||
}
|
||||
|
||||
setSubmenuStyles((prev) => ({ ...prev, [activeSubmenu]: styles }));
|
||||
}, [activeSubmenu]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const menuContent = (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn("context-menu", className)}
|
||||
style={{
|
||||
left: adjustedPosition.x,
|
||||
top: adjustedPosition.y,
|
||||
}}
|
||||
>
|
||||
<ul className="context-menu__list">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
ref={(el) => (itemRefs.current[item.id] = el)}
|
||||
className="context-menu__item-container"
|
||||
onMouseEnter={() =>
|
||||
item.submenu && handleSubmenuMouseEnter(item.id)
|
||||
}
|
||||
onMouseLeave={() => item.submenu && handleSubmenuMouseLeave()}
|
||||
>
|
||||
{item.separator && <div className="context-menu__separator" />}
|
||||
<button
|
||||
type="button"
|
||||
className={cn("context-menu__item", {
|
||||
"context-menu__item--disabled": item.disabled,
|
||||
"context-menu__item--danger": item.danger,
|
||||
"context-menu__item--has-submenu": item.submenu,
|
||||
"context-menu__item--active": activeSubmenu === item.id,
|
||||
})}
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="context-menu__item-icon">{item.icon}</span>
|
||||
)}
|
||||
<span className="context-menu__item-label">{item.label}</span>
|
||||
{item.submenu && (
|
||||
<span className="context-menu__item-arrow">▶</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{item.submenu && activeSubmenu === item.id && (
|
||||
<div
|
||||
className="context-menu__submenu"
|
||||
style={submenuStyles[item.id] || undefined}
|
||||
onMouseEnter={() => handleSubmenuMouseEnter(item.id)}
|
||||
onMouseLeave={() => handleSubmenuMouseLeave()}
|
||||
>
|
||||
<ul className="context-menu__list">
|
||||
{item.submenu.map((subItem) => (
|
||||
<li
|
||||
key={subItem.id}
|
||||
className="context-menu__item-container"
|
||||
>
|
||||
{subItem.separator && (
|
||||
<div className="context-menu__separator" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn("context-menu__item", {
|
||||
"context-menu__item--disabled": subItem.disabled,
|
||||
"context-menu__item--danger": subItem.danger,
|
||||
})}
|
||||
onClick={() => handleItemClick(subItem)}
|
||||
disabled={subItem.disabled}
|
||||
>
|
||||
{subItem.icon && (
|
||||
<span className="context-menu__item-icon">
|
||||
{subItem.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="context-menu__item-label">
|
||||
{subItem.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{children && <div className="context-menu__content">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(menuContent, document.body);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
PlayIcon,
|
||||
DownloadIcon,
|
||||
HeartIcon,
|
||||
HeartFillIcon,
|
||||
GearIcon,
|
||||
PencilIcon,
|
||||
FileDirectoryIcon,
|
||||
LinkIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { LibraryGame } from "@types";
|
||||
import { ContextMenu, ContextMenuItemData, ContextMenuProps } from "..";
|
||||
import { ConfirmModal } from "@renderer/components/confirm-modal/confirm-modal";
|
||||
import { useGameActions } from "..";
|
||||
|
||||
interface GameContextMenuProps extends Omit<ContextMenuProps, "items"> {
|
||||
game: LibraryGame;
|
||||
}
|
||||
|
||||
export function GameContextMenu({
|
||||
game,
|
||||
visible,
|
||||
position,
|
||||
onClose,
|
||||
}: GameContextMenuProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
const [showConfirmRemoveLibrary, setShowConfirmRemoveLibrary] =
|
||||
useState(false);
|
||||
const [showConfirmRemoveFiles, setShowConfirmRemoveFiles] = useState(false);
|
||||
const {
|
||||
canPlay,
|
||||
isDeleting,
|
||||
isGameDownloading,
|
||||
hasRepacks,
|
||||
shouldShowCreateStartMenuShortcut,
|
||||
handlePlayGame,
|
||||
handleToggleFavorite,
|
||||
handleCreateShortcut,
|
||||
handleCreateSteamShortcut,
|
||||
handleOpenFolder,
|
||||
handleOpenDownloadOptions,
|
||||
handleOpenDownloadLocation,
|
||||
handleRemoveFromLibrary,
|
||||
handleRemoveFiles,
|
||||
handleOpenGameOptions,
|
||||
} = useGameActions(game);
|
||||
|
||||
const items: ContextMenuItemData[] = [
|
||||
{
|
||||
id: "play",
|
||||
label: canPlay ? t("play") : t("download"),
|
||||
icon: canPlay ? <PlayIcon size={16} /> : <DownloadIcon size={16} />,
|
||||
onClick: () => {
|
||||
void handlePlayGame();
|
||||
},
|
||||
disabled: isDeleting,
|
||||
},
|
||||
{
|
||||
id: "favorite",
|
||||
label: game.favorite ? t("remove_from_favorites") : t("add_to_favorites"),
|
||||
icon: game.favorite ? (
|
||||
<HeartFillIcon size={16} />
|
||||
) : (
|
||||
<HeartIcon size={16} />
|
||||
),
|
||||
onClick: () => {
|
||||
void handleToggleFavorite();
|
||||
},
|
||||
disabled: isDeleting,
|
||||
},
|
||||
...(game.executablePath
|
||||
? [
|
||||
{
|
||||
id: "shortcuts",
|
||||
label: t("create_shortcut_simple"),
|
||||
icon: <LinkIcon size={16} />,
|
||||
disabled: isDeleting,
|
||||
submenu: [
|
||||
{
|
||||
id: "desktop-shortcut",
|
||||
label: t("create_shortcut"),
|
||||
icon: <LinkIcon size={16} />,
|
||||
onClick: () => handleCreateShortcut("desktop"),
|
||||
disabled: isDeleting,
|
||||
},
|
||||
{
|
||||
id: "steam-shortcut",
|
||||
label: t("create_steam_shortcut"),
|
||||
icon: <SteamLogo style={{ width: 16, height: 16 }} />,
|
||||
onClick: handleCreateSteamShortcut,
|
||||
disabled: isDeleting,
|
||||
},
|
||||
...(shouldShowCreateStartMenuShortcut
|
||||
? [
|
||||
{
|
||||
id: "start-menu-shortcut",
|
||||
label: t("create_start_menu_shortcut"),
|
||||
icon: <LinkIcon size={16} />,
|
||||
onClick: () => handleCreateShortcut("start_menu"),
|
||||
disabled: isDeleting,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
id: "manage",
|
||||
label: t("options"),
|
||||
icon: <GearIcon size={16} />,
|
||||
disabled: isDeleting,
|
||||
submenu: [
|
||||
...(game.executablePath
|
||||
? [
|
||||
{
|
||||
id: "open-folder",
|
||||
label: t("open_folder"),
|
||||
icon: <FileDirectoryIcon size={16} />,
|
||||
onClick: handleOpenFolder,
|
||||
disabled: isDeleting,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(game.executablePath
|
||||
? [
|
||||
{
|
||||
id: "download-options",
|
||||
label: t("open_download_options"),
|
||||
icon: <PlayIcon size={16} />,
|
||||
onClick: handleOpenDownloadOptions,
|
||||
disabled: isDeleting || isGameDownloading || !hasRepacks,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(game.download?.downloadPath
|
||||
? [
|
||||
{
|
||||
id: "download-location",
|
||||
label: t("open_download_location"),
|
||||
icon: <FileDirectoryIcon size={16} />,
|
||||
onClick: handleOpenDownloadLocation,
|
||||
disabled: isDeleting,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
id: "remove-library",
|
||||
label: t("remove_from_library"),
|
||||
icon: <XIcon size={16} />,
|
||||
onClick: () => setShowConfirmRemoveLibrary(true),
|
||||
disabled: isDeleting,
|
||||
danger: true,
|
||||
},
|
||||
...(game.download?.downloadPath
|
||||
? [
|
||||
{
|
||||
id: "remove-files",
|
||||
label: t("remove_files"),
|
||||
icon: <TrashIcon size={16} />,
|
||||
onClick: () => setShowConfirmRemoveFiles(true),
|
||||
disabled: isDeleting || isGameDownloading,
|
||||
danger: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
label: t("properties"),
|
||||
separator: true,
|
||||
icon: <PencilIcon size={16} />,
|
||||
onClick: () => handleOpenGameOptions(),
|
||||
disabled: isDeleting,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
items={items}
|
||||
visible={visible}
|
||||
position={position}
|
||||
onClose={onClose}
|
||||
className={
|
||||
!game.executablePath ? "context-menu--game-not-installed" : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
visible={showConfirmRemoveLibrary}
|
||||
title={t("remove_from_library_title")}
|
||||
description={t("remove_from_library_description", { game: game.title })}
|
||||
onClose={() => {
|
||||
setShowConfirmRemoveLibrary(false);
|
||||
onClose();
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
await handleRemoveFromLibrary();
|
||||
}}
|
||||
confirmLabel={t("remove")}
|
||||
cancelLabel={t("cancel")}
|
||||
confirmTheme="danger"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
visible={showConfirmRemoveFiles}
|
||||
title={t("remove_files")}
|
||||
description={t("delete_modal_description", { ns: "downloads" })}
|
||||
onClose={() => {
|
||||
setShowConfirmRemoveFiles(false);
|
||||
onClose();
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
await handleRemoveFiles();
|
||||
}}
|
||||
confirmLabel={t("remove")}
|
||||
cancelLabel={t("cancel")}
|
||||
confirmTheme="danger"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LibraryGame, ShortcutLocation } from "@types";
|
||||
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { logger } from "@renderer/logger";
|
||||
|
||||
export function useGameActions(game: LibraryGame) {
|
||||
const { t } = useTranslation("game_details");
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { updateLibrary } = useLibrary();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const {
|
||||
removeGameInstaller,
|
||||
removeGameFromLibrary,
|
||||
isGameDeleting,
|
||||
lastPacket,
|
||||
cancelDownload,
|
||||
} = useDownload();
|
||||
|
||||
const [creatingSteamShortcut, setCreatingSteamShortcut] = useState(false);
|
||||
|
||||
const canPlay = Boolean(game.executablePath);
|
||||
const isDeleting = isGameDeleting(game.id);
|
||||
const isGameDownloading =
|
||||
game.download?.status === "active" && lastPacket?.gameId === game.id;
|
||||
const hasRepacks = true;
|
||||
const shouldShowCreateStartMenuShortcut =
|
||||
window.electron.platform === "win32";
|
||||
|
||||
const handlePlayGame = async () => {
|
||||
if (!canPlay) {
|
||||
const path = buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
if (location.pathname === path) {
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:openRepacks", {
|
||||
detail: { objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
} else {
|
||||
navigate(path, { state: { openRepacks: true } });
|
||||
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:openRepacks", {
|
||||
detail: { objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electron.openGame(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
game.executablePath!,
|
||||
game.launchOptions
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorToast("Failed to start game");
|
||||
logger.error("Failed to start game", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
try {
|
||||
if (game.favorite) {
|
||||
await window.electron.removeGameFromFavorites(game.shop, game.objectId);
|
||||
showSuccessToast(t("game_removed_from_favorites"));
|
||||
} else {
|
||||
await window.electron.addGameToFavorites(game.shop, game.objectId);
|
||||
showSuccessToast(t("game_added_to_favorites"));
|
||||
}
|
||||
updateLibrary();
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:game-favorite-toggled", {
|
||||
detail: { shop: game.shop, objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(t("failed_update_favorites"));
|
||||
logger.error("Failed to toggle favorite", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateShortcut = async (location: ShortcutLocation) => {
|
||||
try {
|
||||
const success = await window.electron.createGameShortcut(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
location
|
||||
);
|
||||
|
||||
if (success) {
|
||||
showSuccessToast(t("create_shortcut_success"));
|
||||
} else {
|
||||
showErrorToast(t("create_shortcut_error"));
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(t("create_shortcut_error"));
|
||||
logger.error("Failed to create shortcut", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSteamShortcut = async () => {
|
||||
try {
|
||||
setCreatingSteamShortcut(true);
|
||||
await window.electron.createSteamShortcut(game.shop, game.objectId);
|
||||
|
||||
showSuccessToast(
|
||||
t("create_shortcut_success"),
|
||||
t("you_might_need_to_restart_steam")
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to create Steam shortcut", error);
|
||||
showErrorToast(t("create_shortcut_error"));
|
||||
} finally {
|
||||
setCreatingSteamShortcut(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
try {
|
||||
await window.electron.openGameExecutablePath(game.shop, game.objectId);
|
||||
} catch (error) {
|
||||
showErrorToast("Failed to open folder");
|
||||
logger.error("Failed to open folder", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDownloadOptions = () => {
|
||||
const path = buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
navigate(path, { state: { openRepacks: true } });
|
||||
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:openRepacks", {
|
||||
detail: { objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenGameOptions = () => {
|
||||
const path = buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
|
||||
navigate(path, { state: { openGameOptions: true } });
|
||||
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:openGameOptions", {
|
||||
detail: { objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDownloadLocation = async () => {
|
||||
try {
|
||||
await window.electron.openGameInstallerPath(game.shop, game.objectId);
|
||||
} catch (error) {
|
||||
showErrorToast("Failed to open download location");
|
||||
logger.error("Failed to open download location", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromLibrary = async () => {
|
||||
try {
|
||||
if (isGameDownloading) {
|
||||
await cancelDownload(game.shop, game.objectId);
|
||||
}
|
||||
|
||||
await removeGameFromLibrary(game.shop, game.objectId);
|
||||
updateLibrary();
|
||||
showSuccessToast(t("game_removed_from_library"));
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:game-removed-from-library", {
|
||||
detail: { shop: game.shop, objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(t("failed_remove_from_library"));
|
||||
logger.error("Failed to remove from library", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFiles = async () => {
|
||||
try {
|
||||
await removeGameInstaller(game.shop, game.objectId);
|
||||
updateLibrary();
|
||||
showSuccessToast(t("files_removed_success"));
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:game-files-removed", {
|
||||
detail: { shop: game.shop, objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(t("failed_remove_files"));
|
||||
logger.error("Failed to remove files", error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
canPlay,
|
||||
isDeleting,
|
||||
isGameDownloading,
|
||||
hasRepacks,
|
||||
shouldShowCreateStartMenuShortcut,
|
||||
creatingSteamShortcut,
|
||||
handlePlayGame,
|
||||
handleToggleFavorite,
|
||||
handleCreateShortcut,
|
||||
handleCreateSteamShortcut,
|
||||
handleOpenFolder,
|
||||
handleOpenDownloadOptions,
|
||||
handleOpenDownloadLocation,
|
||||
handleRemoveFromLibrary,
|
||||
handleRemoveFiles,
|
||||
handleOpenGameOptions,
|
||||
};
|
||||
}
|
||||
@@ -15,3 +15,6 @@ export * from "./badge/badge";
|
||||
export * from "./confirmation-modal/confirmation-modal";
|
||||
export * from "./suspense-wrapper/suspense-wrapper";
|
||||
export * from "./debrid-badge/debrid-badge";
|
||||
export * from "./context-menu/context-menu";
|
||||
export * from "./game-context-menu/game-context-menu";
|
||||
export * from "./game-context-menu/use-game-actions";
|
||||
|
||||
@@ -3,6 +3,8 @@ import PlayLogo from "@renderer/assets/play-logo.svg?react";
|
||||
import { LibraryGame } from "@types";
|
||||
import cn from "classnames";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { GameContextMenu } from "..";
|
||||
|
||||
interface SidebarGameItemProps {
|
||||
game: LibraryGame;
|
||||
@@ -16,6 +18,24 @@ export function SidebarGameItem({
|
||||
getGameTitle,
|
||||
}: Readonly<SidebarGameItemProps>) {
|
||||
const location = useLocation();
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
}>({ visible: false, position: { x: 0, y: 0 } });
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
|
||||
};
|
||||
|
||||
const isCustomGame = game.shop === "custom";
|
||||
const sidebarIcon = isCustomGame
|
||||
@@ -31,34 +51,44 @@ export function SidebarGameItem({
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active":
|
||||
location.pathname === `/game/${game.shop}/${game.objectId}`,
|
||||
"sidebar__menu-item--muted": game.download?.status === "removed",
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
<>
|
||||
<li
|
||||
key={game.id}
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active":
|
||||
location.pathname === `/game/${game.shop}/${game.objectId}`,
|
||||
"sidebar__menu-item--muted": game.download?.status === "removed",
|
||||
})}
|
||||
>
|
||||
{sidebarIcon ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={sidebarIcon}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
getFallbackIcon()
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{sidebarIcon ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={sidebarIcon}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
getFallbackIcon()
|
||||
)}
|
||||
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<GameContextMenu
|
||||
game={game}
|
||||
visible={contextMenu.visible}
|
||||
position={contextMenu.position}
|
||||
onClose={handleCloseContextMenu}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.Datanodes]: "Datanodes",
|
||||
[Downloader.Mediafire]: "Mediafire",
|
||||
[Downloader.TorBox]: "TorBox",
|
||||
[Downloader.AllDebrid]: "All-Debrid",
|
||||
[Downloader.Hydra]: "Nimbus",
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from "@types";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { GameDetailsContext } from "./game-details.context.types";
|
||||
import { SteamContentDescriptor } from "@shared";
|
||||
|
||||
@@ -91,6 +92,7 @@ export function GameDetailsContextProvider({
|
||||
}, [getRepacksForObjectId, objectId]);
|
||||
|
||||
const { i18n } = useTranslation("game_details");
|
||||
const location = useLocation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -177,7 +179,7 @@ export function GameDetailsContextProvider({
|
||||
if (abortController.signal.aborted) return;
|
||||
setAchievements(achievements);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => void 0);
|
||||
}
|
||||
}, [
|
||||
updateGame,
|
||||
@@ -198,6 +200,18 @@ export function GameDetailsContextProvider({
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = (location && (location.state as Record<string, unknown>)) || {};
|
||||
if (state.openRepacks) {
|
||||
setShowRepacksModal(true);
|
||||
try {
|
||||
window.history.replaceState({}, document.title, location.pathname);
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
useEffect(() => {
|
||||
if (game?.title) {
|
||||
dispatch(setHeaderTitle(game.title));
|
||||
@@ -222,6 +236,61 @@ export function GameDetailsContextProvider({
|
||||
};
|
||||
}, [game?.id, isGameRunning, updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
try {
|
||||
const detail = (ev as CustomEvent).detail || {};
|
||||
if (detail.objectId && detail.objectId === objectId) {
|
||||
setShowRepacksModal(true);
|
||||
}
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("hydra:openRepacks", handler as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hydra:openRepacks", handler as EventListener);
|
||||
};
|
||||
}, [objectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
try {
|
||||
const detail = (ev as CustomEvent).detail || {};
|
||||
if (detail.objectId && detail.objectId === objectId) {
|
||||
setShowGameOptionsModal(true);
|
||||
}
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("hydra:openGameOptions", handler as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"hydra:openGameOptions",
|
||||
handler as EventListener
|
||||
);
|
||||
};
|
||||
}, [objectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const state =
|
||||
(location && (location.state as Record<string, unknown>)) || {};
|
||||
if (state.openGameOptions) {
|
||||
setShowGameOptionsModal(true);
|
||||
|
||||
try {
|
||||
window.history.replaceState({}, document.title, location.pathname);
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const lastDownloadedOption = useMemo(() => {
|
||||
if (game?.download) {
|
||||
const repack = repacks.find((repack) =>
|
||||
|
||||
@@ -63,6 +63,7 @@ export function SettingsContextProvider({
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const defaultSourceUrl = searchParams.get("urls");
|
||||
const defaultTab = searchParams.get("tab");
|
||||
const defaultAppearanceTheme = searchParams.get("theme");
|
||||
const defaultAppearanceAuthorId = searchParams.get("authorId");
|
||||
const defaultAppearanceAuthorName = searchParams.get("authorName");
|
||||
@@ -77,6 +78,13 @@ export function SettingsContextProvider({
|
||||
}
|
||||
}, [defaultSourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultTab) {
|
||||
const idx = Number(defaultTab);
|
||||
if (!Number.isNaN(idx)) setCurrentCategoryIndex(idx);
|
||||
}
|
||||
}, [defaultTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appearance.theme) setCurrentCategoryIndex(3);
|
||||
}, [appearance.theme]);
|
||||
|
||||
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
@@ -9,6 +9,7 @@ import type {
|
||||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
AllDebridUser,
|
||||
UserProfile,
|
||||
FriendRequest,
|
||||
FriendRequestAction,
|
||||
@@ -240,6 +241,9 @@ declare global {
|
||||
) => Promise<void>;
|
||||
/* User preferences */
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
authenticateAllDebrid: (
|
||||
apiKey: string
|
||||
) => Promise<AllDebridUser | { error_code: string }>;
|
||||
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
updateUserPreferences: (
|
||||
|
||||
@@ -114,6 +114,15 @@ export function DownloadGroup({
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
|
||||
if (download.downloader === Downloader.AllDebrid) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
<p>{t("alldebrid_size_not_supported")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGameDownloading) {
|
||||
if (lastPacket?.isDownloadingMetadata) {
|
||||
return <p>{t("downloading_metadata")}</p>;
|
||||
@@ -181,6 +190,15 @@ export function DownloadGroup({
|
||||
}
|
||||
|
||||
if (download.status === "active") {
|
||||
if ((download.downloader as unknown as string) === "alldebrid") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
<p>{t("alldebrid_size_not_supported")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
@@ -275,7 +293,9 @@ export function DownloadGroup({
|
||||
(download?.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(download?.downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken);
|
||||
!userPreferences?.torBoxApiToken) ||
|
||||
(download?.downloader === Downloader.AllDebrid &&
|
||||
!userPreferences?.allDebridApiKey);
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
.hero-panel-actions {
|
||||
&__action {
|
||||
border: solid 1px globals.$muted-color;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
|
||||
import "./hero-panel-actions.scss";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function HeroPanelActions() {
|
||||
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
|
||||
@@ -52,6 +53,33 @@ export function HeroPanelActions() {
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
useEffect(() => {
|
||||
const onFavoriteToggled = () => {
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const onGameRemoved = () => {
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const onFilesRemoved = () => {
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
};
|
||||
|
||||
window.addEventListener("hydra:game-favorite-toggled", onFavoriteToggled as EventListener);
|
||||
window.addEventListener("hydra:game-removed-from-library", onGameRemoved as EventListener);
|
||||
window.addEventListener("hydra:game-files-removed", onFilesRemoved as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hydra:game-favorite-toggled", onFavoriteToggled as EventListener);
|
||||
window.removeEventListener("hydra:game-removed-from-library", onGameRemoved as EventListener);
|
||||
window.removeEventListener("hydra:game-files-removed", onFilesRemoved as EventListener);
|
||||
};
|
||||
}, [updateLibrary, updateGame]);
|
||||
|
||||
const addGameToLibrary = async () => {
|
||||
setToggleLibraryGameDisabled(true);
|
||||
|
||||
@@ -197,8 +225,8 @@ export function HeroPanelActions() {
|
||||
<Button
|
||||
onClick={() => setShowRepacksModal(true)}
|
||||
theme="outline"
|
||||
disabled={isGameDownloading || !repacks.length}
|
||||
className="hero-panel-actions__action"
|
||||
disabled={isGameDownloading}
|
||||
className={`hero-panel-actions__action ${!repacks.length ? 'hero-panel-actions__action--disabled' : ''}`}
|
||||
>
|
||||
<DownloadIcon />
|
||||
{t("download")}
|
||||
|
||||
@@ -117,6 +117,8 @@ export function DownloadSettingsModal({
|
||||
return userPreferences?.realDebridApiToken;
|
||||
if (downloader === Downloader.TorBox)
|
||||
return userPreferences?.torBoxApiToken;
|
||||
if (downloader === Downloader.AllDebrid)
|
||||
return userPreferences?.allDebridApiKey;
|
||||
if (downloader === Downloader.Hydra)
|
||||
return isFeatureEnabled(Feature.Nimbus);
|
||||
return true;
|
||||
@@ -131,6 +133,7 @@ export function DownloadSettingsModal({
|
||||
downloaders,
|
||||
userPreferences?.realDebridApiToken,
|
||||
userPreferences?.torBoxApiToken,
|
||||
userPreferences?.allDebridApiKey,
|
||||
]);
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
@@ -191,6 +194,8 @@ export function DownloadSettingsModal({
|
||||
const shouldDisableButton =
|
||||
(downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(downloader === Downloader.AllDebrid &&
|
||||
!userPreferences?.allDebridApiKey) ||
|
||||
(downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken) ||
|
||||
(downloader === Downloader.Hydra &&
|
||||
|
||||
@@ -277,4 +277,4 @@ export function RepacksModal({
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
12
src/renderer/src/pages/settings/settings-all-debrid.scss
Normal file
12
src/renderer/src/pages/settings/settings-all-debrid.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.settings-all-debrid {
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
129
src/renderer/src/pages/settings/settings-all-debrid.tsx
Normal file
129
src/renderer/src/pages/settings/settings-all-debrid.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
|
||||
import "./settings-all-debrid.scss";
|
||||
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
|
||||
import { settingsContext } from "@renderer/context";
|
||||
|
||||
const ALL_DEBRID_API_TOKEN_URL = "https://alldebrid.com/apikeys";
|
||||
|
||||
export function SettingsAllDebrid() {
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const { updateUserPreferences } = useContext(settingsContext);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
useAllDebrid: false,
|
||||
allDebridApiKey: null as string | null,
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
setForm({
|
||||
useAllDebrid: Boolean(userPreferences.allDebridApiKey),
|
||||
allDebridApiKey: userPreferences.allDebridApiKey ?? null,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = async (
|
||||
event
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
if (form.useAllDebrid) {
|
||||
if (!form.allDebridApiKey) {
|
||||
showErrorToast(t("alldebrid_missing_key"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electron.authenticateAllDebrid(
|
||||
form.allDebridApiKey
|
||||
);
|
||||
|
||||
if ("error_code" in result) {
|
||||
showErrorToast(t(result.error_code));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.isPremium) {
|
||||
showErrorToast(
|
||||
t("all_debrid_free_account_error", { username: result.username })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessToast(
|
||||
t("all_debrid_account_linked"),
|
||||
t("debrid_linked_message", { username: result.username })
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t("changes_saved"));
|
||||
}
|
||||
|
||||
updateUserPreferences({
|
||||
allDebridApiKey: form.useAllDebrid ? form.allDebridApiKey : null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
showErrorToast(t("alldebrid_unknown_error"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled =
|
||||
(form.useAllDebrid && !form.allDebridApiKey) || isLoading;
|
||||
|
||||
return (
|
||||
<form className="settings-all-debrid__form" onSubmit={handleFormSubmit}>
|
||||
<p className="settings-all-debrid__description">
|
||||
{t("all_debrid_description")}
|
||||
</p>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_all_debrid")}
|
||||
checked={form.useAllDebrid}
|
||||
onChange={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
useAllDebrid: !form.useAllDebrid,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{form.useAllDebrid && (
|
||||
<TextField
|
||||
label={t("api_token")}
|
||||
value={form.allDebridApiKey ?? ""}
|
||||
type="password"
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, allDebridApiKey: event.target.value })
|
||||
}
|
||||
rightContent={
|
||||
<Button type="submit" disabled={isButtonDisabled}>
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
}
|
||||
placeholder="API Key"
|
||||
hint={
|
||||
<Trans i18nKey="debrid_api_token_hint" ns="settings">
|
||||
<Link to={ALL_DEBRID_API_TOKEN_URL} />
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsAllDebrid } from "./settings-all-debrid";
|
||||
import { SettingsGeneral } from "./settings-general";
|
||||
import { SettingsBehavior } from "./settings-behavior";
|
||||
import { SettingsDownloadSources } from "./settings-download-sources";
|
||||
@@ -42,6 +43,7 @@ export default function Settings() {
|
||||
]
|
||||
: []),
|
||||
{ tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
|
||||
{ tabLabel: "All-Debrid", contentTitle: "All-Debrid" },
|
||||
];
|
||||
|
||||
if (userDetails)
|
||||
@@ -81,6 +83,10 @@ export default function Settings() {
|
||||
return <SettingsRealDebrid />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 6) {
|
||||
return <SettingsAllDebrid />;
|
||||
}
|
||||
|
||||
return <SettingsAccount />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum Downloader {
|
||||
RealDebrid,
|
||||
AllDebrid,
|
||||
Torrent,
|
||||
Gofile,
|
||||
PixelDrain,
|
||||
@@ -55,6 +56,7 @@ export enum AuthPage {
|
||||
|
||||
export enum DownloadError {
|
||||
NotCachedOnRealDebrid = "download_error_not_cached_on_real_debrid",
|
||||
NotCachedInAllDebrid = "download_error_not_cached_in_alldebrid",
|
||||
NotCachedOnTorBox = "download_error_not_cached_on_torbox",
|
||||
GofileQuotaExceeded = "download_error_gofile_quota_exceeded",
|
||||
RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized",
|
||||
|
||||
@@ -123,6 +123,7 @@ export const getDownloadersForUri = (uri: string) => {
|
||||
Downloader.Hydra,
|
||||
Downloader.TorBox,
|
||||
Downloader.RealDebrid,
|
||||
Downloader.AllDebrid,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -175,3 +175,11 @@ export interface SeedingStatus {
|
||||
status: DownloadStatus;
|
||||
uploadSpeed: number;
|
||||
}
|
||||
|
||||
/* All-Debrid */
|
||||
export interface AllDebridUser {
|
||||
username: string;
|
||||
email: string;
|
||||
isPremium: boolean;
|
||||
premiumUntil: string;
|
||||
}
|
||||
|
||||
@@ -95,9 +95,11 @@ export type AchievementCustomNotificationPosition =
|
||||
|
||||
export interface UserPreferences {
|
||||
downloadsPath?: string | null;
|
||||
ggDealsApiKey?: string | null;
|
||||
language?: string;
|
||||
realDebridApiToken?: string | null;
|
||||
torBoxApiToken?: string | null;
|
||||
allDebridApiKey?: string | null;
|
||||
preferQuitInsteadOfHiding?: boolean;
|
||||
runAtStartup?: boolean;
|
||||
startMinimized?: boolean;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"src/locales/index.ts",
|
||||
"src/shared/**/*",
|
||||
"src/stories/**/*",
|
||||
"src/types/**/*",
|
||||
".storybook/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
|
||||
Reference in New Issue
Block a user