From aacf9abc6aef60f99b2ef82f51011a7e8e039ac3 Mon Sep 17 00:00:00 2001 From: ItsYeBoi20 Date: Wed, 1 Oct 2025 05:04:34 +0200 Subject: [PATCH] Update PR #1452 to latest HydraLauncher and fix conflicts --- python_rpc/http_multi_link_downloader.py | 151 +++++++++ python_rpc/main.py | 58 +++- src/locales/en/translation.json | 73 +--- src/locales/ro/translation.json | 13 +- src/main/events/index.ts | 8 +- .../authenticate-all-debrid.ts | 17 + src/main/main.ts | 5 + src/main/services/download/all-debrid.ts | 315 ++++++++++++++++++ .../services/download/download-manager.ts | 23 ++ src/main/services/download/helpers.ts | 21 +- src/main/services/download/index.ts | 1 + src/main/services/python-rpc.ts | 6 +- src/preload/index.ts | 48 +-- src/renderer/src/constants.ts | 1 + src/renderer/src/declaration.d.ts | 43 +-- .../src/pages/downloads/download-group.tsx | 22 +- .../game-details/hero/hero-panel-actions.tsx | 2 +- .../modals/download-settings-modal.tsx | 5 + .../pages/settings/settings-all-debrid.scss | 12 + .../pages/settings/settings-all-debrid.tsx | 129 +++++++ src/renderer/src/pages/settings/settings.tsx | 6 + src/shared/constants.ts | 2 + src/shared/index.ts | 1 + src/types/download.types.ts | 8 + src/types/level.types.ts | 13 +- tsconfig.web.json | 1 + 26 files changed, 805 insertions(+), 179 deletions(-) create mode 100644 python_rpc/http_multi_link_downloader.py create mode 100644 src/main/events/user-preferences/authenticate-all-debrid.ts create mode 100644 src/main/services/download/all-debrid.ts create mode 100644 src/renderer/src/pages/settings/settings-all-debrid.scss create mode 100644 src/renderer/src/pages/settings/settings-all-debrid.tsx 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 bcf8cf54..bf0cbb73 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -4,6 +4,7 @@ "successfully_signed_in": "Successfully signed in" }, "home": { + "featured": "Featured", "surprise_me": "Surprise me", "no_results": "No results found", "start_typing": "Starting typing to search...", @@ -27,56 +28,7 @@ "friends": "Friends", "need_help": "Need help?", "favorites": "Favorites", - "playable_button_title": "Show only games you can play now", - "add_custom_game_tooltip": "Add Custom Game", - "show_playable_only_tooltip": "Show Playable Only", - "custom_game_modal": "Add Custom Game", - "custom_game_modal_description": "Add a custom game to your library by selecting an executable file", - "custom_game_modal_executable_path": "Executable Path", - "custom_game_modal_select_executable": "Select executable file", - "custom_game_modal_title": "Title", - "custom_game_modal_enter_title": "Enter title", - "custom_game_modal_browse": "Browse", - "custom_game_modal_cancel": "Cancel", - "custom_game_modal_add": "Add Game", - "custom_game_modal_adding": "Adding Game...", - "custom_game_modal_success": "Custom game added successfully", - "custom_game_modal_failed": "Failed to add custom game", - "custom_game_modal_executable": "Executable", - "edit_game_modal": "Customize Assets", - "edit_game_modal_description": "Customize game assets and details", - "edit_game_modal_title": "Title", - "edit_game_modal_enter_title": "Enter title", - "edit_game_modal_image": "Image", - "edit_game_modal_select_image": "Select image", - "edit_game_modal_browse": "Browse", - "edit_game_modal_image_preview": "Image preview", - "edit_game_modal_icon": "Icon", - "edit_game_modal_select_icon": "Select icon", - "edit_game_modal_icon_preview": "Icon preview", - "edit_game_modal_logo": "Logo", - "edit_game_modal_select_logo": "Select logo", - "edit_game_modal_logo_preview": "Logo preview", - "edit_game_modal_hero": "Library Hero", - "edit_game_modal_select_hero": "Select library hero image", - "edit_game_modal_hero_preview": "Library hero image preview", - "edit_game_modal_cancel": "Cancel", - "edit_game_modal_update": "Update", - "edit_game_modal_updating": "Updating...", - "edit_game_modal_fill_required": "Please fill in all required fields", - "edit_game_modal_success": "Assets updated successfully", - "edit_game_modal_failed": "Failed to update assets", - "edit_game_modal_image_filter": "Image", - "edit_game_modal_icon_resolution": "Recommended resolution: 256x256px", - "edit_game_modal_logo_resolution": "Recommended resolution: 640x360px", - "edit_game_modal_hero_resolution": "Recommended resolution: 1920x620px", - "edit_game_modal_assets": "Assets", - "edit_game_modal_drop_icon_image_here": "Drop icon image here", - "edit_game_modal_drop_logo_image_here": "Drop logo image here", - "edit_game_modal_drop_hero_image_here": "Drop hero image here", - "edit_game_modal_drop_to_replace_icon": "Drop to replace icon", - "edit_game_modal_drop_to_replace_logo": "Drop to replace logo", - "edit_game_modal_drop_to_replace_hero": "Drop to replace hero" + "playable_button_title": "Show only games you can play now" }, "header": { "search": "Search games", @@ -279,7 +231,6 @@ "backup_unfrozen": "Backup unpinned", "backup_freeze_failed": "Failed to freeze backup", "backup_freeze_failed_description": "You must leave at least one free slot for automatic backups", - "edit_game_modal_button": "Customize game assets", "game_details": "Game Details", "currency_symbol": "$", "currency_country": "us", @@ -290,11 +241,10 @@ "keyshop_price": "Keyshop price", "historical_retail": "Historical retail", "historical_keyshop": "Historical keyshop", + "supported_languages": "Supported languages", "language": "Language", "caption": "Caption", - "audio": "Audio", - "filter_by_source": "Filter by source", - "no_repacks_found": "No sources found for this game" + "audio": "Audio" }, "activation": { "title": "Activate Hydra", @@ -332,6 +282,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…" }, @@ -340,6 +291,7 @@ "change": "Update", "notifications": "Notifications", "enable_download_notifications": "When a download is complete", + "gg_deals_api_key_description": "gg deals api key. Used to show the lowest price. (https://gg.deals/api/)", "enable_repack_list_notifications": "When a new repack is added", "real_debrid_api_token_label": "Real-Debrid API token", "quit_app_instead_hiding": "Don't hide Hydra when closing", @@ -441,6 +393,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", @@ -514,8 +477,6 @@ "user_profile": { "amount_hours": "{{amount}} hours", "amount_minutes": "{{amount}} minutes", - "amount_hours_short": "{{amount}}h", - "amount_minutes_short": "{{amount}}m", "last_time_played": "Last played {{period}}", "activity": "Recent Activity", "library": "Library", diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json index 8ed6fd39..2c4e68a4 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -1,6 +1,7 @@ { "language_name": "Română", "home": { + "featured": "Recomandate", "surprise_me": "Surprinde-mă", "no_results": "Niciun rezultat găsit" }, @@ -18,6 +19,7 @@ }, "header": { "search": "Caută jocuri", + "home": "Acasă", "catalogue": "Catalog", "downloads": "Descărcări", @@ -30,7 +32,10 @@ "downloading": "Se descarcă {{title}}... ({{percentage}} complet) - Concluzie {{eta}} - {{speed}}", "calculating_eta": "Se descarcă {{title}}... ({{percentage}} complet) - Calculare timp rămas..." }, - "catalogue": {}, + "catalogue": { + "next_page": "Pagina următoare", + "previous_page": "Pagina anterioară" + }, "game_details": { "open_download_options": "Deschide opțiunile de descărcare", "download_options_zero": "Nicio opțiune de descărcare", @@ -135,7 +140,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 d4c461f8..8cb48d47 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -14,9 +14,6 @@ import "./catalogue/get-developers"; import "./hardware/get-disk-free-space"; import "./hardware/check-folder-write-permission"; import "./library/add-game-to-library"; -import "./library/add-custom-game-to-library"; -import "./library/update-custom-game"; -import "./library/update-game-custom-assets"; import "./library/add-game-to-favorites"; import "./library/remove-game-from-favorites"; import "./library/toggle-game-pin"; @@ -40,9 +37,7 @@ import "./library/reset-game-achievements"; import "./library/change-game-playtime"; import "./library/toggle-automatic-cloud-sync"; import "./library/get-default-wine-prefix-selection-path"; -import "./library/cleanup-unused-assets"; import "./library/create-steam-shortcut"; -import "./library/copy-custom-game-asset"; import "./misc/open-checkout"; import "./misc/open-external"; import "./misc/show-open-dialog"; @@ -51,10 +46,9 @@ import "./misc/show-item-in-folder"; import "./misc/get-badges"; import "./misc/install-common-redist"; import "./misc/can-install-common-redist"; -import "./misc/save-temp-file"; -import "./misc/delete-temp-file"; import "./torrenting/cancel-game-download"; import "./torrenting/pause-game-download"; +import "./user-preferences/authenticate-all-debrid"; import "./torrenting/resume-game-download"; import "./torrenting/start-game-download"; import "./torrenting/pause-game-seed"; 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 17c1225f..66eedcbf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -102,6 +102,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), @@ -128,52 +130,6 @@ contextBridge.exposeInMainWorld("electron", { ), addGameToLibrary: (shop: GameShop, objectId: string, title: string) => ipcRenderer.invoke("addGameToLibrary", shop, objectId, title), - addCustomGameToLibrary: ( - title: string, - executablePath: string, - iconUrl?: string, - logoImageUrl?: string, - libraryHeroImageUrl?: string - ) => - ipcRenderer.invoke( - "addCustomGameToLibrary", - title, - executablePath, - iconUrl, - logoImageUrl, - libraryHeroImageUrl - ), - copyCustomGameAsset: ( - sourcePath: string, - assetType: "icon" | "logo" | "hero" - ) => ipcRenderer.invoke("copyCustomGameAsset", sourcePath, assetType), - saveTempFile: (fileName: string, fileData: Uint8Array) => - ipcRenderer.invoke("saveTempFile", fileName, fileData), - deleteTempFile: (filePath: string) => - ipcRenderer.invoke("deleteTempFile", filePath), - cleanupUnusedAssets: () => ipcRenderer.invoke("cleanupUnusedAssets"), - updateCustomGame: (params: { - shop: GameShop; - objectId: string; - title: string; - iconUrl?: string; - logoImageUrl?: string; - libraryHeroImageUrl?: string; - originalIconPath?: string; - originalLogoPath?: string; - originalHeroPath?: string; - }) => ipcRenderer.invoke("updateCustomGame", params), - updateGameCustomAssets: (params: { - shop: GameShop; - objectId: string; - title: string; - customIconUrl?: string | null; - customLogoImageUrl?: string | null; - customHeroImageUrl?: string | null; - customOriginalIconPath?: string | null; - customOriginalLogoPath?: string | null; - customOriginalHeroPath?: string | null; - }) => ipcRenderer.invoke("updateGameCustomAssets", params), createGameShortcut: ( shop: GameShop, objectId: string, 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/declaration.d.ts b/src/renderer/src/declaration.d.ts index e6277888..2c942312 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -28,6 +28,7 @@ import type { LibraryGame, GameRunning, TorBoxUser, + AllDebridUser, Theme, Badge, Auth, @@ -112,43 +113,6 @@ declare global { objectId: string, title: string ) => Promise; - addCustomGameToLibrary: ( - title: string, - executablePath: string, - iconUrl?: string, - logoImageUrl?: string, - libraryHeroImageUrl?: string - ) => Promise; - updateCustomGame: (params: { - shop: GameShop; - objectId: string; - title: string; - iconUrl?: string; - logoImageUrl?: string; - libraryHeroImageUrl?: string; - originalIconPath?: string; - originalLogoPath?: string; - originalHeroPath?: string; - }) => Promise; - copyCustomGameAsset: ( - sourcePath: string, - assetType: "icon" | "logo" | "hero" - ) => Promise; - cleanupUnusedAssets: () => Promise<{ - deletedCount: number; - errors: string[]; - }>; - updateGameCustomAssets: (params: { - shop: GameShop; - objectId: string; - title: string; - customIconUrl?: string | null; - customLogoImageUrl?: string | null; - customHeroImageUrl?: string | null; - customOriginalIconPath?: string | null; - customOriginalLogoPath?: string | null; - customOriginalHeroPath?: string | null; - }) => Promise; createGameShortcut: ( shop: GameShop, objectId: string, @@ -212,6 +176,9 @@ declare global { ) => Promise; /* User preferences */ authenticateRealDebrid: (apiToken: string) => Promise; + authenticateAllDebrid: ( + apiKey: string + ) => Promise; authenticateTorBox: (apiToken: string) => Promise; getUserPreferences: () => Promise; updateUserPreferences: ( @@ -310,8 +277,6 @@ declare global { onCommonRedistProgress: ( cb: (value: { log: string; complete: boolean }) => void ) => () => Electron.IpcRenderer; - saveTempFile: (fileName: string, fileData: Uint8Array) => Promise; - deleteTempFile: (filePath: string) => Promise; platform: NodeJS.Platform; /* Auto update */ 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.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index d8d98583..307de108 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 @@ -229,7 +229,7 @@ export function HeroPanelActions() { {game.favorite ? : } - {userDetails && game.shop !== "custom" && ( + {userDetails && ( + } + 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..567523e6 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -33,17 +33,6 @@ export interface User { export interface Game { title: string; iconUrl: string | null; - libraryHeroImageUrl: string | null; - logoImageUrl: string | null; - customIconUrl?: string | null; - customLogoImageUrl?: string | null; - customHeroImageUrl?: string | null; - originalIconPath?: string | null; - originalLogoPath?: string | null; - originalHeroPath?: string | null; - customOriginalIconPath?: string | null; - customOriginalLogoPath?: string | null; - customOriginalHeroPath?: string | null; playTimeInMilliseconds: number; unsyncedDeltaPlayTimeInMilliseconds?: number; lastTimePlayed: Date | null; @@ -95,9 +84,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": {