diff --git a/python_rpc/http_multi_link_downloader.py b/python_rpc/http_multi_link_downloader.py new file mode 100644 index 00000000..3968d77c --- /dev/null +++ b/python_rpc/http_multi_link_downloader.py @@ -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 [] \ No newline at end of file diff --git a/python_rpc/main.py b/python_rpc/main.py index 152f8ffb..36170025 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -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: diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 670dda4f..22c54234 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index fc72fc51..b782da21 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -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", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index b361dba1..37569701 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -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", diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json index 8ed6fd39..be02c7b4 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -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ă", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 378a3b6e..1d537db3 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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"; diff --git a/src/main/events/user-preferences/authenticate-all-debrid.ts b/src/main/events/user-preferences/authenticate-all-debrid.ts new file mode 100644 index 00000000..713db965 --- /dev/null +++ b/src/main/events/user-preferences/authenticate-all-debrid.ts @@ -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); diff --git a/src/main/main.ts b/src/main/main.ts index f96662c3..67391057 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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); } diff --git a/src/main/services/download/all-debrid.ts b/src/main/services/download/all-debrid.ts new file mode 100644 index 00000000..05ee56c6 --- /dev/null +++ b/src/main/services/download/all-debrid.ts @@ -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 { + 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 { + 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 + } +} diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 57b3bac2..7c256b51 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -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); diff --git a/src/main/services/download/helpers.ts b/src/main/services/download/helpers.ts index 0856eb16..84db662e 100644 --- a/src/main/services/download/helpers.ts +++ b/src/main/services/download/helpers.ts @@ -17,17 +17,24 @@ export const calculateETA = ( }; export const getDirSize = async (dir: string): Promise => { - const getItemSize = async (filePath: string): Promise => { - 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 => { + 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)); diff --git a/src/main/services/download/index.ts b/src/main/services/download/index.ts index f4e2eddc..c28d560b 100644 --- a/src/main/services/download/index.ts +++ b/src/main/services/download/index.ts @@ -1,3 +1,4 @@ export * from "./download-manager"; export * from "./real-debrid"; +export * from "./all-debrid"; export * from "./torbox"; diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index da4f1e71..f3ce9f6c 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -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> = { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7596fd11..813758f0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/components/confirm-modal/confirm-modal.scss b/src/renderer/src/components/confirm-modal/confirm-modal.scss new file mode 100644 index 00000000..e5bda187 --- /dev/null +++ b/src/renderer/src/components/confirm-modal/confirm-modal.scss @@ -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; + } +} diff --git a/src/renderer/src/components/confirm-modal/confirm-modal.tsx b/src/renderer/src/components/confirm-modal/confirm-modal.tsx new file mode 100644 index 00000000..d210c035 --- /dev/null +++ b/src/renderer/src/components/confirm-modal/confirm-modal.tsx @@ -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; + 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 ( + +
+ + + +
+
+ ); +} diff --git a/src/renderer/src/components/context-menu/context-menu.scss b/src/renderer/src/components/context-menu/context-menu.scss new file mode 100644 index 00000000..673fa2da --- /dev/null +++ b/src/renderer/src/components/context-menu/context-menu.scss @@ -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); + } +} diff --git a/src/renderer/src/components/context-menu/context-menu.tsx b/src/renderer/src/components/context-menu/context-menu.tsx new file mode 100644 index 00000000..cb9e0347 --- /dev/null +++ b/src/renderer/src/components/context-menu/context-menu.tsx @@ -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(null); + const [adjustedPosition, setAdjustedPosition] = useState(position); + const [activeSubmenu, setActiveSubmenu] = useState(null); + const submenuCloseTimeout = useRef(null); + const itemRefs = useRef>({}); + const [submenuStyles, setSubmenuStyles] = useState< + Record + >({}); + + 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 = ( +
+
    + {items.map((item) => ( +
  • (itemRefs.current[item.id] = el)} + className="context-menu__item-container" + onMouseEnter={() => + item.submenu && handleSubmenuMouseEnter(item.id) + } + onMouseLeave={() => item.submenu && handleSubmenuMouseLeave()} + > + {item.separator &&
    } + + + {item.submenu && activeSubmenu === item.id && ( +
    handleSubmenuMouseEnter(item.id)} + onMouseLeave={() => handleSubmenuMouseLeave()} + > +
      + {item.submenu.map((subItem) => ( +
    • + {subItem.separator && ( +
      + )} + +
    • + ))} +
    +
    + )} +
  • + ))} +
+ {children &&
{children}
} +
+ ); + + return createPortal(menuContent, document.body); +} diff --git a/src/renderer/src/components/game-context-menu/game-context-menu.tsx b/src/renderer/src/components/game-context-menu/game-context-menu.tsx new file mode 100644 index 00000000..bb341a3a --- /dev/null +++ b/src/renderer/src/components/game-context-menu/game-context-menu.tsx @@ -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 { + 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 ? : , + onClick: () => { + void handlePlayGame(); + }, + disabled: isDeleting, + }, + { + id: "favorite", + label: game.favorite ? t("remove_from_favorites") : t("add_to_favorites"), + icon: game.favorite ? ( + + ) : ( + + ), + onClick: () => { + void handleToggleFavorite(); + }, + disabled: isDeleting, + }, + ...(game.executablePath + ? [ + { + id: "shortcuts", + label: t("create_shortcut_simple"), + icon: , + disabled: isDeleting, + submenu: [ + { + id: "desktop-shortcut", + label: t("create_shortcut"), + icon: , + onClick: () => handleCreateShortcut("desktop"), + disabled: isDeleting, + }, + { + id: "steam-shortcut", + label: t("create_steam_shortcut"), + icon: , + onClick: handleCreateSteamShortcut, + disabled: isDeleting, + }, + ...(shouldShowCreateStartMenuShortcut + ? [ + { + id: "start-menu-shortcut", + label: t("create_start_menu_shortcut"), + icon: , + onClick: () => handleCreateShortcut("start_menu"), + disabled: isDeleting, + }, + ] + : []), + ], + }, + ] + : []), + + { + id: "manage", + label: t("options"), + icon: , + disabled: isDeleting, + submenu: [ + ...(game.executablePath + ? [ + { + id: "open-folder", + label: t("open_folder"), + icon: , + onClick: handleOpenFolder, + disabled: isDeleting, + }, + ] + : []), + ...(game.executablePath + ? [ + { + id: "download-options", + label: t("open_download_options"), + icon: , + onClick: handleOpenDownloadOptions, + disabled: isDeleting || isGameDownloading || !hasRepacks, + }, + ] + : []), + ...(game.download?.downloadPath + ? [ + { + id: "download-location", + label: t("open_download_location"), + icon: , + onClick: handleOpenDownloadLocation, + disabled: isDeleting, + }, + ] + : []), + + { + id: "remove-library", + label: t("remove_from_library"), + icon: , + onClick: () => setShowConfirmRemoveLibrary(true), + disabled: isDeleting, + danger: true, + }, + ...(game.download?.downloadPath + ? [ + { + id: "remove-files", + label: t("remove_files"), + icon: , + onClick: () => setShowConfirmRemoveFiles(true), + disabled: isDeleting || isGameDownloading, + danger: true, + }, + ] + : []), + ], + }, + { + id: "properties", + label: t("properties"), + separator: true, + icon: , + onClick: () => handleOpenGameOptions(), + disabled: isDeleting, + }, + ]; + + return ( + <> + + + { + setShowConfirmRemoveLibrary(false); + onClose(); + }} + onConfirm={async () => { + await handleRemoveFromLibrary(); + }} + confirmLabel={t("remove")} + cancelLabel={t("cancel")} + confirmTheme="danger" + /> + + { + setShowConfirmRemoveFiles(false); + onClose(); + }} + onConfirm={async () => { + await handleRemoveFiles(); + }} + confirmLabel={t("remove")} + cancelLabel={t("cancel")} + confirmTheme="danger" + /> + + ); +} diff --git a/src/renderer/src/components/game-context-menu/use-game-actions.ts b/src/renderer/src/components/game-context-menu/use-game-actions.ts new file mode 100644 index 00000000..042b796b --- /dev/null +++ b/src/renderer/src/components/game-context-menu/use-game-actions.ts @@ -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, + }; +} diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index 8373e0dc..9970be42 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -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"; diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 7733aee0..356aa913 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -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) { 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 ( -
  • - -
  • + + {getGameTitle(game)} + + + + + + ); } diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index e28c7633..5f5661ea 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -11,6 +11,7 @@ export const DOWNLOADER_NAME = { [Downloader.Datanodes]: "Datanodes", [Downloader.Mediafire]: "Mediafire", [Downloader.TorBox]: "TorBox", + [Downloader.AllDebrid]: "All-Debrid", [Downloader.Hydra]: "Nimbus", }; diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 6fe01663..23ea3845 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -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)) || {}; + 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)) || {}; + 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) => diff --git a/src/renderer/src/context/settings/settings.context.tsx b/src/renderer/src/context/settings/settings.context.tsx index 61a3fdb7..5c79f38d 100644 --- a/src/renderer/src/context/settings/settings.context.tsx +++ b/src/renderer/src/context/settings/settings.context.tsx @@ -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]); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index c1e06a89..82bbeb28 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -9,6 +9,7 @@ import type { UserPreferences, StartGameDownloadPayload, RealDebridUser, + AllDebridUser, UserProfile, FriendRequest, FriendRequestAction, @@ -240,6 +241,9 @@ declare global { ) => Promise; /* User preferences */ authenticateRealDebrid: (apiToken: string) => Promise; + authenticateAllDebrid: ( + apiKey: string + ) => Promise; authenticateTorBox: (apiToken: string) => Promise; getUserPreferences: () => Promise; updateUserPreferences: ( diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 06e9face..bfa27792 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -114,6 +114,15 @@ export function DownloadGroup({ return

    {t("deleting")}

    ; } + if (download.downloader === Downloader.AllDebrid) { + return ( + <> +

    {progress}

    +

    {t("alldebrid_size_not_supported")}

    + + ); + } + if (isGameDownloading) { if (lastPacket?.isDownloadingMetadata) { return

    {t("downloading_metadata")}

    ; @@ -181,6 +190,15 @@ export function DownloadGroup({ } if (download.status === "active") { + if ((download.downloader as unknown as string) === "alldebrid") { + return ( + <> +

    {formatDownloadProgress(download.progress)}

    +

    {t("alldebrid_size_not_supported")}

    + + ); + } + return ( <>

    {formatDownloadProgress(download.progress)}

    @@ -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 [ { diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.scss b/src/renderer/src/pages/game-details/hero/hero-panel-actions.scss index 1f258ced..f8313370 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.scss +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.scss @@ -3,6 +3,10 @@ .hero-panel-actions { &__action { border: solid 1px globals.$muted-color; + + &--disabled { + opacity: 0.5; + } } &__container { diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index d8d98583..ac8a1615 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -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() { + } + placeholder="API Key" + hint={ + + + + } + /> + )} + + ); +} diff --git a/src/renderer/src/pages/settings/settings.tsx b/src/renderer/src/pages/settings/settings.tsx index 325c2e17..d609d218 100644 --- a/src/renderer/src/pages/settings/settings.tsx +++ b/src/renderer/src/pages/settings/settings.tsx @@ -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 ; } + if (currentCategoryIndex === 6) { + return ; + } + return ; }; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 851aec49..352ffe12 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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", diff --git a/src/shared/index.ts b/src/shared/index.ts index 2f35692f..000ffd22 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -123,6 +123,7 @@ export const getDownloadersForUri = (uri: string) => { Downloader.Hydra, Downloader.TorBox, Downloader.RealDebrid, + Downloader.AllDebrid, ]; } diff --git a/src/types/download.types.ts b/src/types/download.types.ts index 004d8f27..d19a3b83 100644 --- a/src/types/download.types.ts +++ b/src/types/download.types.ts @@ -175,3 +175,11 @@ export interface SeedingStatus { status: DownloadStatus; uploadSpeed: number; } + +/* All-Debrid */ +export interface AllDebridUser { + username: string; + email: string; + isPremium: boolean; + premiumUntil: string; +} diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 8a6c56a0..93925068 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -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; diff --git a/tsconfig.web.json b/tsconfig.web.json index 6dc0c4ab..c80396f3 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -8,6 +8,7 @@ "src/locales/index.ts", "src/shared/**/*", "src/stories/**/*", + "src/types/**/*", ".storybook/**/*" ], "compilerOptions": {