mirror of
				https://github.com/hydralauncher/hydra.git
				synced 2025-03-09 15:40:26 +00:00 
			
		
		
		
	feat: removing crypto from level
This commit is contained in:
		
						commit
						0a37ce4cda
					
				
					 128 changed files with 1883 additions and 660 deletions
				
			
		
							
								
								
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -14,3 +14,5 @@ aria2/ | |||
| 
 | ||||
| # Sentry Config File | ||||
| .env.sentry-build-plugin | ||||
| 
 | ||||
| *storybook.log | ||||
|  |  | |||
|  | @ -88,9 +88,8 @@ | |||
|     "@swc/core": "^1.4.16", | ||||
|     "@types/auto-launch": "^5.0.5", | ||||
|     "@types/color": "^3.0.6", | ||||
|     "@types/folder-hash": "^4.0.4", | ||||
|     "@types/jsdom": "^21.1.7", | ||||
|     "@types/jsonwebtoken": "^9.0.7", | ||||
|     "@types/jsonwebtoken": "^9.0.8", | ||||
|     "@types/lodash-es": "^4.17.12", | ||||
|     "@types/node": "^20.12.7", | ||||
|     "@types/parse-torrent": "^5.8.7", | ||||
|  | @ -99,12 +98,12 @@ | |||
|     "@types/sound-play": "^1.1.3", | ||||
|     "@types/user-agents": "^1.0.4", | ||||
|     "@vitejs/plugin-react": "^4.2.1", | ||||
|     "electron": "^31.7.6", | ||||
|     "electron": "^31.7.7", | ||||
|     "electron-builder": "^25.1.8", | ||||
|     "electron-vite": "^2.0.0", | ||||
|     "electron-vite": "^2.3.0", | ||||
|     "eslint": "^8.56.0", | ||||
|     "eslint-plugin-jsx-a11y": "^6.10.2", | ||||
|     "eslint-plugin-react": "^7.37.2", | ||||
|     "eslint-plugin-react": "^7.37.4", | ||||
|     "eslint-plugin-react-hooks": "^4.6.0", | ||||
|     "husky": "^9.1.7", | ||||
|     "prettier": "^3.4.2", | ||||
|  |  | |||
|  | @ -11,11 +11,12 @@ class HttpDownloader: | |||
|             ) | ||||
|         ) | ||||
| 
 | ||||
|     def start_download(self, url: str, save_path: str, header: str): | ||||
|     def start_download(self, url: str, save_path: str, header: str, out: str = None): | ||||
|         if self.download: | ||||
|             self.aria2.resume([self.download]) | ||||
|         else: | ||||
|             downloads = self.aria2.add(url, options={"header": header, "dir": save_path}) | ||||
|             downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out}) | ||||
|              | ||||
|             self.download = downloads[0] | ||||
|      | ||||
|     def pause_download(self): | ||||
|  |  | |||
|  | @ -28,14 +28,14 @@ if start_download_payload: | |||
|         torrent_downloader = TorrentDownloader(torrent_session) | ||||
|         downloads[initial_download['game_id']] = torrent_downloader | ||||
|         try: | ||||
|             torrent_downloader.start_download(initial_download['url'], initial_download['save_path'], "") | ||||
|             torrent_downloader.start_download(initial_download['url'], initial_download['save_path']) | ||||
|         except Exception as e: | ||||
|             print("Error starting torrent download", e) | ||||
|     else: | ||||
|         http_downloader = HttpDownloader() | ||||
|         downloads[initial_download['game_id']] = http_downloader | ||||
|         try: | ||||
|             http_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header')) | ||||
|             http_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 http download", e) | ||||
| 
 | ||||
|  | @ -45,7 +45,7 @@ if start_seeding_payload: | |||
|         torrent_downloader = TorrentDownloader(torrent_session, lt.torrent_flags.upload_mode) | ||||
|         downloads[seed['game_id']] = torrent_downloader | ||||
|         try: | ||||
|             torrent_downloader.start_download(seed['url'], seed['save_path'], "") | ||||
|             torrent_downloader.start_download(seed['url'], seed['save_path']) | ||||
|         except Exception as e: | ||||
|             print("Error starting seeding", e) | ||||
| 
 | ||||
|  | @ -94,7 +94,7 @@ def seed_status(): | |||
| 
 | ||||
| @app.route("/healthcheck", methods=["GET"]) | ||||
| def healthcheck(): | ||||
|     return "", 200 | ||||
|     return "ok", 200 | ||||
| 
 | ||||
| @app.route("/process-list", methods=["GET"]) | ||||
| def process_list(): | ||||
|  | @ -140,18 +140,18 @@ def action(): | |||
| 
 | ||||
|         if url.startswith('magnet'): | ||||
|             if existing_downloader and isinstance(existing_downloader, TorrentDownloader): | ||||
|                 existing_downloader.start_download(url, data['save_path'], "") | ||||
|                 existing_downloader.start_download(url, data['save_path']) | ||||
|             else: | ||||
|                 torrent_downloader = TorrentDownloader(torrent_session) | ||||
|                 downloads[game_id] = torrent_downloader | ||||
|                 torrent_downloader.start_download(url, data['save_path'], "") | ||||
|                 torrent_downloader.start_download(url, data['save_path']) | ||||
|         else: | ||||
|             if existing_downloader and isinstance(existing_downloader, HttpDownloader): | ||||
|                 existing_downloader.start_download(url, data['save_path'], data.get('header')) | ||||
|                 existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out')) | ||||
|             else: | ||||
|                 http_downloader = HttpDownloader() | ||||
|                 downloads[game_id] = http_downloader | ||||
|                 http_downloader.start_download(url, data['save_path'], data.get('header')) | ||||
|                 http_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out')) | ||||
|          | ||||
|         downloading_game_id = game_id | ||||
| 
 | ||||
|  | @ -167,7 +167,7 @@ def action(): | |||
|     elif action == 'resume_seeding': | ||||
|         torrent_downloader = TorrentDownloader(torrent_session, lt.torrent_flags.upload_mode) | ||||
|         downloads[game_id] = torrent_downloader | ||||
|         torrent_downloader.start_download(data['url'], data['save_path'], "") | ||||
|         torrent_downloader.start_download(data['url'], data['save_path']) | ||||
|     elif action == 'pause_seeding': | ||||
|         downloader = downloads.get(game_id) | ||||
|         if downloader: | ||||
|  |  | |||
|  | @ -102,7 +102,7 @@ class TorrentDownloader: | |||
|             "http://bvarf.tracker.sh:2086/announce", | ||||
|         ] | ||||
| 
 | ||||
|     def start_download(self, magnet: str, save_path: str, header: str): | ||||
|     def start_download(self, magnet: str, save_path: str): | ||||
|         params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers, 'flags': self.flags} | ||||
|         self.torrent_handle = self.session.add_torrent(params) | ||||
|         self.torrent_handle.resume() | ||||
|  |  | |||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -236,13 +236,13 @@ | |||
|     "behavior": "السلوك", | ||||
|     "download_sources": "مصادر التنزيل", | ||||
|     "language": "اللغة", | ||||
|     "real_debrid_api_token": "رمز API", | ||||
|     "api_token": "رمز API", | ||||
|     "enable_real_debrid": "تفعيل Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، مقيدة فقط بسرعة الإنترنت لديك.", | ||||
|     "real_debrid_invalid_token": "رمز API غير صالح", | ||||
|     "real_debrid_api_token_hint": "يمكنك الحصول على رمز API الخاص بك <0>هنا</0>", | ||||
|     "debrid_invalid_token": "رمز API غير صالح", | ||||
|     "debrid_api_token_hint": "يمكنك الحصول على رمز API الخاص بك <0>هنا</0>", | ||||
|     "real_debrid_free_account_error": "الحساب \"{{username}}\" هو حساب مجاني. يرجى الاشتراك في Real-Debrid", | ||||
|     "real_debrid_linked_message": "تم ربط الحساب \"{{username}}\"", | ||||
|     "debrid_linked_message": "تم ربط الحساب \"{{username}}\"", | ||||
|     "save_changes": "حفظ التغييرات", | ||||
|     "changes_saved": "تم حفظ التغييرات بنجاح", | ||||
|     "download_sources_description": "سيقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL المصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.", | ||||
|  |  | |||
|  | @ -14,8 +14,10 @@ | |||
|     "paused": "{{title}} (Спынена)", | ||||
|     "downloading": "{{title}} ({{percentage}} - Сцягванне…)", | ||||
|     "filter": "Фільтар бібліятэкі", | ||||
|     "home": "Галоўная" | ||||
|     "home": "Галоўная", | ||||
|     "favorites": "Улюбленыя" | ||||
|   }, | ||||
| 
 | ||||
|   "header": { | ||||
|     "search": "Пошук", | ||||
|     "home": "Галоўная", | ||||
|  |  | |||
|  | @ -26,7 +26,8 @@ | |||
|     "game_has_no_executable": "Играта няма избран изпълним файл", | ||||
|     "sign_in": "Вписване", | ||||
|     "friends": "Приятели", | ||||
|     "need_help": "Имате нужда от помощ??" | ||||
|     "need_help": "Имате нужда от помощ??", | ||||
|     "favorites": "Любими игри" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Търсене", | ||||
|  | @ -230,13 +231,13 @@ | |||
|     "behavior": "Поведение", | ||||
|     "download_sources": "Източници за изтегляне", | ||||
|     "language": "Език", | ||||
|     "real_debrid_api_token": "API Токен", | ||||
|     "api_token": "API Токен", | ||||
|     "enable_real_debrid": "Включи Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid е неограничен даунлоудър, който ви позволява бързо да изтегляте файлове, ограничени само от скоростта на интернет..", | ||||
|     "real_debrid_invalid_token": "Невалиден API токен", | ||||
|     "real_debrid_api_token_hint": "Вземете своя API токен <0>тук</0>", | ||||
|     "debrid_invalid_token": "Невалиден API токен", | ||||
|     "debrid_api_token_hint": "Вземете своя API токен <0>тук</0>", | ||||
|     "real_debrid_free_account_error": "Акаунтът \"{{username}}\" е безплатен акаунт. Моля абонирай се за Real-Debrid", | ||||
|     "real_debrid_linked_message": "Акаунтът \"{{username}}\" е свързан", | ||||
|     "debrid_linked_message": "Акаунтът \"{{username}}\" е свързан", | ||||
|     "save_changes": "Запази промените", | ||||
|     "changes_saved": "Промените са успешно запазни", | ||||
|     "download_sources_description": "Hydra ще извлича връзките за изтегляне от тези източници. URL адресът на източника трябва да е директна връзка към .json файл, съдържащ връзките за изтегляне.", | ||||
|  |  | |||
|  | @ -20,10 +20,12 @@ | |||
|     "home": "Inici", | ||||
|     "queued": "{{title}} (En espera)", | ||||
|     "game_has_no_executable": "El joc encara no té un executable seleccionat", | ||||
|     "sign_in": "Entra" | ||||
|     "sign_in": "Entra", | ||||
|     "favorites": "Favorits" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Cerca jocs", | ||||
| 
 | ||||
|     "home": "Inici", | ||||
|     "catalogue": "Catàleg", | ||||
|     "downloads": "Baixades", | ||||
|  | @ -161,13 +163,13 @@ | |||
|     "behavior": "Comportament", | ||||
|     "download_sources": "Fonts de descàrrega", | ||||
|     "language": "Idioma", | ||||
|     "real_debrid_api_token": "Testimoni API", | ||||
|     "api_token": "Testimoni API", | ||||
|     "enable_real_debrid": "Activa el Real Debrid", | ||||
|     "real_debrid_description": "Real-Debrid és un programa de descàrrega sense restriccions que us permet descarregar fitxers a l'instant i al màxim de la vostra velocitat d'Internet.", | ||||
|     "real_debrid_invalid_token": "Invalida el testimoni de l'API", | ||||
|     "real_debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí</0>.", | ||||
|     "debrid_invalid_token": "Invalida el testimoni de l'API", | ||||
|     "debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí</0>.", | ||||
|     "real_debrid_free_account_error": "L'usuari \"{{username}}\" és un compte gratuït. Si us plau subscriu-te a Real-Debrid", | ||||
|     "real_debrid_linked_message": "Compte \"{{username}}\" vinculat", | ||||
|     "debrid_linked_message": "Compte \"{{username}}\" vinculat", | ||||
|     "save_changes": "Desa els canvis", | ||||
|     "changes_saved": "Els canvis s'han desat correctament", | ||||
|     "download_sources_description": "Hydra buscarà els enllaços de descàrrega d'aquestes fonts. L'URL d'origen ha de ser un enllaç directe a un fitxer .json que contingui els enllaços de descàrrega.", | ||||
|  |  | |||
|  | @ -26,7 +26,8 @@ | |||
|     "game_has_no_executable": "Hra nemá zvolen žádný spustitelný soubor", | ||||
|     "sign_in": "Přihlásit se", | ||||
|     "friends": "Přátelé", | ||||
|     "need_help": "Potřebujete pomoc?" | ||||
|     "need_help": "Potřebujete pomoc?", | ||||
|     "favorites": "Oblíbené" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Vyhledat hry", | ||||
|  | @ -214,13 +215,13 @@ | |||
|     "behavior": "Chování", | ||||
|     "download_sources": "Zdroje stahování", | ||||
|     "language": "Jazyk", | ||||
|     "real_debrid_api_token": "API Token", | ||||
|     "api_token": "API Token", | ||||
|     "enable_real_debrid": "Povolit Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid je neomezený správce stahování, který umožňuje stahovat soubory v nejvyšší rychlosti vašeho internetu.", | ||||
|     "real_debrid_invalid_token": "Neplatný API token", | ||||
|     "real_debrid_api_token_hint": "API token můžeš sehnat <0>zde</0>", | ||||
|     "debrid_invalid_token": "Neplatný API token", | ||||
|     "debrid_api_token_hint": "API token můžeš sehnat <0>zde</0>", | ||||
|     "real_debrid_free_account_error": "Účet \"{{username}}\" má základní úroveň. Prosím předplaťte si Real-Debrid", | ||||
|     "real_debrid_linked_message": "Účet \"{{username}}\" je propojen", | ||||
|     "debrid_linked_message": "Účet \"{{username}}\" je propojen", | ||||
|     "save_changes": "Uložit změny", | ||||
|     "changes_saved": "Změny úspěšně uloženy", | ||||
|     "download_sources_description": "Hydra bude odsud sbírat soubory. Zdrojový odkaz musí být .json soubor obsahující odkazy na soubory.", | ||||
|  |  | |||
|  | @ -24,10 +24,12 @@ | |||
|     "queued": "{{title}} (I køen)", | ||||
|     "game_has_no_executable": "Spillet har ikke nogen eksekverbar fil valgt", | ||||
|     "sign_in": "Log ind", | ||||
|     "friends": "Venner" | ||||
|     "friends": "Venner", | ||||
|     "favorites": "Favoritter" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Søg efter spil", | ||||
| 
 | ||||
|     "home": "Hjem", | ||||
|     "catalogue": "Katalog", | ||||
|     "downloads": "Downloads", | ||||
|  | @ -177,13 +179,13 @@ | |||
|     "behavior": "Opførsel", | ||||
|     "download_sources": "Download kilder", | ||||
|     "language": "Sprog", | ||||
|     "real_debrid_api_token": "API nøgle", | ||||
|     "api_token": "API nøgle", | ||||
|     "enable_real_debrid": "Slå Real-Debrid til", | ||||
|     "real_debrid_description": "Real-Debrid er en ubegrænset downloader der gør det muligt for dig at downloade filer med det samme og med den bedste udnyttelse af din internet hastighed.", | ||||
|     "real_debrid_invalid_token": "Ugyldig API nøgle", | ||||
|     "real_debrid_api_token_hint": "Du kan få din API nøgle <0>her</0>", | ||||
|     "debrid_invalid_token": "Ugyldig API nøgle", | ||||
|     "debrid_api_token_hint": "Du kan få din API nøgle <0>her</0>", | ||||
|     "real_debrid_free_account_error": "Brugeren \"{{username}}\" er en gratis bruger. Venligst abbonér på Real-Debrid", | ||||
|     "real_debrid_linked_message": "Brugeren \"{{username}}\" er forbundet", | ||||
|     "debrid_linked_message": "Brugeren \"{{username}}\" er forbundet", | ||||
|     "save_changes": "Gem ændringer", | ||||
|     "changes_saved": "Ændringer gemt successfuldt", | ||||
|     "download_sources_description": "Hydra vil hente download links fra disse kilder. Kilde URLen skal være et direkte link til en .json fil der indeholder download linkene.", | ||||
|  |  | |||
|  | @ -20,10 +20,12 @@ | |||
|     "home": "Home", | ||||
|     "queued": "{{title}} (In Warteschlange)", | ||||
|     "game_has_no_executable": "Spiel hat keine ausführbare Datei gewählt", | ||||
|     "sign_in": "Anmelden" | ||||
|     "sign_in": "Anmelden", | ||||
|     "favorites": "Favoriten" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Spiele suchen", | ||||
| 
 | ||||
|     "home": "Home", | ||||
|     "catalogue": "Katalog", | ||||
|     "downloads": "Downloads", | ||||
|  | @ -161,13 +163,13 @@ | |||
|     "behavior": "Verhalten", | ||||
|     "download_sources": "Download-Quellen", | ||||
|     "language": "Sprache", | ||||
|     "real_debrid_api_token": "API Token", | ||||
|     "api_token": "API Token", | ||||
|     "enable_real_debrid": "Real-Debrid aktivieren", | ||||
|     "real_debrid_description": "Real-Debrid ist ein unrestriktiver Downloader, der es dir ermöglicht Dateien sofort und mit deiner maximalen Internetgeschwindigkeit herunterzuladen.", | ||||
|     "real_debrid_invalid_token": "API token nicht gültig", | ||||
|     "real_debrid_api_token_hint": "<0>Hier</0> kannst du dir deinen API Token holen", | ||||
|     "debrid_invalid_token": "API token nicht gültig", | ||||
|     "debrid_api_token_hint": "<0>Hier</0> kannst du dir deinen API Token holen", | ||||
|     "real_debrid_free_account_error": "Das Konto \"{{username}}\" ist ein gratis account. Bitte abonniere Real-Debrid", | ||||
|     "real_debrid_linked_message": "Konto \"{{username}}\" verknüpft", | ||||
|     "debrid_linked_message": "Konto \"{{username}}\" verknüpft", | ||||
|     "save_changes": "Änderungen speichern", | ||||
|     "changes_saved": "Änderungen erfolgreich gespeichert", | ||||
|     "download_sources_description": "Hydra wird die Download-Links von diesen Quellen abrufen. Die Quell-URL muss ein direkter Link zu einer .json Datei, welche die Download-Links enthält, sein.", | ||||
|  |  | |||
|  | @ -26,7 +26,8 @@ | |||
|     "game_has_no_executable": "Game has no executable selected", | ||||
|     "sign_in": "Sign in", | ||||
|     "friends": "Friends", | ||||
|     "need_help": "Need help?" | ||||
|     "need_help": "Need help?", | ||||
|     "favorites": "Favorites" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Search games", | ||||
|  | @ -184,8 +185,13 @@ | |||
|     "reset_achievements_description": "This will reset all achievements for {{game}}", | ||||
|     "reset_achievements_title": "Are you sure?", | ||||
|     "reset_achievements_success": "Achievements successfully reset", | ||||
|     "reset_achievements_error": "Failed to reset achievements" | ||||
|     "reset_achievements_error": "Failed to reset achievements", | ||||
|     "download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.", | ||||
|     "download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.", | ||||
|     "download_error_not_cached_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.", | ||||
|     "download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available." | ||||
|   }, | ||||
| 
 | ||||
|   "activation": { | ||||
|     "title": "Activate Hydra", | ||||
|     "installation_id": "Installation ID:", | ||||
|  | @ -236,13 +242,13 @@ | |||
|     "behavior": "Behavior", | ||||
|     "download_sources": "Download sources", | ||||
|     "language": "Language", | ||||
|     "real_debrid_api_token": "API Token", | ||||
|     "api_token": "API Token", | ||||
|     "enable_real_debrid": "Enable Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid is an unrestricted downloader that allows you to quickly download files, only limited by your internet speed.", | ||||
|     "real_debrid_invalid_token": "Invalid API token", | ||||
|     "real_debrid_api_token_hint": "You can get your API token <0>here</0>", | ||||
|     "debrid_invalid_token": "Invalid API token", | ||||
|     "debrid_api_token_hint": "You can get your API token <0>here</0>", | ||||
|     "real_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to Real-Debrid", | ||||
|     "real_debrid_linked_message": "Account \"{{username}}\" linked", | ||||
|     "debrid_linked_message": "Account \"{{username}}\" linked", | ||||
|     "save_changes": "Save changes", | ||||
|     "changes_saved": "Changes successfully saved", | ||||
|     "download_sources_description": "Hydra will fetch the download links from these sources. The source URL must be a direct link to a .json file containing the download links.", | ||||
|  | @ -316,7 +322,11 @@ | |||
|     "delete_all_themes_description": "This will delete all your custom themes", | ||||
|     "delete_theme_description": "This will delete the theme {{theme}}", | ||||
|     "cancel": "Cancel", | ||||
|     "appearance": "Appearance" | ||||
|     "appearance": "Appearance", | ||||
|     "enable_torbox": "Enable Torbox", | ||||
|     "torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.", | ||||
|     "torbox_account_linked": "TorBox account linked", | ||||
|     "real_debrid_account_linked": "Real-Debrid account linked" | ||||
|   }, | ||||
|   "notifications": { | ||||
|     "download_complete": "Download complete", | ||||
|  |  | |||
|  | @ -26,7 +26,8 @@ | |||
|     "game_has_no_executable": "El juego no tiene un ejecutable seleccionado", | ||||
|     "sign_in": "Iniciar sesión", | ||||
|     "friends": "Amigos", | ||||
|     "need_help": "¿Necesitas ayuda?" | ||||
|     "need_help": "¿Necesitas ayuda?", | ||||
|     "favorites": "Favoritos" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Buscar juegos", | ||||
|  | @ -175,7 +176,16 @@ | |||
|     "backup_from": "Copia de seguridad de {{date}}", | ||||
|     "custom_backup_location_set": "Se configuró la carpeta de copia de seguridad", | ||||
|     "clear": "Limpiar", | ||||
|     "no_directory_selected": "No se seleccionó un directorio" | ||||
|     "no_directory_selected": "No se seleccionó un directorio", | ||||
|     "launch_options": "Opciones de Inicio", | ||||
|     "launch_options_description": "Los usuarios avanzados pueden introducir sus propias modificaciones de opciones de inicio (característica experimental)", | ||||
|     "launch_options_placeholder": "Sin parámetro específicado", | ||||
|     "no_write_permission": "No se puede descargar en este directorio. Presiona aquí para aprender más.", | ||||
|     "reset_achievements": "Reiniciar logros", | ||||
|     "reset_achievements_description": "Esto reiniciará todos los logros de {{game}}", | ||||
|     "reset_achievements_title": "¿Estás seguro?", | ||||
|     "reset_achievements_success": "Logros reiniciados exitosamente", | ||||
|     "reset_achievements_error": "Se produjo un error al reiniciar los logros" | ||||
|   }, | ||||
|   "activation": { | ||||
|     "title": "Activar Hydra", | ||||
|  | @ -227,13 +237,13 @@ | |||
|     "behavior": "Otros", | ||||
|     "download_sources": "Fuentes de descarga", | ||||
|     "language": "Idioma", | ||||
|     "real_debrid_api_token": "Token API", | ||||
|     "api_token": "Token API", | ||||
|     "enable_real_debrid": "Activar Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid es una forma de descargar sin restricciones archivos instantáneamente con la máxima velocidad de tu internet.", | ||||
|     "real_debrid_invalid_token": "Token de API inválido", | ||||
|     "real_debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>", | ||||
|     "debrid_invalid_token": "Token de API inválido", | ||||
|     "debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>", | ||||
|     "real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratuita. Por favor, suscríbete a Real-Debrid", | ||||
|     "real_debrid_linked_message": "Cuenta \"{{username}}\" vinculada", | ||||
|     "debrid_linked_message": "Cuenta \"{{username}}\" vinculada", | ||||
|     "save_changes": "Guardar cambios", | ||||
|     "changes_saved": "Ajustes guardados exitosamente", | ||||
|     "download_sources_description": "Hydra buscará los enlaces de descarga de estas fuentes. La URL de origen debe ser un enlace directo a un archivo .json que contenga los enlaces de descarga", | ||||
|  | @ -271,7 +281,23 @@ | |||
|     "launch_minimized": "Iniciar Hydra minimizado", | ||||
|     "disable_nsfw_alert": "Desactivar alerta NSFW", | ||||
|     "seed_after_download_complete": "Realizar seeding después de que se completa la descarga", | ||||
|     "show_hidden_achievement_description": "Ocultar descripción de logros ocultos antes de desbloquearlos" | ||||
|     "show_hidden_achievement_description": "Ocultar descripción de logros ocultos antes de desbloquearlos", | ||||
|     "account": "Cuenta", | ||||
|     "account_data_updated_successfully": "Datos de la cuenta actualizados", | ||||
|     "bill_sent_until": "Tú próxima factura se enviará el {{date}}", | ||||
|     "current_email": "Correo actual:", | ||||
|     "manage_subscription": "Gestionar suscripción", | ||||
|     "no_email_account": "No has configurado un correo aún", | ||||
|     "no_subscription": "Disfruta Hydra de la mejor manera", | ||||
|     "no_users_blocked": "No tienes usuarios bloqueados", | ||||
|     "notifications": "Notificaciones", | ||||
|     "renew_subscription": "Renovar Hydra Cloud", | ||||
|     "subscription_active_until": "Tu Hydra Cloud está activa hasta {{date}}", | ||||
|     "subscription_expired_at": "Tú suscripción expiró el {{date}}", | ||||
|     "subscription_renew_cancelled": "Está desactivada la renovación automática", | ||||
|     "subscription_renews_on": "Tú suscripción se renueva el {{date}}", | ||||
|     "update_email": "Actualizar correo", | ||||
|     "update_password": "Actualizar contraseña" | ||||
|   }, | ||||
|   "notifications": { | ||||
|     "download_complete": "Descarga completada", | ||||
|  |  | |||
|  | @ -25,7 +25,8 @@ | |||
|     "queued": "{{title}} (Järjekorras)", | ||||
|     "game_has_no_executable": "Mängul pole käivitusfaili valitud", | ||||
|     "sign_in": "Logi sisse", | ||||
|     "friends": "Sõbrad" | ||||
|     "friends": "Sõbrad", | ||||
|     "favorites": "Lemmikud" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Otsi mänge", | ||||
|  | @ -213,13 +214,13 @@ | |||
|     "behavior": "Käitumine", | ||||
|     "download_sources": "Allalaadimise allikad", | ||||
|     "language": "Keel", | ||||
|     "real_debrid_api_token": "API Võti", | ||||
|     "api_token": "API Võti", | ||||
|     "enable_real_debrid": "Luba Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid on piiranguteta allalaadija, mis võimaldab sul faile alla laadida koheselt ja sinu internetiühenduse parima kiirusega.", | ||||
|     "real_debrid_invalid_token": "Vigane API võti", | ||||
|     "real_debrid_api_token_hint": "Sa saad oma API võtme <0>siit</0>", | ||||
|     "debrid_invalid_token": "Vigane API võti", | ||||
|     "debrid_api_token_hint": "Sa saad oma API võtme <0>siit</0>", | ||||
|     "real_debrid_free_account_error": "Konto \"{{username}}\" on tasuta konto. Palun telli Real-Debrid", | ||||
|     "real_debrid_linked_message": "Konto \"{{username}}\" ühendatud", | ||||
|     "debrid_linked_message": "Konto \"{{username}}\" ühendatud", | ||||
|     "save_changes": "Salvesta muudatused", | ||||
|     "changes_saved": "Muudatused edukalt salvestatud", | ||||
|     "download_sources_description": "Hydra laeb allalaadimise lingid nendest allikatest. Allika URL peab olema otsene link .json failile, mis sisaldab allalaadimise linke.", | ||||
|  |  | |||
|  | @ -14,8 +14,10 @@ | |||
|     "paused": "{{title}} (متوقف شده)", | ||||
|     "downloading": "{{title}} ({{percentage}} - در حال دانلود…)", | ||||
|     "filter": "فیلتر کردن کتابخانه", | ||||
|     "home": "خانه" | ||||
|     "home": "خانه", | ||||
|     "favorites": "علاقهمندیها" | ||||
|   }, | ||||
| 
 | ||||
|   "header": { | ||||
|     "search": "جستجوی  بازیها", | ||||
|     "home": "خانه", | ||||
|  | @ -110,7 +112,7 @@ | |||
|     "general": "کلی", | ||||
|     "behavior": "رفتار", | ||||
|     "enable_real_debrid": "فعالسازی Real-Debrid", | ||||
|     "real_debrid_api_token_hint": "کلید API خود را از <ب0>اینجا</0> بگیرید.", | ||||
|     "debrid_api_token_hint": "کلید API خود را از <ب0>اینجا</0> بگیرید.", | ||||
|     "save_changes": "ذخیره تغییرات" | ||||
|   }, | ||||
|   "notifications": { | ||||
|  |  | |||
|  | @ -14,10 +14,12 @@ | |||
|     "paused": "{{title}} (En pause)", | ||||
|     "downloading": "{{title}} ({{percentage}} - Téléchargement en cours…)", | ||||
|     "filter": "Filtrer la bibliothèque", | ||||
|     "home": "Page d’accueil" | ||||
|     "home": "Page d’accueil", | ||||
|     "favorites": "Favoris" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Recherche", | ||||
| 
 | ||||
|     "catalogue": "Catalogue", | ||||
|     "downloads": "Téléchargements", | ||||
|     "search_results": "Résultats de la recherche", | ||||
|  |  | |||
|  | @ -14,10 +14,12 @@ | |||
|     "paused": "{{title}} (Szünet)", | ||||
|     "downloading": "{{title}} ({{percentage}} - Letöltés…)", | ||||
|     "filter": "Könyvtár szűrése", | ||||
|     "home": "Főoldal" | ||||
|     "home": "Főoldal", | ||||
|     "favorites": "Kedvenc játékok" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Keresés", | ||||
| 
 | ||||
|     "home": "Főoldal", | ||||
|     "catalogue": "Katalógus", | ||||
|     "downloads": "Letöltések", | ||||
|  |  | |||
|  | @ -20,10 +20,12 @@ | |||
|     "home": "Beranda", | ||||
|     "queued": "{{title}} (Antrian)", | ||||
|     "game_has_no_executable": "Game tidak punya file eksekusi yang dipilih", | ||||
|     "sign_in": "Masuk" | ||||
|     "sign_in": "Masuk", | ||||
|     "favorites": "Favorit" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Cari game", | ||||
| 
 | ||||
|     "home": "Beranda", | ||||
|     "catalogue": "Katalog", | ||||
|     "downloads": "Unduhan", | ||||
|  | @ -161,13 +163,13 @@ | |||
|     "behavior": "Perilaku", | ||||
|     "download_sources": "Sumber unduhan", | ||||
|     "language": "Bahasa", | ||||
|     "real_debrid_api_token": "Token API", | ||||
|     "api_token": "Token API", | ||||
|     "enable_real_debrid": "Aktifkan Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid adalah downloader tanpa batas yang memungkinkan kamu untuk mengunduh file dengan cepat dan pada kecepatan terbaik dari Internet kamu.", | ||||
|     "real_debrid_invalid_token": "Token API tidak valid", | ||||
|     "real_debrid_api_token_hint": "Kamu bisa dapatkan token API di <0>sini</0>", | ||||
|     "debrid_invalid_token": "Token API tidak valid", | ||||
|     "debrid_api_token_hint": "Kamu bisa dapatkan token API di <0>sini</0>", | ||||
|     "real_debrid_free_account_error": "Akun \"{{username}}\" adalah akun gratis. Silakan berlangganan Real-Debrid", | ||||
|     "real_debrid_linked_message": "Akun \"{{username}}\" terhubung", | ||||
|     "debrid_linked_message": "Akun \"{{username}}\" terhubung", | ||||
|     "save_changes": "Simpan perubahan", | ||||
|     "changes_saved": "Perubahan disimpan berhasil", | ||||
|     "download_sources_description": "Hydra akan mencari link unduhan dari sini. URL harus menuju file .json dengan link unduhan.", | ||||
|  |  | |||
|  | @ -14,10 +14,12 @@ | |||
|     "paused": "{{title}} (In pausa)", | ||||
|     "downloading": "{{title}} ({{percentage}} - Download…)", | ||||
|     "filter": "Filtra libreria", | ||||
|     "home": "Home" | ||||
|     "home": "Home", | ||||
|     "favorites": "Preferiti" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Cerca", | ||||
| 
 | ||||
|     "home": "Home", | ||||
|     "catalogue": "Catalogo", | ||||
|     "downloads": "Download", | ||||
|  | @ -118,7 +120,7 @@ | |||
|     "general": "Generale", | ||||
|     "behavior": "Comportamento", | ||||
|     "enable_real_debrid": "Abilita Real Debrid", | ||||
|     "real_debrid_api_token_hint": "Puoi trovare la tua chiave API <0>here</0>", | ||||
|     "debrid_api_token_hint": "Puoi trovare la tua chiave API <0>here</0>", | ||||
|     "save_changes": "Salva modifiche" | ||||
|   }, | ||||
|   "notifications": { | ||||
|  |  | |||
|  | @ -20,8 +20,10 @@ | |||
|     "home": "Басты бет", | ||||
|     "queued": "{{title}} (Кезекте)", | ||||
|     "game_has_no_executable": "Ойынды іске қосу файлы таңдалмаған", | ||||
|     "sign_in": "Кіру" | ||||
|     "sign_in": "Кіру", | ||||
|     "favorites": "Таңдаулылар" | ||||
|   }, | ||||
| 
 | ||||
|   "header": { | ||||
|     "search": "Іздеу", | ||||
|     "home": "Басты бет", | ||||
|  | @ -159,13 +161,13 @@ | |||
|     "behavior": "Мінез-құлық", | ||||
|     "download_sources": "Жүктеу көздері", | ||||
|     "language": "Тіл", | ||||
|     "real_debrid_api_token": "API Кілті", | ||||
|     "api_token": "API Кілті", | ||||
|     "enable_real_debrid": "Real-Debrid-ті қосу", | ||||
|     "real_debrid_description": "Real-Debrid - бұл шектеусіз жүктеуші, ол интернетте орналастырылған файлдарды тез жүктеуге немесе жеке желі арқылы кез келген блоктарды айналып өтіп, оларды бірден плеерге беруге мүмкіндік береді.", | ||||
|     "real_debrid_invalid_token": "Қате API кілті", | ||||
|     "real_debrid_api_token_hint": "API кілтін <0>осы жерден</0> алуға болады", | ||||
|     "debrid_invalid_token": "Қате API кілті", | ||||
|     "debrid_api_token_hint": "API кілтін <0>осы жерден</0> алуға болады", | ||||
|     "real_debrid_free_account_error": "\"{{username}}\" аккаунты жазылымға ие емес. Real-Debrid жазылымын алыңыз", | ||||
|     "real_debrid_linked_message": "\"{{username}}\" аккаунты байланған", | ||||
|     "debrid_linked_message": "\"{{username}}\" аккаунты байланған", | ||||
|     "save_changes": "Өзгерістерді сақтау", | ||||
|     "changes_saved": "Өзгерістер сәтті сақталды", | ||||
|     "download_sources_description": "Hydra осы көздерден жүктеу сілтемелерін алады. URL-да жүктеу сілтемелері бар .json файлына тікелей сілтеме болуы керек.", | ||||
|  |  | |||
|  | @ -14,8 +14,10 @@ | |||
|     "paused": "{{title}} (일시 정지됨)", | ||||
|     "downloading": "{{title}} ({{percentage}} - 다운로드 중…)", | ||||
|     "filter": "라이브러리 정렬", | ||||
|     "home": "홈" | ||||
|     "home": "홈", | ||||
|     "favorites": "즐겨찾기" | ||||
|   }, | ||||
| 
 | ||||
|   "header": { | ||||
|     "search": "게임 검색하기", | ||||
|     "home": "홈", | ||||
|  | @ -110,7 +112,7 @@ | |||
|     "general": "일반", | ||||
|     "behavior": "행동", | ||||
|     "enable_real_debrid": "Real-Debrid 활성화", | ||||
|     "real_debrid_api_token_hint": "API 키를 <0>이곳</0>에서 얻으세요.", | ||||
|     "debrid_api_token_hint": "API 키를 <0>이곳</0>에서 얻으세요.", | ||||
|     "save_changes": "변경 사항 저장" | ||||
|   }, | ||||
|   "notifications": { | ||||
|  |  | |||
|  | @ -24,10 +24,12 @@ | |||
|     "queued": "{{title}} (I køen)", | ||||
|     "game_has_no_executable": "Spillet har ikke noen kjørbar fil valgt", | ||||
|     "sign_in": "Logge inn", | ||||
|     "friends": "Venner" | ||||
|     "friends": "Venner", | ||||
|     "favorites": "Favoritter" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Søk efter spill", | ||||
| 
 | ||||
|     "home": "Hjem", | ||||
|     "catalogue": "Katalog", | ||||
|     "downloads": "Nedlastinger", | ||||
|  | @ -177,13 +179,13 @@ | |||
|     "behavior": "Oppførsel", | ||||
|     "download_sources": "Nedlastingskilder", | ||||
|     "language": "Språk", | ||||
|     "real_debrid_api_token": "API nøkkel", | ||||
|     "api_token": "API nøkkel", | ||||
|     "enable_real_debrid": "Slå på Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid er en ubegrenset nedlaster som gør det mulig for deg å laste ned filer med en gang og med den beste utnyttelsen av internethastigheten din.", | ||||
|     "real_debrid_invalid_token": "Ugyldig API nøkkel", | ||||
|     "real_debrid_api_token_hint": "Du kan få API nøkkelen din <0>her</0>", | ||||
|     "debrid_invalid_token": "Ugyldig API nøkkel", | ||||
|     "debrid_api_token_hint": "Du kan få API nøkkelen din <0>her</0>", | ||||
|     "real_debrid_free_account_error": "Brukeren \"{{username}}\" er en gratis bruker. Vennligst abboner på Real-Debrid", | ||||
|     "real_debrid_linked_message": "Brukeren \"{{username}}\" er forbunnet", | ||||
|     "debrid_linked_message": "Brukeren \"{{username}}\" er forbunnet", | ||||
|     "save_changes": "Lagre endringer", | ||||
|     "changes_saved": "Lagring av endringer vellykket", | ||||
|     "download_sources_description": "Hydra vil hente nedlastingslenker fra disse kildene. Kilde URLen skal være en direkte lenke til en .json fil som inneholder nedlastingslenkene.", | ||||
|  |  | |||
|  | @ -14,10 +14,12 @@ | |||
|     "paused": "{{title}} (Gepauzeerd)", | ||||
|     "downloading": "{{title}} ({{percentage}} - Downloading…)", | ||||
|     "filter": "Filter Bibliotheek", | ||||
|     "home": "Home" | ||||
|     "home": "Home", | ||||
|     "favorites": "Favorieten" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Zoek spellen", | ||||
| 
 | ||||
|     "home": "Home", | ||||
|     "catalogue": "Bibliotheek", | ||||
|     "downloads": "Downloads", | ||||
|  | @ -111,7 +113,7 @@ | |||
|     "general": "Algemeen", | ||||
|     "behavior": "Gedrag", | ||||
|     "enable_real_debrid": "Enable Real-Debrid", | ||||
|     "real_debrid_api_token_hint": "U kunt uw API-sleutel <0>hier</0> verkrijgen.", | ||||
|     "debrid_api_token_hint": "U kunt uw API-sleutel <0>hier</0> verkrijgen.", | ||||
|     "save_changes": "Wijzigingen opslaan" | ||||
|   }, | ||||
|   "notifications": { | ||||
|  |  | |||
|  | @ -14,10 +14,12 @@ | |||
|     "paused": "{{title}} (Zatrzymano)", | ||||
|     "downloading": "{{title}} ({{percentage}} - Pobieranie…)", | ||||
|     "filter": "Filtruj biblioteke", | ||||
|     "home": "Główna" | ||||
|     "home": "Główna", | ||||
|     "favorites": "Ulubione" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Szukaj", | ||||
| 
 | ||||
|     "home": "Główna", | ||||
|     "catalogue": "Katalog", | ||||
|     "downloads": "Pobrane", | ||||
|  | @ -119,7 +121,7 @@ | |||
|     "behavior": "Zachowania", | ||||
|     "language": "Język", | ||||
|     "enable_real_debrid": "Włącz Real-Debrid", | ||||
|     "real_debrid_api_token_hint": "Możesz uzyskać swój klucz API <0>tutaj</0>", | ||||
|     "debrid_api_token_hint": "Możesz uzyskać swój klucz API <0>tutaj</0>", | ||||
|     "save_changes": "Zapisz zmiany" | ||||
|   }, | ||||
|   "notifications": { | ||||
|  |  | |||
|  | @ -26,10 +26,12 @@ | |||
|     "game_has_no_executable": "Jogo não possui executável selecionado", | ||||
|     "sign_in": "Login", | ||||
|     "friends": "Amigos", | ||||
|     "need_help": "Precisa de ajuda?" | ||||
|     "need_help": "Precisa de ajuda?", | ||||
|     "favorites": "Favoritos" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Buscar jogos", | ||||
| 
 | ||||
|     "catalogue": "Catálogo", | ||||
|     "downloads": "Downloads", | ||||
|     "search_results": "Resultados da busca", | ||||
|  | @ -172,8 +174,14 @@ | |||
|     "reset_achievements_description": "Isso irá resetar todas as conquistas de {{game}}", | ||||
|     "reset_achievements_title": "Tem certeza?", | ||||
|     "reset_achievements_success": "Conquistas resetadas com sucesso", | ||||
|     "reset_achievements_error": "Falha ao resetar conquistas" | ||||
|     "reset_achievements_error": "Falha ao resetar conquistas", | ||||
|     "no_write_permission": "Não é possível baixar nesse diretório. Clique aqui para saber mais.", | ||||
|     "download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde a cota resetar.", | ||||
|     "download_error_real_debrid_account_not_authorized": "Sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique sua assinatura e tente novamente.", | ||||
|     "download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.", | ||||
|     "download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível." | ||||
|   }, | ||||
| 
 | ||||
|   "activation": { | ||||
|     "title": "Ativação", | ||||
|     "installation_id": "ID da instalação:", | ||||
|  | @ -224,13 +232,13 @@ | |||
|     "behavior": "Comportamento", | ||||
|     "download_sources": "Fontes de download", | ||||
|     "language": "Idioma", | ||||
|     "real_debrid_api_token": "Token de API", | ||||
|     "api_token": "Token de API", | ||||
|     "enable_real_debrid": "Habilitar Real-Debrid", | ||||
|     "real_debrid_api_token_hint": "Você pode obter seu token de API <0>aqui</0>", | ||||
|     "debrid_api_token_hint": "Você pode obter seu token de API <0>aqui</0>", | ||||
|     "real_debrid_description": "O Real-Debrid é um downloader sem restrições que permite baixar arquivos instantaneamente e com a melhor velocidade da sua Internet.", | ||||
|     "real_debrid_invalid_token": "Token de API inválido", | ||||
|     "debrid_invalid_token": "Token de API inválido", | ||||
|     "real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine a Real-Debrid", | ||||
|     "real_debrid_linked_message": "Conta \"{{username}}\" vinculada", | ||||
|     "debrid_linked_message": "Conta \"{{username}}\" vinculada", | ||||
|     "save_changes": "Salvar mudanças", | ||||
|     "changes_saved": "Ajustes salvos com sucesso", | ||||
|     "download_sources_description": "Hydra vai buscar links de download em todas as fontes habilitadas. A URL da fonte deve ser um link direto para um arquivo .json contendo uma lista de links.", | ||||
|  | @ -284,7 +292,11 @@ | |||
|     "become_subscriber": "Seja Hydra Cloud", | ||||
|     "subscription_renew_cancelled": "A renovação automática está desativada", | ||||
|     "subscription_renews_on": "Sua assinatura renova dia {{date}}", | ||||
|     "bill_sent_until": "Sua próxima cobrança será enviada até esse dia" | ||||
|     "bill_sent_until": "Sua próxima cobrança será enviada até esse dia", | ||||
|     "enable_torbox": "Habilitar Torbox", | ||||
|     "torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.", | ||||
|     "torbox_account_linked": "Conta do TorBox vinculada", | ||||
|     "real_debrid_account_linked": "Conta Real-Debrid associada" | ||||
|   }, | ||||
|   "notifications": { | ||||
|     "download_complete": "Download concluído", | ||||
|  |  | |||
|  | @ -25,10 +25,12 @@ | |||
|     "queued": "{{title}} (Na fila)", | ||||
|     "game_has_no_executable": "O jogo não tem um executável selecionado", | ||||
|     "sign_in": "Iniciar sessão", | ||||
|     "friends": "Amigos" | ||||
|     "friends": "Amigos", | ||||
|     "favorites": "Favoritos" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Procurar jogos", | ||||
| 
 | ||||
|     "catalogue": "Catálogo", | ||||
|     "downloads": "Transferências", | ||||
|     "search_results": "Resultados da pesquisa", | ||||
|  | @ -205,13 +207,13 @@ | |||
|     "behavior": "Comportamento", | ||||
|     "download_sources": "Fontes de transferência", | ||||
|     "language": "Idioma", | ||||
|     "real_debrid_api_token": "Token de API", | ||||
|     "api_token": "Token de API", | ||||
|     "enable_real_debrid": "Ativar Real-Debrid", | ||||
|     "real_debrid_api_token_hint": "Podes obter o teu token de API <0>aqui</0>", | ||||
|     "debrid_api_token_hint": "Podes obter o teu token de API <0>aqui</0>", | ||||
|     "real_debrid_description": "O Real-Debrid é um downloader sem restrições que permite descarregar ficheiros instantaneamente e com a melhor velocidade da tua Internet.", | ||||
|     "real_debrid_invalid_token": "Token de API inválido", | ||||
|     "debrid_invalid_token": "Token de API inválido", | ||||
|     "real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, subscreve o Real-Debrid", | ||||
|     "real_debrid_linked_message": "Conta \"{{username}}\" associada", | ||||
|     "debrid_linked_message": "Conta \"{{username}}\" associada", | ||||
|     "save_changes": "Guardar alterações", | ||||
|     "changes_saved": "Alterações guardadas com sucesso", | ||||
|     "download_sources_description": "O Hydra vai procurar links de download em todas as fontes ativadas. O URL da fonte deve ser um link direto para um ficheiro .json que contenha uma lista de links.", | ||||
|  |  | |||
|  | @ -14,10 +14,12 @@ | |||
|     "paused": "{{title}} (Pauzat)", | ||||
|     "downloading": "{{title}} ({{percentage}} - Se descarcă...)", | ||||
|     "filter": "Filtrează biblioteca", | ||||
|     "home": "Acasă" | ||||
|     "home": "Acasă", | ||||
|     "favorites": "Favorite" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Caută jocuri", | ||||
| 
 | ||||
|     "home": "Acasă", | ||||
|     "catalogue": "Catalog", | ||||
|     "downloads": "Descărcări", | ||||
|  | @ -124,13 +126,13 @@ | |||
|     "general": "General", | ||||
|     "behavior": "Comportament", | ||||
|     "language": "Limbă", | ||||
|     "real_debrid_api_token": "Token API", | ||||
|     "api_token": "Token API", | ||||
|     "enable_real_debrid": "Activează Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid este un descărcător fără restricții care îți permite să descarci fișiere instantaneu și la cea mai bună viteză a internetului tău.", | ||||
|     "real_debrid_invalid_token": "Token API invalid", | ||||
|     "real_debrid_api_token_hint": "Poți obține token-ul tău API <0>aici</0>", | ||||
|     "debrid_invalid_token": "Token API invalid", | ||||
|     "debrid_api_token_hint": "Poți obține token-ul tău API <0>aici</0>", | ||||
|     "real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid", | ||||
|     "real_debrid_linked_message": "Contul \"{{username}}\" a fost legat", | ||||
|     "debrid_linked_message": "Contul \"{{username}}\" a fost legat", | ||||
|     "save_changes": "Salvează modificările", | ||||
|     "changes_saved": "Modificările au fost salvate cu succes" | ||||
|   }, | ||||
|  |  | |||
|  | @ -26,7 +26,8 @@ | |||
|     "game_has_no_executable": "Файл запуска игры не выбран", | ||||
|     "sign_in": "Войти", | ||||
|     "friends": "Друзья", | ||||
|     "need_help": "Нужна помощь?" | ||||
|     "need_help": "Нужна помощь?", | ||||
|     "favorites": "Избранное" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Поиск", | ||||
|  | @ -237,13 +238,13 @@ | |||
|     "behavior": "Поведение", | ||||
|     "download_sources": "Источники загрузки", | ||||
|     "language": "Язык", | ||||
|     "real_debrid_api_token": "API Ключ", | ||||
|     "api_token": "API Ключ", | ||||
|     "enable_real_debrid": "Включить Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid - это неограниченный загрузчик, который позволяет быстро скачивать файлы, размещенные в Интернете, или мгновенно передавать их в плеер через частную сеть, позволяющую обходить любые блокировки.", | ||||
|     "real_debrid_invalid_token": "Неверный API ключ", | ||||
|     "real_debrid_api_token_hint": "API ключ можно получить <0>здесь</0>", | ||||
|     "debrid_invalid_token": "Неверный API ключ", | ||||
|     "debrid_api_token_hint": "API ключ можно получить <0>здесь</0>", | ||||
|     "real_debrid_free_account_error": "Аккаунт \"{{username}}\" - не имеет подписки. Пожалуйста, оформите подписку на Real-Debrid", | ||||
|     "real_debrid_linked_message": "Привязан аккаунт \"{{username}}\"", | ||||
|     "debrid_linked_message": "Привязан аккаунт \"{{username}}\"", | ||||
|     "save_changes": "Сохранить изменения", | ||||
|     "changes_saved": "Изменения успешно сохранены", | ||||
|     "download_sources_description": "Hydra будет получать ссылки на загрузки из этих источников. URL должна содержать прямую ссылку на .json-файл с ссылками для загрузок.", | ||||
|  |  | |||
|  | @ -26,7 +26,8 @@ | |||
|     "game_has_no_executable": "Oyun için bir çalıştırılabilir dosya seçilmedi", | ||||
|     "sign_in": "Giriş yap", | ||||
|     "friends": "Arkadaşlar", | ||||
|     "need_help": "Yardıma mı ihtiyacınız var?" | ||||
|     "need_help": "Yardıma mı ihtiyacınız var?", | ||||
|     "favorites": "Favoriler" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Oyunları ara", | ||||
|  | @ -236,13 +237,13 @@ | |||
|     "behavior": "Davranış", | ||||
|     "download_sources": "İndirme kaynakları", | ||||
|     "language": "Dil", | ||||
|     "real_debrid_api_token": "API Anahtarı", | ||||
|     "api_token": "API Anahtarı", | ||||
|     "enable_real_debrid": "Real-Debrid'i Etkinleştir", | ||||
|     "real_debrid_description": "Real-Debrid, yalnızca internet hızınızla sınırlı olarak hızlı dosya indirmenizi sağlayan sınırsız bir indirici.", | ||||
|     "real_debrid_invalid_token": "Geçersiz API anahtarı", | ||||
|     "real_debrid_api_token_hint": "API anahtarınızı <0>buradan</0> alabilirsiniz", | ||||
|     "debrid_invalid_token": "Geçersiz API anahtarı", | ||||
|     "debrid_api_token_hint": "API anahtarınızı <0>buradan</0> alabilirsiniz", | ||||
|     "real_debrid_free_account_error": "\"{{username}}\" hesabı ücretsiz bir hesaptır. Lütfen Real-Debrid abonesi olun", | ||||
|     "real_debrid_linked_message": "\"{{username}}\" hesabı bağlandı", | ||||
|     "debrid_linked_message": "\"{{username}}\" hesabı bağlandı", | ||||
|     "save_changes": "Değişiklikleri Kaydet", | ||||
|     "changes_saved": "Değişiklikler başarıyla kaydedildi", | ||||
|     "download_sources_description": "Hydra, indirme bağlantılarını bu kaynaklardan alacak. Kaynak URL, indirme bağlantılarını içeren bir .json dosyasına doğrudan bir bağlantı olmalıdır.", | ||||
|  |  | |||
|  | @ -20,10 +20,12 @@ | |||
|     "home": "Головна", | ||||
|     "game_has_no_executable": "Не було вибрано файл для запуску гри", | ||||
|     "queued": "{{title}} в черзі", | ||||
|     "sign_in": "Увійти" | ||||
|     "sign_in": "Увійти", | ||||
|     "favorites": "Улюблені" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "Пошук", | ||||
| 
 | ||||
|     "home": "Головна", | ||||
|     "catalogue": "Каталог", | ||||
|     "downloads": "Завантаження", | ||||
|  | @ -174,13 +176,13 @@ | |||
|     "import": "Імпортувати", | ||||
|     "insert_valid_json_url": "Вставте дійсний URL JSON-файлу", | ||||
|     "language": "Мова", | ||||
|     "real_debrid_api_token": "API-токен", | ||||
|     "real_debrid_api_token_hint": "API токен можливо отримати <0>тут</0>", | ||||
|     "api_token": "API-токен", | ||||
|     "debrid_api_token_hint": "API токен можливо отримати <0>тут</0>", | ||||
|     "real_debrid_api_token_label": "Real-Debrid API-токен", | ||||
|     "real_debrid_description": "Real-Debrid — це необмежений завантажувач, який дозволяє швидко завантажувати файли, розміщені в Інтернеті, або миттєво передавати їх у плеєр через приватну мережу, що дозволяє обходити будь-які блокування.", | ||||
|     "real_debrid_free_account_error": "Акаунт \"{{username}}\" - не має наявної підписки. Будь ласка, оформіть підписку на Real-Debrid", | ||||
|     "real_debrid_invalid_token": "Невірний API-токен", | ||||
|     "real_debrid_linked_message": "Акаунт \"{{username}}\" привязаний", | ||||
|     "debrid_invalid_token": "Невірний API-токен", | ||||
|     "debrid_linked_message": "Акаунт \"{{username}}\" привязаний", | ||||
|     "remove_download_source": "Видалити", | ||||
|     "removed_download_source": "Джерело завантажень було видалено", | ||||
|     "save_changes": "Зберегти зміни", | ||||
|  |  | |||
|  | @ -25,7 +25,8 @@ | |||
|     "queued": "{{title}} (已加入下载队列)", | ||||
|     "game_has_no_executable": "未选择游戏的可执行文件", | ||||
|     "sign_in": "登入", | ||||
|     "friends": "好友" | ||||
|     "friends": "好友", | ||||
|     "favorites": "收藏" | ||||
|   }, | ||||
|   "header": { | ||||
|     "search": "搜索游戏", | ||||
|  | @ -213,13 +214,13 @@ | |||
|     "behavior": "行为", | ||||
|     "download_sources": "下载源", | ||||
|     "language": "语言", | ||||
|     "real_debrid_api_token": "API 令牌", | ||||
|     "api_token": "API 令牌", | ||||
|     "enable_real_debrid": "启用 Real-Debrid", | ||||
|     "real_debrid_description": "Real-Debrid 是一个无限制的下载器,允许您以最快的互联网速度即时下载文件。", | ||||
|     "real_debrid_invalid_token": "无效的 API 令牌", | ||||
|     "real_debrid_api_token_hint": "您可以从<0>这里</0>获取API密钥.", | ||||
|     "debrid_invalid_token": "无效的 API 令牌", | ||||
|     "debrid_api_token_hint": "您可以从<0>这里</0>获取API密钥.", | ||||
|     "real_debrid_free_account_error": "账户 \"{{username}}\" 是免费账户。请订阅 Real-Debrid", | ||||
|     "real_debrid_linked_message": "账户 \"{{username}}\" 已链接", | ||||
|     "debrid_linked_message": "账户 \"{{username}}\" 已链接", | ||||
|     "save_changes": "保存更改", | ||||
|     "changes_saved": "更改已成功保存", | ||||
|     "download_sources_description": "Hydra 将从这些源获取下载链接。源 URL 必须是直接链接到包含下载链接的 .json 文件。", | ||||
|  |  | |||
|  | @ -1,6 +1,5 @@ | |||
| import { registerEvent } from "../register-event"; | ||||
| import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services"; | ||||
| import { PythonRPC } from "@main/services/python-rpc"; | ||||
| import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; | ||||
| 
 | ||||
| const signOut = async (_event: Electron.IpcMainInvokeEvent) => { | ||||
|  | @ -25,9 +24,6 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => { | |||
|   /* Cancels any ongoing downloads */ | ||||
|   DownloadManager.cancelDownload(); | ||||
| 
 | ||||
|   /* Disconnects libtorrent */ | ||||
|   PythonRPC.kill(); | ||||
| 
 | ||||
|   HydraApi.handleSignOut(); | ||||
| 
 | ||||
|   await Promise.all([ | ||||
|  |  | |||
|  | @ -1,47 +1,8 @@ | |||
| import type { AppUpdaterEvent } from "@types"; | ||||
| import { registerEvent } from "../register-event"; | ||||
| import updater, { UpdateInfo } from "electron-updater"; | ||||
| import { WindowManager } from "@main/services"; | ||||
| import { app } from "electron"; | ||||
| import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications"; | ||||
| 
 | ||||
| const { autoUpdater } = updater; | ||||
| 
 | ||||
| const sendEvent = (event: AppUpdaterEvent) => { | ||||
|   WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event); | ||||
| }; | ||||
| 
 | ||||
| const sendEventsForDebug = false; | ||||
| 
 | ||||
| const isAutoInstallAvailable = | ||||
|   process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null; | ||||
| 
 | ||||
| const mockValuesForDebug = () => { | ||||
|   sendEvent({ type: "update-available", info: { version: "1.3.0" } }); | ||||
|   sendEvent({ type: "update-downloaded" }); | ||||
| }; | ||||
| 
 | ||||
| const newVersionInfo = { version: "" }; | ||||
| import { UpdateManager } from "@main/services/update-manager"; | ||||
| 
 | ||||
| const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => { | ||||
|   autoUpdater | ||||
|     .once("update-available", (info: UpdateInfo) => { | ||||
|       sendEvent({ type: "update-available", info }); | ||||
|       newVersionInfo.version = info.version; | ||||
|     }) | ||||
|     .once("update-downloaded", () => { | ||||
|       sendEvent({ type: "update-downloaded" }); | ||||
|       publishNotificationUpdateReadyToInstall(newVersionInfo.version); | ||||
|     }); | ||||
| 
 | ||||
|   if (app.isPackaged) { | ||||
|     autoUpdater.autoDownload = isAutoInstallAvailable; | ||||
|     autoUpdater.checkForUpdates(); | ||||
|   } else if (sendEventsForDebug) { | ||||
|     mockValuesForDebug(); | ||||
|   } | ||||
| 
 | ||||
|   return isAutoInstallAvailable; | ||||
|   return UpdateManager.checkForUpdates(); | ||||
| }; | ||||
| 
 | ||||
| registerEvent("checkForUpdates", checkForUpdates); | ||||
|  |  | |||
|  | @ -1,15 +1,21 @@ | |||
| import fs from "node:fs"; | ||||
| import path from "node:path"; | ||||
| 
 | ||||
| import { registerEvent } from "../register-event"; | ||||
| 
 | ||||
| const checkFolderWritePermission = async ( | ||||
|   _event: Electron.IpcMainInvokeEvent, | ||||
|   path: string | ||||
| ) => | ||||
|   new Promise((resolve) => { | ||||
|     fs.access(path, fs.constants.W_OK, (err) => { | ||||
|       resolve(!err); | ||||
|     }); | ||||
|   }); | ||||
|   testPath: string | ||||
| ) => { | ||||
|   const testFilePath = path.join(testPath, ".hydra-write-test"); | ||||
| 
 | ||||
|   try { | ||||
|     fs.writeFileSync(testFilePath, ""); | ||||
|     fs.rmSync(testFilePath); | ||||
|     return true; | ||||
|   } catch (err) { | ||||
|     return false; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| registerEvent("checkFolderWritePermission", checkFolderWritePermission); | ||||
|  |  | |||
|  | @ -3,15 +3,14 @@ import { db, levelKeys } from "@main/level"; | |||
| import type { UserPreferences } from "@types"; | ||||
| 
 | ||||
| export const getDownloadsPath = async () => { | ||||
|   const userPreferences = await db.get<string, UserPreferences>( | ||||
|   const userPreferences = await db.get<string, UserPreferences | null>( | ||||
|     levelKeys.userPreferences, | ||||
|     { | ||||
|       valueEncoding: "json", | ||||
|     } | ||||
|   ); | ||||
| 
 | ||||
|   if (userPreferences && userPreferences.downloadsPath) | ||||
|     return userPreferences.downloadsPath; | ||||
|   if (userPreferences?.downloadsPath) return userPreferences.downloadsPath; | ||||
| 
 | ||||
|   return defaultDownloadsPath; | ||||
| }; | ||||
|  |  | |||
							
								
								
									
										7
									
								
								src/main/events/helpers/parse-launch-options.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/main/events/helpers/parse-launch-options.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| export const parseLaunchOptions = (params?: string | null): string[] => { | ||||
|   if (!params) { | ||||
|     return []; | ||||
|   } | ||||
| 
 | ||||
|   return params.split(" "); | ||||
| }; | ||||
|  | @ -13,6 +13,8 @@ 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-game-to-favorites"; | ||||
| import "./library/remove-game-from-favorites"; | ||||
| import "./library/create-game-shortcut"; | ||||
| import "./library/close-game"; | ||||
| import "./library/delete-game-folder"; | ||||
|  | @ -46,6 +48,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-torbox"; | ||||
| import "./download-sources/put-download-source"; | ||||
| import "./auth/sign-out"; | ||||
| import "./auth/open-auth-window"; | ||||
|  |  | |||
							
								
								
									
										25
									
								
								src/main/events/library/add-game-to-favorites.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/main/events/library/add-game-to-favorites.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| import { registerEvent } from "../register-event"; | ||||
| import { gamesSublevel, levelKeys } from "@main/level"; | ||||
| import type { GameShop } from "@types"; | ||||
| 
 | ||||
| const addGameToFavorites = async ( | ||||
|   _event: Electron.IpcMainInvokeEvent, | ||||
|   shop: GameShop, | ||||
|   objectId: string | ||||
| ) => { | ||||
|   const gameKey = levelKeys.game(shop, objectId); | ||||
| 
 | ||||
|   const game = await gamesSublevel.get(gameKey); | ||||
|   if (!game) return; | ||||
| 
 | ||||
|   try { | ||||
|     await gamesSublevel.put(gameKey, { | ||||
|       ...game, | ||||
|       favorite: true, | ||||
|     }); | ||||
|   } catch (error) { | ||||
|     throw new Error(`Failed to update game favorite status: ${error}`); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| registerEvent("addGameToFavorites", addGameToFavorites); | ||||
|  | @ -46,9 +46,9 @@ const addGameToLibrary = async ( | |||
| 
 | ||||
|     await gamesSublevel.put(levelKeys.game(shop, objectId), game); | ||||
| 
 | ||||
|     updateLocalUnlockedAchivements(game!); | ||||
|     updateLocalUnlockedAchivements(game); | ||||
| 
 | ||||
|     createGame(game!).catch(() => {}); | ||||
|     createGame(game).catch(() => {}); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,8 +1,10 @@ | |||
| import { registerEvent } from "../register-event"; | ||||
| import { shell } from "electron"; | ||||
| import { spawn } from "child_process"; | ||||
| import { parseExecutablePath } from "../helpers/parse-executable-path"; | ||||
| import { gamesSublevel, levelKeys } from "@main/level"; | ||||
| import { GameShop } from "@types"; | ||||
| import { parseLaunchOptions } from "../helpers/parse-launch-options"; | ||||
| 
 | ||||
| const openGame = async ( | ||||
|   _event: Electron.IpcMainInvokeEvent, | ||||
|  | @ -11,8 +13,8 @@ const openGame = async ( | |||
|   executablePath: string, | ||||
|   launchOptions?: string | null | ||||
| ) => { | ||||
|   // TODO: revisit this for launchOptions
 | ||||
|   const parsedPath = parseExecutablePath(executablePath); | ||||
|   const parsedParams = parseLaunchOptions(launchOptions); | ||||
| 
 | ||||
|   const gameKey = levelKeys.game(shop, objectId); | ||||
| 
 | ||||
|  | @ -26,7 +28,12 @@ const openGame = async ( | |||
|     launchOptions, | ||||
|   }); | ||||
| 
 | ||||
|   shell.openPath(parsedPath); | ||||
|   if (parsedParams.length === 0) { | ||||
|     shell.openPath(parsedPath); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   spawn(parsedPath, parsedParams, { shell: false, detached: true }); | ||||
| }; | ||||
| 
 | ||||
| registerEvent("openGame", openGame); | ||||
|  |  | |||
							
								
								
									
										25
									
								
								src/main/events/library/remove-game-from-favorites.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/main/events/library/remove-game-from-favorites.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| import { registerEvent } from "../register-event"; | ||||
| import { gamesSublevel, levelKeys } from "@main/level"; | ||||
| import type { GameShop } from "@types"; | ||||
| 
 | ||||
| const removeGameFromFavorites = async ( | ||||
|   _event: Electron.IpcMainInvokeEvent, | ||||
|   shop: GameShop, | ||||
|   objectId: string | ||||
| ) => { | ||||
|   const gameKey = levelKeys.game(shop, objectId); | ||||
| 
 | ||||
|   const game = await gamesSublevel.get(gameKey); | ||||
|   if (!game) return; | ||||
| 
 | ||||
|   try { | ||||
|     await gamesSublevel.put(gameKey, { | ||||
|       ...game, | ||||
|       favorite: false, | ||||
|     }); | ||||
|   } catch (error) { | ||||
|     throw new Error(`Failed to update game favorite status: ${error}`); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| registerEvent("removeGameFromFavorites", removeGameFromFavorites); | ||||
|  | @ -10,7 +10,7 @@ const publishNewRepacksNotification = async ( | |||
| ) => { | ||||
|   if (newRepacksCount < 1) return; | ||||
| 
 | ||||
|   const userPreferences = await db.get<string, UserPreferences>( | ||||
|   const userPreferences = await db.get<string, UserPreferences | null>( | ||||
|     levelKeys.userPreferences, | ||||
|     { | ||||
|       valueEncoding: "json", | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ import { omit } from "lodash-es"; | |||
| import axios from "axios"; | ||||
| import { fileTypeFromFile } from "file-type"; | ||||
| 
 | ||||
| const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { | ||||
| export const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { | ||||
|   return HydraApi.patch<UserProfile>("/profile", updateProfile); | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,11 +1,12 @@ | |||
| import { registerEvent } from "../register-event"; | ||||
| import type { Download, StartGameDownloadPayload } from "@types"; | ||||
| import { DownloadManager, HydraApi } from "@main/services"; | ||||
| import { DownloadManager, HydraApi, logger } from "@main/services"; | ||||
| 
 | ||||
| import { steamGamesWorker } from "@main/workers"; | ||||
| import { createGame } from "@main/services/library-sync"; | ||||
| import { steamUrlBuilder } from "@shared"; | ||||
| import { Downloader, DownloadError, steamUrlBuilder } from "@shared"; | ||||
| import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; | ||||
| import { AxiosError } from "axios"; | ||||
| 
 | ||||
| const startGameDownload = async ( | ||||
|   _event: Electron.IpcMainInvokeEvent, | ||||
|  | @ -75,23 +76,55 @@ const startGameDownload = async ( | |||
|     queued: true, | ||||
|   }; | ||||
| 
 | ||||
|   await downloadsSublevel.put(gameKey, download); | ||||
|   try { | ||||
|     await DownloadManager.startDownload(download).then(() => { | ||||
|       return downloadsSublevel.put(gameKey, download); | ||||
|     }); | ||||
| 
 | ||||
|   await DownloadManager.startDownload(download); | ||||
|     const updatedGame = await gamesSublevel.get(gameKey); | ||||
| 
 | ||||
|   const updatedGame = await gamesSublevel.get(gameKey); | ||||
|     await Promise.all([ | ||||
|       createGame(updatedGame!).catch(() => {}), | ||||
|       HydraApi.post( | ||||
|         "/games/download", | ||||
|         { | ||||
|           objectId, | ||||
|           shop, | ||||
|         }, | ||||
|         { needsAuth: false } | ||||
|       ).catch(() => {}), | ||||
|     ]); | ||||
| 
 | ||||
|   await Promise.all([ | ||||
|     createGame(updatedGame!).catch(() => {}), | ||||
|     HydraApi.post( | ||||
|       "/games/download", | ||||
|       { | ||||
|         objectId, | ||||
|         shop, | ||||
|       }, | ||||
|       { needsAuth: false } | ||||
|     ).catch(() => {}), | ||||
|   ]); | ||||
|     return { ok: true }; | ||||
|   } catch (err: unknown) { | ||||
|     logger.error("Failed to start download", err); | ||||
| 
 | ||||
|     if (err instanceof AxiosError) { | ||||
|       if (err.response?.status === 429 && downloader === Downloader.Gofile) { | ||||
|         return { ok: false, error: DownloadError.GofileQuotaExceeded }; | ||||
|       } | ||||
| 
 | ||||
|       if ( | ||||
|         err.response?.status === 403 && | ||||
|         downloader === Downloader.RealDebrid | ||||
|       ) { | ||||
|         return { | ||||
|           ok: false, | ||||
|           error: DownloadError.RealDebridAccountNotAuthorized, | ||||
|         }; | ||||
|       } | ||||
| 
 | ||||
|       if (downloader === Downloader.TorBox) { | ||||
|         return { ok: false, error: err.response?.data?.detail }; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (err instanceof Error) { | ||||
|       return { ok: false, error: err.message }; | ||||
|     } | ||||
| 
 | ||||
|     return { ok: false }; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| registerEvent("startGameDownload", startGameDownload); | ||||
|  |  | |||
							
								
								
									
										14
									
								
								src/main/events/user-preferences/authenticate-torbox.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/main/events/user-preferences/authenticate-torbox.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| import { registerEvent } from "../register-event"; | ||||
| import { TorBoxClient } from "@main/services/download/torbox"; | ||||
| 
 | ||||
| const authenticateTorBox = async ( | ||||
|   _event: Electron.IpcMainInvokeEvent, | ||||
|   apiToken: string | ||||
| ) => { | ||||
|   TorBoxClient.authorize(apiToken); | ||||
| 
 | ||||
|   const user = await TorBoxClient.getUser(); | ||||
|   return user; | ||||
| }; | ||||
| 
 | ||||
| registerEvent("authenticateTorBox", authenticateTorBox); | ||||
|  | @ -3,12 +3,13 @@ import { registerEvent } from "../register-event"; | |||
| import type { UserPreferences } from "@types"; | ||||
| import i18next from "i18next"; | ||||
| import { db, levelKeys } from "@main/level"; | ||||
| import { patchUserProfile } from "../profile/update-profile"; | ||||
| 
 | ||||
| const updateUserPreferences = async ( | ||||
|   _event: Electron.IpcMainInvokeEvent, | ||||
|   preferences: Partial<UserPreferences> | ||||
| ) => { | ||||
|   const userPreferences = await db.get<string, UserPreferences>( | ||||
|   const userPreferences = await db.get<string, UserPreferences | null>( | ||||
|     levelKeys.userPreferences, | ||||
|     { valueEncoding: "json" } | ||||
|   ); | ||||
|  | @ -19,6 +20,11 @@ const updateUserPreferences = async ( | |||
|     }); | ||||
| 
 | ||||
|     i18next.changeLanguage(preferences.language); | ||||
|     patchUserProfile({ language: preferences.language }).catch(() => {}); | ||||
|   } | ||||
| 
 | ||||
|   if (!preferences.downloadsPath) { | ||||
|     preferences.downloadsPath = null; | ||||
|   } | ||||
| 
 | ||||
|   await db.put<string, UserPreferences>( | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ const getComparedUnlockedAchievements = async ( | |||
|   shop: GameShop, | ||||
|   userId: string | ||||
| ) => { | ||||
|   const userPreferences = await db.get<string, UserPreferences>( | ||||
|   const userPreferences = await db.get<string, UserPreferences | null>( | ||||
|     levelKeys.userPreferences, | ||||
|     { | ||||
|       valueEncoding: "json", | ||||
|  | @ -25,7 +25,7 @@ const getComparedUnlockedAchievements = async ( | |||
|     { | ||||
|       shop, | ||||
|       objectId, | ||||
|       language: userPreferences?.language || "en", | ||||
|       language: userPreferences?.language ?? "en", | ||||
|     } | ||||
|   ).then((achievements) => { | ||||
|     const sortedAchievements = achievements.achievements | ||||
|  |  | |||
|  | @ -12,7 +12,7 @@ export const getUnlockedAchievements = async ( | |||
|     levelKeys.game(shop, objectId) | ||||
|   ); | ||||
| 
 | ||||
|   const userPreferences = await db.get<string, UserPreferences>( | ||||
|   const userPreferences = await db.get<string, UserPreferences | null>( | ||||
|     levelKeys.userPreferences, | ||||
|     { | ||||
|       valueEncoding: "json", | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ import resources from "@locales"; | |||
| import { PythonRPC } from "./services/python-rpc"; | ||||
| import { Aria2 } from "./services/aria2"; | ||||
| import { db, levelKeys } from "./level"; | ||||
| import { loadState } from "./main"; | ||||
| 
 | ||||
| const { autoUpdater } = updater; | ||||
| 
 | ||||
|  | @ -57,7 +58,7 @@ app.whenReady().then(async () => { | |||
|     return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); | ||||
|   }); | ||||
| 
 | ||||
|   await import("./main"); | ||||
|   await loadState(); | ||||
| 
 | ||||
|   const language = await db.get<string, string>(levelKeys.language, { | ||||
|     valueEncoding: "utf-8", | ||||
|  |  | |||
|  | @ -1,3 +1,2 @@ | |||
| export { db } from "./level"; | ||||
| 
 | ||||
| export * from "./sublevels"; | ||||
|  |  | |||
|  | @ -2,5 +2,4 @@ export * from "./downloads"; | |||
| export * from "./games"; | ||||
| export * from "./game-shop-cache"; | ||||
| export * from "./game-achievements"; | ||||
| 
 | ||||
| export * from "./keys"; | ||||
|  |  | |||
|  | @ -14,9 +14,20 @@ import { | |||
| } from "./level"; | ||||
| import { Auth, User, type UserPreferences } from "@types"; | ||||
| import { knexClient } from "./knex-client"; | ||||
| import { TorBoxClient } from "./services/download/torbox"; | ||||
| 
 | ||||
| const loadState = async (userPreferences: UserPreferences | null) => { | ||||
|   import("./events"); | ||||
| export const loadState = async () => { | ||||
|   const userPreferences = await migrateFromSqlite().then(async () => { | ||||
|     await db.put<string, boolean>(levelKeys.sqliteMigrationDone, true, { | ||||
|       valueEncoding: "json", | ||||
|     }); | ||||
| 
 | ||||
|     return db.get<string, UserPreferences | null>(levelKeys.userPreferences, { | ||||
|       valueEncoding: "json", | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   await import("./events"); | ||||
| 
 | ||||
|   Aria2.spawn(); | ||||
| 
 | ||||
|  | @ -24,6 +35,10 @@ const loadState = async (userPreferences: UserPreferences | null) => { | |||
|     RealDebridClient.authorize(userPreferences.realDebridApiToken); | ||||
|   } | ||||
| 
 | ||||
|   if (userPreferences?.torBoxApiToken) { | ||||
|     TorBoxClient.authorize(userPreferences.torBoxApiToken); | ||||
|   } | ||||
| 
 | ||||
|   Ludusavi.addManifestToLudusaviConfig(); | ||||
| 
 | ||||
|   HydraApi.setupApi().then(() => { | ||||
|  | @ -96,22 +111,27 @@ const migrateFromSqlite = async () => { | |||
|       if (userPreferences.length > 0) { | ||||
|         const { realDebridApiToken, ...rest } = userPreferences[0]; | ||||
| 
 | ||||
|         await db.put(levelKeys.userPreferences, { | ||||
|           ...rest, | ||||
|           realDebridApiToken, | ||||
|           preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1, | ||||
|           runAtStartup: rest.runAtStartup === 1, | ||||
|           startMinimized: rest.startMinimized === 1, | ||||
|           disableNsfwAlert: rest.disableNsfwAlert === 1, | ||||
|           seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1, | ||||
|           showHiddenAchievementsDescription: | ||||
|             rest.showHiddenAchievementsDescription === 1, | ||||
|           downloadNotificationsEnabled: rest.downloadNotificationsEnabled === 1, | ||||
|           repackUpdatesNotificationsEnabled: | ||||
|             rest.repackUpdatesNotificationsEnabled === 1, | ||||
|           achievementNotificationsEnabled: | ||||
|             rest.achievementNotificationsEnabled === 1, | ||||
|         }); | ||||
|         await db.put<string, UserPreferences>( | ||||
|           levelKeys.userPreferences, | ||||
|           { | ||||
|             ...rest, | ||||
|             realDebridApiToken, | ||||
|             preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1, | ||||
|             runAtStartup: rest.runAtStartup === 1, | ||||
|             startMinimized: rest.startMinimized === 1, | ||||
|             disableNsfwAlert: rest.disableNsfwAlert === 1, | ||||
|             seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1, | ||||
|             showHiddenAchievementsDescription: | ||||
|               rest.showHiddenAchievementsDescription === 1, | ||||
|             downloadNotificationsEnabled: | ||||
|               rest.downloadNotificationsEnabled === 1, | ||||
|             repackUpdatesNotificationsEnabled: | ||||
|               rest.repackUpdatesNotificationsEnabled === 1, | ||||
|             achievementNotificationsEnabled: | ||||
|               rest.achievementNotificationsEnabled === 1, | ||||
|           }, | ||||
|           { valueEncoding: "json" } | ||||
|         ); | ||||
| 
 | ||||
|         if (rest.language) { | ||||
|           await db.put(levelKeys.language, rest.language); | ||||
|  | @ -182,15 +202,3 @@ const migrateFromSqlite = async () => { | |||
|     migrateUser, | ||||
|   ]); | ||||
| }; | ||||
| 
 | ||||
| migrateFromSqlite().then(async () => { | ||||
|   await db.put<string, boolean>(levelKeys.sqliteMigrationDone, true, { | ||||
|     valueEncoding: "json", | ||||
|   }); | ||||
| 
 | ||||
|   db.get<string, UserPreferences>(levelKeys.userPreferences, { | ||||
|     valueEncoding: "json", | ||||
|   }).then((userPreferences) => { | ||||
|     loadState(userPreferences); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -141,7 +141,7 @@ const processAchievementFileDiff = async ( | |||
| export class AchievementWatcherManager { | ||||
|   private static hasFinishedMergingWithRemote = false; | ||||
| 
 | ||||
|   public static watchAchievements = () => { | ||||
|   public static watchAchievements() { | ||||
|     if (!this.hasFinishedMergingWithRemote) return; | ||||
| 
 | ||||
|     if (process.platform === "win32") { | ||||
|  | @ -149,12 +149,12 @@ export class AchievementWatcherManager { | |||
|     } | ||||
| 
 | ||||
|     return watchAchievementsWithWine(); | ||||
|   }; | ||||
|   } | ||||
| 
 | ||||
|   private static preProcessGameAchievementFiles = ( | ||||
|   private static preProcessGameAchievementFiles( | ||||
|     game: Game, | ||||
|     gameAchievementFiles: AchievementFile[] | ||||
|   ) => { | ||||
|   ) { | ||||
|     const unlockedAchievements: UnlockedAchievement[] = []; | ||||
|     for (const achievementFile of gameAchievementFiles) { | ||||
|       const parsedAchievements = parseAchievementFile( | ||||
|  | @ -182,7 +182,7 @@ export class AchievementWatcherManager { | |||
|     } | ||||
| 
 | ||||
|     return mergeAchievements(game, unlockedAchievements, false); | ||||
|   }; | ||||
|   } | ||||
| 
 | ||||
|   private static preSearchAchievementsWindows = async () => { | ||||
|     const games = await gamesSublevel | ||||
|  | @ -230,7 +230,7 @@ export class AchievementWatcherManager { | |||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   public static preSearchAchievements = async () => { | ||||
|   public static async preSearchAchievements() { | ||||
|     try { | ||||
|       const newAchievementsCount = | ||||
|         process.platform === "win32" | ||||
|  | @ -256,5 +256,5 @@ export class AchievementWatcherManager { | |||
|     } | ||||
| 
 | ||||
|     this.hasFinishedMergingWithRemote = true; | ||||
|   }; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -40,7 +40,7 @@ export const getGameAchievementData = async ( | |||
|         throw err; | ||||
|       } | ||||
| 
 | ||||
|       logger.error("Failed to get game achievements", err); | ||||
|       logger.error("Failed to get game achievements for", objectId, err); | ||||
| 
 | ||||
|       return []; | ||||
|     }); | ||||
|  |  | |||
|  | @ -59,7 +59,7 @@ export const mergeAchievements = async ( | |||
|   const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? []; | ||||
| 
 | ||||
|   const newAchievementsMap = new Map( | ||||
|     achievements.reverse().map((achievement) => { | ||||
|     achievements.toReversed().map((achievement) => { | ||||
|       return [achievement.name.toUpperCase(), achievement]; | ||||
|     }) | ||||
|   ); | ||||
|  | @ -87,7 +87,7 @@ export const mergeAchievements = async ( | |||
|     userPreferences?.achievementNotificationsEnabled | ||||
|   ) { | ||||
|     const achievementsInfo = newAchievements | ||||
|       .sort((a, b) => { | ||||
|       .toSorted((a, b) => { | ||||
|         return a.unlockTime - b.unlockTime; | ||||
|       }) | ||||
|       .map((achievement) => { | ||||
|  |  | |||
|  | @ -1,8 +1,8 @@ | |||
| import { Downloader } from "@shared"; | ||||
| import { Downloader, DownloadError } from "@shared"; | ||||
| import { WindowManager } from "../window-manager"; | ||||
| import { publishDownloadCompleteNotification } from "../notifications"; | ||||
| import type { Download, DownloadProgress, UserPreferences } from "@types"; | ||||
| import { GofileApi, QiwiApi, DatanodesApi } from "../hosters"; | ||||
| import { GofileApi, QiwiApi, DatanodesApi, MediafireApi } from "../hosters"; | ||||
| import { PythonRPC } from "../python-rpc"; | ||||
| import { | ||||
|   LibtorrentPayload, | ||||
|  | @ -15,6 +15,7 @@ import path from "path"; | |||
| import { logger } from "../logger"; | ||||
| import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; | ||||
| import { sortBy } from "lodash-es"; | ||||
| import { TorBoxClient } from "./torbox"; | ||||
| 
 | ||||
| export class DownloadManager { | ||||
|   private static downloadingGameId: string | null = null; | ||||
|  | @ -25,17 +26,20 @@ export class DownloadManager { | |||
|   ) { | ||||
|     PythonRPC.spawn( | ||||
|       download?.status === "active" | ||||
|         ? await this.getDownloadPayload(download).catch(() => undefined) | ||||
|         ? await this.getDownloadPayload(download).catch((err) => { | ||||
|             logger.error("Error getting download payload", err); | ||||
|             return undefined; | ||||
|           }) | ||||
|         : undefined, | ||||
|       downloadsToSeed?.map((download) => ({ | ||||
|         game_id: `${download.shop}-${download.objectId}`, | ||||
|         game_id: levelKeys.game(download.shop, download.objectId), | ||||
|         url: download.uri, | ||||
|         save_path: download.downloadPath, | ||||
|       })) | ||||
|     ); | ||||
| 
 | ||||
|     if (download) { | ||||
|       this.downloadingGameId = `${download.shop}-${download.objectId}`; | ||||
|       this.downloadingGameId = levelKeys.game(download.shop, download.objectId); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -106,7 +110,7 @@ export class DownloadManager { | |||
| 
 | ||||
|       if (!download || !game) return; | ||||
| 
 | ||||
|       const userPreferences = await db.get<string, UserPreferences>( | ||||
|       const userPreferences = await db.get<string, UserPreferences | null>( | ||||
|         levelKeys.userPreferences, | ||||
|         { | ||||
|           valueEncoding: "json", | ||||
|  | @ -230,7 +234,9 @@ export class DownloadManager { | |||
|     }); | ||||
| 
 | ||||
|     WindowManager.mainWindow?.setProgressBar(-1); | ||||
| 
 | ||||
|     if (downloadKey === this.downloadingGameId) { | ||||
|       WindowManager.mainWindow?.webContents.send("on-download-progress", null); | ||||
|       this.downloadingGameId = null; | ||||
|     } | ||||
|   } | ||||
|  | @ -260,6 +266,8 @@ export class DownloadManager { | |||
|         const token = await GofileApi.authorize(); | ||||
|         const downloadLink = await GofileApi.getDownloadLink(id!); | ||||
| 
 | ||||
|         await GofileApi.checkDownloadUrl(downloadLink); | ||||
| 
 | ||||
|         return { | ||||
|           action: "start", | ||||
|           game_id: downloadId, | ||||
|  | @ -270,10 +278,11 @@ export class DownloadManager { | |||
|       } | ||||
|       case Downloader.PixelDrain: { | ||||
|         const id = download.uri.split("/").pop(); | ||||
| 
 | ||||
|         return { | ||||
|           action: "start", | ||||
|           game_id: downloadId, | ||||
|           url: `https://pixeldrain.com/api/file/${id}?download`, | ||||
|           url: `https://cdn.pd5-gamedriveorg.workers.dev/api/file/${id}`, | ||||
|           save_path: download.downloadPath, | ||||
|         }; | ||||
|       } | ||||
|  | @ -295,6 +304,16 @@ export class DownloadManager { | |||
|           save_path: download.downloadPath, | ||||
|         }; | ||||
|       } | ||||
|       case Downloader.Mediafire: { | ||||
|         const downloadUrl = await MediafireApi.getDownloadUrl(download.uri); | ||||
| 
 | ||||
|         return { | ||||
|           action: "start", | ||||
|           game_id: downloadId, | ||||
|           url: downloadUrl, | ||||
|           save_path: download.downloadPath, | ||||
|         }; | ||||
|       } | ||||
|       case Downloader.Torrent: | ||||
|         return { | ||||
|           action: "start", | ||||
|  | @ -305,10 +324,7 @@ export class DownloadManager { | |||
|       case Downloader.RealDebrid: { | ||||
|         const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); | ||||
| 
 | ||||
|         if (!downloadUrl) | ||||
|           throw new Error( | ||||
|             "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available." | ||||
|           ); | ||||
|         if (!downloadUrl) throw new Error(DownloadError.NotCachedInRealDebrid); | ||||
| 
 | ||||
|         return { | ||||
|           action: "start", | ||||
|  | @ -317,6 +333,18 @@ export class DownloadManager { | |||
|           save_path: download.downloadPath, | ||||
|         }; | ||||
|       } | ||||
|       case Downloader.TorBox: { | ||||
|         const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); | ||||
| 
 | ||||
|         if (!url) return; | ||||
|         return { | ||||
|           action: "start", | ||||
|           game_id: downloadId, | ||||
|           url, | ||||
|           save_path: download.downloadPath, | ||||
|           out: name, | ||||
|         }; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -6,24 +6,23 @@ import type { | |||
|   TorBoxAddTorrentRequest, | ||||
|   TorBoxRequestLinkRequest, | ||||
| } from "@types"; | ||||
| import { logger } from "../logger"; | ||||
| 
 | ||||
| export class TorBoxClient { | ||||
|   private static instance: AxiosInstance; | ||||
|   private static readonly baseURL = "https://api.torbox.app/v1/api"; | ||||
|   public static apiToken: string; | ||||
|   private static apiToken: string; | ||||
| 
 | ||||
|   static authorize(apiToken: string) { | ||||
|     this.apiToken = apiToken; | ||||
|     this.instance = axios.create({ | ||||
|       baseURL: this.baseURL, | ||||
|       headers: { | ||||
|         Authorization: `Bearer ${apiToken}`, | ||||
|       }, | ||||
|     }); | ||||
|     this.apiToken = apiToken; | ||||
|   } | ||||
| 
 | ||||
|   static async addMagnet(magnet: string) { | ||||
|   private static async addMagnet(magnet: string) { | ||||
|     const form = new FormData(); | ||||
|     form.append("magnet", magnet); | ||||
| 
 | ||||
|  | @ -32,6 +31,10 @@ export class TorBoxClient { | |||
|       form | ||||
|     ); | ||||
| 
 | ||||
|     if (!response.data.success) { | ||||
|       throw new Error(response.data.detail); | ||||
|     } | ||||
| 
 | ||||
|     return response.data.data; | ||||
|   } | ||||
| 
 | ||||
|  | @ -55,22 +58,16 @@ export class TorBoxClient { | |||
|   } | ||||
| 
 | ||||
|   static async requestLink(id: number) { | ||||
|     const searchParams = new URLSearchParams({}); | ||||
| 
 | ||||
|     searchParams.set("token", this.apiToken); | ||||
|     searchParams.set("torrent_id", id.toString()); | ||||
|     searchParams.set("zip_link", "true"); | ||||
|     const searchParams = new URLSearchParams({ | ||||
|       token: this.apiToken, | ||||
|       torrent_id: id.toString(), | ||||
|       zip_link: "true", | ||||
|     }); | ||||
| 
 | ||||
|     const response = await this.instance.get<TorBoxRequestLinkRequest>( | ||||
|       "/torrents/requestdl?" + searchParams.toString() | ||||
|     ); | ||||
| 
 | ||||
|     if (response.status !== 200) { | ||||
|       logger.error(response.data.error); | ||||
|       logger.error(response.data.detail); | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     return response.data.data; | ||||
|   } | ||||
| 
 | ||||
|  | @ -81,7 +78,7 @@ export class TorBoxClient { | |||
|     return response.data.data; | ||||
|   } | ||||
| 
 | ||||
|   static async getTorrentId(magnetUri: string) { | ||||
|   private static async getTorrentIdAndName(magnetUri: string) { | ||||
|     const userTorrents = await this.getAllTorrentsFromUser(); | ||||
| 
 | ||||
|     const { infoHash } = await parseTorrent(magnetUri); | ||||
|  | @ -89,9 +86,18 @@ export class TorBoxClient { | |||
|       (userTorrent) => userTorrent.hash === infoHash | ||||
|     ); | ||||
| 
 | ||||
|     if (userTorrent) return userTorrent.id; | ||||
|     if (userTorrent) return { id: userTorrent.id, name: userTorrent.name }; | ||||
| 
 | ||||
|     const torrent = await this.addMagnet(magnetUri); | ||||
|     return torrent.torrent_id; | ||||
|     return { id: torrent.torrent_id, name: torrent.name }; | ||||
|   } | ||||
| 
 | ||||
|   static async getDownloadInfo(uri: string) { | ||||
|     const torrentData = await this.getTorrentIdAndName(uri); | ||||
|     const url = await this.requestLink(torrentData.id); | ||||
| 
 | ||||
|     const name = torrentData.name ? `${torrentData.name}.zip` : undefined; | ||||
| 
 | ||||
|     return { url, name }; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -60,4 +60,12 @@ export class GofileApi { | |||
| 
 | ||||
|     throw new Error("Failed to get download link"); | ||||
|   } | ||||
| 
 | ||||
|   public static async checkDownloadUrl(url: string) { | ||||
|     return axios.head(url, { | ||||
|       headers: { | ||||
|         Cookie: `accountToken=${this.token}`, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| export * from "./gofile"; | ||||
| export * from "./qiwi"; | ||||
| export * from "./datanodes"; | ||||
| export * from "./mediafire"; | ||||
|  |  | |||
							
								
								
									
										54
									
								
								src/main/services/hosters/mediafire.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/main/services/hosters/mediafire.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,54 @@ | |||
| import fetch from "node-fetch"; | ||||
| 
 | ||||
| export class MediafireApi { | ||||
|   private static readonly validMediafireIdentifierDL = /^[a-zA-Z0-9]+$/m; | ||||
|   private static readonly validMediafirePreDL = | ||||
|     /(?<=['"])(https?:)?(\/\/)?(www\.)?mediafire\.com\/(file|view|download)\/[^'"?]+\?dkey=[^'"]+(?=['"])/; | ||||
|   private static readonly validDynamicDL = | ||||
|     /(?<=['"])https?:\/\/download\d+\.mediafire\.com\/[^'"]+(?=['"])/; | ||||
|   private static readonly checkHTTP = /^https?:\/\//m; | ||||
| 
 | ||||
|   public static async getDownloadUrl(mediafireUrl: string): Promise<string> { | ||||
|     try { | ||||
|       const processedUrl = this.processUrl(mediafireUrl); | ||||
|       const response = await fetch(processedUrl); | ||||
| 
 | ||||
|       if (!response.ok) throw new Error("Failed to fetch Mediafire page"); | ||||
| 
 | ||||
|       const html = await response.text(); | ||||
|       return this.extractDirectUrl(html); | ||||
|     } catch (error) { | ||||
|       throw new Error(`Failed to get download URL`); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private static processUrl(url: string): string { | ||||
|     let processed = url.replace("http://", "https://"); | ||||
| 
 | ||||
|     if (this.validMediafireIdentifierDL.test(processed)) { | ||||
|       processed = `https://mediafire.com/?${processed}`; | ||||
|     } | ||||
| 
 | ||||
|     if (!this.checkHTTP.test(processed)) { | ||||
|       processed = processed.startsWith("//") | ||||
|         ? `https:${processed}` | ||||
|         : `https://${processed}`; | ||||
|     } | ||||
| 
 | ||||
|     return processed; | ||||
|   } | ||||
| 
 | ||||
|   private static extractDirectUrl(html: string): string { | ||||
|     const preMatch = this.validMediafirePreDL.exec(html); | ||||
|     if (preMatch?.[0]) { | ||||
|       return preMatch[0]; | ||||
|     } | ||||
| 
 | ||||
|     const dlMatch = this.validDynamicDL.exec(html); | ||||
|     if (dlMatch?.[0]) { | ||||
|       return dlMatch[0]; | ||||
|     } | ||||
| 
 | ||||
|     throw new Error("No valid download links found"); | ||||
|   } | ||||
| } | ||||
|  | @ -3,7 +3,7 @@ import { WindowManager } from "./window-manager"; | |||
| import url from "url"; | ||||
| import { uploadGamesBatch } from "./library-sync"; | ||||
| import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id"; | ||||
| import { logger } from "./logger"; | ||||
| import { networkLogger as logger } from "./logger"; | ||||
| import { UserNotLoggedInError, SubscriptionRequiredError } from "@shared"; | ||||
| import { omit } from "lodash-es"; | ||||
| import { appVersion } from "@main/constants"; | ||||
|  | @ -154,7 +154,8 @@ export class HydraApi { | |||
|         (error) => { | ||||
|           logger.error(" ---- RESPONSE ERROR -----"); | ||||
|           const { config } = error; | ||||
|           const data = JSON.parse(config.data); | ||||
| 
 | ||||
|           const data = JSON.parse(config.data ?? null); | ||||
| 
 | ||||
|           logger.error( | ||||
|             config.method, | ||||
|  | @ -175,14 +176,22 @@ export class HydraApi { | |||
|               error.response.status, | ||||
|               error.response.data | ||||
|             ); | ||||
|           } else if (error.request) { | ||||
|             const errorData = error.toJSON(); | ||||
|             logger.error("Request error:", errorData.message); | ||||
|           } else { | ||||
|             logger.error("Error", error.message); | ||||
| 
 | ||||
|             return Promise.reject(error as Error); | ||||
|           } | ||||
|           logger.error(" ----- END RESPONSE ERROR -------"); | ||||
|           return Promise.reject(error); | ||||
| 
 | ||||
|           if (error.request) { | ||||
|             const errorData = error.toJSON(); | ||||
|             logger.error("Request error:", errorData.code, errorData.message); | ||||
|             return Promise.reject( | ||||
|               new Error( | ||||
|                 `Request failed with ${errorData.code} ${errorData.message}` | ||||
|               ) | ||||
|             ); | ||||
|           } | ||||
| 
 | ||||
|           logger.error("Error", error.message); | ||||
|           return Promise.reject(error as Error); | ||||
|         } | ||||
|       ); | ||||
|     } | ||||
|  |  | |||
|  | @ -1,4 +1,3 @@ | |||
| export * from "./crypto"; | ||||
| export * from "./logger"; | ||||
| export * from "./steam"; | ||||
| export * from "./steam-250"; | ||||
|  |  | |||
|  | @ -35,22 +35,20 @@ export const mergeWithRemoteGames = async () => { | |||
|             name: "getById", | ||||
|           }); | ||||
| 
 | ||||
|           if (steamGame) { | ||||
|             const iconUrl = steamGame?.clientIcon | ||||
|               ? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon) | ||||
|               : null; | ||||
|           const iconUrl = steamGame?.clientIcon | ||||
|             ? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon) | ||||
|             : null; | ||||
| 
 | ||||
|             gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { | ||||
|               objectId: game.objectId, | ||||
|               title: steamGame?.name, | ||||
|               remoteId: game.id, | ||||
|               shop: game.shop, | ||||
|               iconUrl, | ||||
|               lastTimePlayed: game.lastTimePlayed, | ||||
|               playTimeInMilliseconds: game.playTimeInMilliseconds, | ||||
|               isDeleted: false, | ||||
|             }); | ||||
|           } | ||||
|           gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { | ||||
|             objectId: game.objectId, | ||||
|             title: steamGame?.name, | ||||
|             remoteId: game.id, | ||||
|             shop: game.shop, | ||||
|             iconUrl, | ||||
|             lastTimePlayed: game.lastTimePlayed, | ||||
|             playTimeInMilliseconds: game.playTimeInMilliseconds, | ||||
|             isDeleted: false, | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ export const uploadGamesBatch = async () => { | |||
|       ); | ||||
|     }); | ||||
| 
 | ||||
|   const gamesChunks = chunk(games, 200); | ||||
|   const gamesChunks = chunk(games, 50); | ||||
| 
 | ||||
|   for (const chunk of gamesChunks) { | ||||
|     await HydraApi.post( | ||||
|  |  | |||
|  | @ -6,8 +6,12 @@ log.transports.file.resolvePathFn = ( | |||
|   _: log.PathVariables, | ||||
|   message?: log.LogMessage | undefined | ||||
| ) => { | ||||
|   if (message?.scope === "python-instance") { | ||||
|     return path.join(logsPath, "pythoninstance.txt"); | ||||
|   if (message?.scope === "python-rpc") { | ||||
|     return path.join(logsPath, "pythonrpc.txt"); | ||||
|   } | ||||
| 
 | ||||
|   if (message?.scope === "network") { | ||||
|     return path.join(logsPath, "network.txt"); | ||||
|   } | ||||
| 
 | ||||
|   if (message?.scope == "achievements") { | ||||
|  | @ -34,3 +38,4 @@ log.initialize(); | |||
| export const pythonRpcLogger = log.scope("python-rpc"); | ||||
| export const logger = log.scope("main"); | ||||
| export const achievementsLogger = log.scope("achievements"); | ||||
| export const networkLogger = log.scope("network"); | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import { sleep } from "@main/helpers"; | |||
| import { DownloadManager } from "./download"; | ||||
| import { watchProcesses } from "./process-watcher"; | ||||
| import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager"; | ||||
| import { UpdateManager } from "./update-manager"; | ||||
| 
 | ||||
| export const startMainLoop = async () => { | ||||
|   // eslint-disable-next-line no-constant-condition
 | ||||
|  | @ -11,6 +12,7 @@ export const startMainLoop = async () => { | |||
|       DownloadManager.watchDownloads(), | ||||
|       AchievementWatcherManager.watchAchievements(), | ||||
|       DownloadManager.getSeedStatus(), | ||||
|       UpdateManager.checkForUpdatePeriodically(), | ||||
|     ]); | ||||
| 
 | ||||
|     await sleep(1500); | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ import { achievementSoundPath } from "@main/constants"; | |||
| import icon from "@resources/icon.png?asset"; | ||||
| import { NotificationOptions, toXmlString } from "./xml"; | ||||
| import { logger } from "../logger"; | ||||
| import { WindowManager } from "../window-manager"; | ||||
| import type { Game, UserPreferences } from "@types"; | ||||
| import { db, levelKeys } from "@main/level"; | ||||
| 
 | ||||
|  | @ -96,7 +97,9 @@ export const publishCombinedNewAchievementNotification = async ( | |||
|     toastXml: toXmlString(options), | ||||
|   }).show(); | ||||
| 
 | ||||
|   if (process.platform !== "linux") { | ||||
|   if (WindowManager.mainWindow) { | ||||
|     WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); | ||||
|   } else if (process.platform !== "linux") { | ||||
|     sound.play(achievementSoundPath); | ||||
|   } | ||||
| }; | ||||
|  | @ -143,7 +146,9 @@ export const publishNewAchievementNotification = async (info: { | |||
|     toastXml: toXmlString(options), | ||||
|   }).show(); | ||||
| 
 | ||||
|   if (process.platform !== "linux") { | ||||
|   if (WindowManager.mainWindow) { | ||||
|     WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); | ||||
|   } else if (process.platform !== "linux") { | ||||
|     sound.play(achievementSoundPath); | ||||
|   } | ||||
| }; | ||||
|  |  | |||
|  | @ -21,11 +21,18 @@ export const getSteamAppDetails = async ( | |||
|   }); | ||||
| 
 | ||||
|   return axios | ||||
|     .get( | ||||
|     .get<SteamAppDetailsResponse>( | ||||
|       `http://store.steampowered.com/api/appdetails?${searchParams.toString()}` | ||||
|     ) | ||||
|     .then((response) => { | ||||
|       if (response.data[objectId].success) return response.data[objectId].data; | ||||
|       if (response.data[objectId].success) { | ||||
|         const data = response.data[objectId].data; | ||||
|         return { | ||||
|           ...data, | ||||
|           objectId, | ||||
|         }; | ||||
|       } | ||||
| 
 | ||||
|       return null; | ||||
|     }) | ||||
|     .catch((err) => { | ||||
|  |  | |||
							
								
								
									
										60
									
								
								src/main/services/update-manager.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/main/services/update-manager.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,60 @@ | |||
| import updater, { UpdateInfo } from "electron-updater"; | ||||
| import { logger, WindowManager } from "@main/services"; | ||||
| import { AppUpdaterEvent } from "@types"; | ||||
| import { app } from "electron"; | ||||
| import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications"; | ||||
| 
 | ||||
| const isAutoInstallAvailable = | ||||
|   process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null; | ||||
| 
 | ||||
| const { autoUpdater } = updater; | ||||
| const sendEventsForDebug = false; | ||||
| 
 | ||||
| export class UpdateManager { | ||||
|   private static hasNotified = false; | ||||
|   private static newVersion = ""; | ||||
|   private static checkTick = 0; | ||||
| 
 | ||||
|   private static mockValuesForDebug() { | ||||
|     this.sendEvent({ type: "update-available", info: { version: "1.3.0" } }); | ||||
|     this.sendEvent({ type: "update-downloaded" }); | ||||
|   } | ||||
| 
 | ||||
|   private static sendEvent(event: AppUpdaterEvent) { | ||||
|     WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event); | ||||
|   } | ||||
| 
 | ||||
|   public static checkForUpdates() { | ||||
|     autoUpdater | ||||
|       .once("update-available", (info: UpdateInfo) => { | ||||
|         this.sendEvent({ type: "update-available", info }); | ||||
|         this.newVersion = info.version; | ||||
|       }) | ||||
|       .once("update-downloaded", () => { | ||||
|         this.sendEvent({ type: "update-downloaded" }); | ||||
| 
 | ||||
|         if (!this.hasNotified) { | ||||
|           this.hasNotified = true; | ||||
|           publishNotificationUpdateReadyToInstall(this.newVersion); | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|     if (app.isPackaged) { | ||||
|       autoUpdater.autoDownload = isAutoInstallAvailable; | ||||
|       autoUpdater.checkForUpdates().then((result) => { | ||||
|         logger.log(`Check for updates result: ${result}`); | ||||
|       }); | ||||
|     } else if (sendEventsForDebug) { | ||||
|       this.mockValuesForDebug(); | ||||
|     } | ||||
| 
 | ||||
|     return isAutoInstallAvailable; | ||||
|   } | ||||
| 
 | ||||
|   public static checkForUpdatePeriodically() { | ||||
|     if (this.checkTick % 2000 == 0) { | ||||
|       this.checkForUpdates(); | ||||
|     } | ||||
|     this.checkTick++; | ||||
|   } | ||||
| } | ||||
|  | @ -29,7 +29,6 @@ export const getUserData = async () => { | |||
|     }) | ||||
|     .catch(async (err) => { | ||||
|       if (err instanceof UserNotLoggedInError) { | ||||
|         logger.info("User is not logged in", err); | ||||
|         return null; | ||||
|       } | ||||
|       logger.error("Failed to get logged user"); | ||||
|  | @ -59,6 +58,7 @@ export const getUserData = async () => { | |||
|                 expiresAt: loggedUser.subscription.expiresAt, | ||||
|               } | ||||
|             : null, | ||||
|           featurebaseJwt: "", | ||||
|         } as UserDetails; | ||||
|       } | ||||
| 
 | ||||
|  |  | |||
|  | @ -19,6 +19,7 @@ import { db, gamesSublevel, levelKeys } from "@main/level"; | |||
| import { slice, sortBy } from "lodash-es"; | ||||
| import type { UserPreferences } from "@types"; | ||||
| import { AuthPage } from "@shared"; | ||||
| import { isStaging } from "@main/constants"; | ||||
| 
 | ||||
| export class WindowManager { | ||||
|   public static mainWindow: Electron.BrowserWindow | null = null; | ||||
|  | @ -52,7 +53,7 @@ export class WindowManager { | |||
|       minHeight: 540, | ||||
|       backgroundColor: "#1c1c1c", | ||||
|       titleBarStyle: process.platform === "linux" ? "default" : "hidden", | ||||
|       ...(process.platform === "linux" ? { icon } : {}), | ||||
|       icon, | ||||
|       trafficLightPosition: { x: 16, y: 16 }, | ||||
|       titleBarOverlay: { | ||||
|         symbolColor: "#DADBE1", | ||||
|  | @ -129,7 +130,8 @@ export class WindowManager { | |||
|     this.mainWindow.removeMenu(); | ||||
| 
 | ||||
|     this.mainWindow.on("ready-to-show", () => { | ||||
|       if (!app.isPackaged) WindowManager.mainWindow?.webContents.openDevTools(); | ||||
|       if (!app.isPackaged || isStaging) | ||||
|         WindowManager.mainWindow?.webContents.openDevTools(); | ||||
|       WindowManager.mainWindow?.show(); | ||||
|     }); | ||||
| 
 | ||||
|  | @ -147,6 +149,11 @@ export class WindowManager { | |||
|       WindowManager.mainWindow?.setProgressBar(-1); | ||||
|       WindowManager.mainWindow = null; | ||||
|     }); | ||||
| 
 | ||||
|     this.mainWindow.webContents.setWindowOpenHandler((handler) => { | ||||
|       shell.openExternal(handler.url); | ||||
|       return { action: "deny" }; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   public static openAuthWindow(page: AuthPage, searchParams: URLSearchParams) { | ||||
|  |  | |||
|  | @ -33,10 +33,10 @@ contextBridge.exposeInMainWorld("electron", { | |||
|     ipcRenderer.invoke("pauseGameSeed", shop, objectId), | ||||
|   resumeGameSeed: (shop: GameShop, objectId: string) => | ||||
|     ipcRenderer.invoke("resumeGameSeed", shop, objectId), | ||||
|   onDownloadProgress: (cb: (value: DownloadProgress) => void) => { | ||||
|   onDownloadProgress: (cb: (value: DownloadProgress | null) => void) => { | ||||
|     const listener = ( | ||||
|       _event: Electron.IpcRendererEvent, | ||||
|       value: DownloadProgress | ||||
|       value: DownloadProgress | null | ||||
|     ) => cb(value); | ||||
|     ipcRenderer.on("on-download-progress", listener); | ||||
|     return () => ipcRenderer.removeListener("on-download-progress", listener); | ||||
|  | @ -93,6 +93,8 @@ contextBridge.exposeInMainWorld("electron", { | |||
|     ipcRenderer.invoke("autoLaunch", autoLaunchProps), | ||||
|   authenticateRealDebrid: (apiToken: string) => | ||||
|     ipcRenderer.invoke("authenticateRealDebrid", apiToken), | ||||
|   authenticateTorBox: (apiToken: string) => | ||||
|     ipcRenderer.invoke("authenticateTorBox", apiToken), | ||||
| 
 | ||||
|   /* Download sources */ | ||||
|   putDownloadSource: (objectIds: string[]) => | ||||
|  | @ -109,11 +111,16 @@ contextBridge.exposeInMainWorld("electron", { | |||
|     executablePath: string | null | ||||
|   ) => | ||||
|     ipcRenderer.invoke("updateExecutablePath", shop, objectId, executablePath), | ||||
|   addGameToFavorites: (shop: GameShop, objectId: string) => | ||||
|     ipcRenderer.invoke("addGameToFavorites", shop, objectId), | ||||
|   removeGameFromFavorites: (shop: GameShop, objectId: string) => | ||||
|     ipcRenderer.invoke("removeGameFromFavorites", shop, objectId), | ||||
|   updateLaunchOptions: ( | ||||
|     shop: GameShop, | ||||
|     objectId: string, | ||||
|     launchOptions: string | null | ||||
|   ) => ipcRenderer.invoke("updateLaunchOptions", shop, objectId, launchOptions), | ||||
| 
 | ||||
|   selectGameWinePrefix: ( | ||||
|     shop: GameShop, | ||||
|     objectId: string, | ||||
|  | @ -170,6 +177,12 @@ contextBridge.exposeInMainWorld("electron", { | |||
|     return () => | ||||
|       ipcRenderer.removeListener("on-library-batch-complete", listener); | ||||
|   }, | ||||
|   onAchievementUnlocked: (cb: () => void) => { | ||||
|     const listener = (_event: Electron.IpcRendererEvent) => cb(); | ||||
|     ipcRenderer.on("on-achievement-unlocked", listener); | ||||
|     return () => | ||||
|       ipcRenderer.removeListener("on-achievement-unlocked", listener); | ||||
|   }, | ||||
| 
 | ||||
|   /* Hardware */ | ||||
|   getDiskFreeSpace: (path: string) => | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { useCallback, useEffect, useRef } from "react"; | ||||
| 
 | ||||
| import achievementSound from "@renderer/assets/audio/achievement.wav"; | ||||
| import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; | ||||
| 
 | ||||
| import { | ||||
|  | @ -85,7 +85,7 @@ export function App() { | |||
|   useEffect(() => { | ||||
|     const unsubscribe = window.electron.onDownloadProgress( | ||||
|       (downloadProgress) => { | ||||
|         if (downloadProgress.progress === 1) { | ||||
|         if (downloadProgress?.progress === 1) { | ||||
|           clearDownload(); | ||||
|           updateLibrary(); | ||||
|           return; | ||||
|  | @ -245,6 +245,22 @@ export function App() { | |||
|     loadAndApplyTheme(); | ||||
|   }, []); | ||||
| 
 | ||||
|   const playAudio = useCallback(() => { | ||||
|     const audio = new Audio(achievementSound); | ||||
|     audio.volume = 0.2; | ||||
|     audio.play(); | ||||
|   }, []); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const unsubscribe = window.electron.onAchievementUnlocked(() => { | ||||
|       playAudio(); | ||||
|     }); | ||||
| 
 | ||||
|     return () => { | ||||
|       unsubscribe(); | ||||
|     }; | ||||
|   }, [playAudio]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const unsubscribe = window.electron.onCssInjected((cssString) => { | ||||
|       if (cssString) { | ||||
|  | @ -252,9 +268,7 @@ export function App() { | |||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     return () => { | ||||
|       unsubscribe(); | ||||
|     }; | ||||
|     return () => unsubscribe(); | ||||
|   }, []); | ||||
| 
 | ||||
|   const handleToastClose = useCallback(() => { | ||||
|  | @ -276,9 +290,11 @@ export function App() { | |||
| 
 | ||||
|       <Toast | ||||
|         visible={toast.visible} | ||||
|         title={toast.title} | ||||
|         message={toast.message} | ||||
|         type={toast.type} | ||||
|         onClose={handleToastClose} | ||||
|         duration={toast.duration} | ||||
|       /> | ||||
| 
 | ||||
|       <HydraCloudModal | ||||
|  |  | |||
							
								
								
									
										
											BIN
										
									
								
								src/renderer/src/assets/audio/achievement.wav
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/renderer/src/assets/audio/achievement.wav
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/renderer/src/assets/icons/torbox.webp
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/renderer/src/assets/icons/torbox.webp
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 12 KiB | 
|  | @ -7,5 +7,6 @@ | |||
|   border: solid 1px globals.$muted-color; | ||||
|   border-radius: 4px; | ||||
|   display: flex; | ||||
|   gap: 4px; | ||||
|   align-items: center; | ||||
| } | ||||
|  |  | |||
|  | @ -21,4 +21,14 @@ | |||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &__version-button { | ||||
|     color: globals.$body-color; | ||||
|     border-bottom: solid 1px transparent; | ||||
| 
 | ||||
|     &:hover { | ||||
|       border-bottom: solid 1px globals.$body-color; | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -19,8 +19,6 @@ export function BottomPanel() { | |||
| 
 | ||||
|   const { lastPacket, progress, downloadSpeed, eta } = useDownload(); | ||||
| 
 | ||||
|   const isGameDownloading = !!lastPacket; | ||||
| 
 | ||||
|   const [version, setVersion] = useState(""); | ||||
|   const [sessionHash, setSessionHash] = useState<null | string>(""); | ||||
| 
 | ||||
|  | @ -33,9 +31,11 @@ export function BottomPanel() { | |||
|   }, [userDetails?.id]); | ||||
| 
 | ||||
|   const status = useMemo(() => { | ||||
|     if (isGameDownloading) { | ||||
|       const game = library.find((game) => game.id === lastPacket?.gameId)!; | ||||
|     const game = lastPacket | ||||
|       ? library.find((game) => game.id === lastPacket?.gameId) | ||||
|       : undefined; | ||||
| 
 | ||||
|     if (game) { | ||||
|       if (lastPacket?.isCheckingFiles) | ||||
|         return t("checking_files", { | ||||
|           title: game.title, | ||||
|  | @ -64,7 +64,7 @@ export function BottomPanel() { | |||
|     } | ||||
| 
 | ||||
|     return t("no_downloads_in_progress"); | ||||
|   }, [t, isGameDownloading, library, lastPacket, progress, eta, downloadSpeed]); | ||||
|   }, [t, library, lastPacket, progress, eta, downloadSpeed]); | ||||
| 
 | ||||
|   return ( | ||||
|     <footer className="bottom-panel"> | ||||
|  | @ -76,10 +76,15 @@ export function BottomPanel() { | |||
|         <small>{status}</small> | ||||
|       </button> | ||||
| 
 | ||||
|       <small> | ||||
|         {sessionHash ? `${sessionHash} -` : ""} v{version} " | ||||
|         {VERSION_CODENAME}" | ||||
|       </small> | ||||
|       <button | ||||
|         data-featurebase-changelog | ||||
|         className="bottom-panel__version-button" | ||||
|       > | ||||
|         <small data-featurebase-changelog> | ||||
|           {sessionHash ? `${sessionHash} -` : ""} v{version} " | ||||
|           {VERSION_CODENAME}" | ||||
|         </small> | ||||
|       </button> | ||||
|     </footer> | ||||
|   ); | ||||
| } | ||||
|  |  | |||
|  | @ -15,6 +15,8 @@ | |||
|   &__checkbox { | ||||
|     width: 20px; | ||||
|     height: 20px; | ||||
|     min-width: 20px; | ||||
|     min-height: 20px; | ||||
|     border-radius: 4px; | ||||
|     background-color: globals.$dark-background-color; | ||||
|     display: flex; | ||||
|  | @ -45,6 +47,9 @@ | |||
| 
 | ||||
|   &__label { | ||||
|     cursor: pointer; | ||||
|     text-overflow: ellipsis; | ||||
|     overflow: hidden; | ||||
|     white-space: nowrap; | ||||
| 
 | ||||
|     &:has(+ input:disabled) { | ||||
|       cursor: not-allowed; | ||||
|  |  | |||
							
								
								
									
										50
									
								
								src/renderer/src/components/sidebar/sidebar-game-item.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/renderer/src/components/sidebar/sidebar-game-item.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,50 @@ | |||
| import SteamLogo from "@renderer/assets/steam-logo.svg?react"; | ||||
| import { LibraryGame } from "@types"; | ||||
| import cn from "classnames"; | ||||
| import { useLocation } from "react-router-dom"; | ||||
| 
 | ||||
| interface SidebarGameItemProps { | ||||
|   game: LibraryGame; | ||||
|   handleSidebarGameClick: (event: React.MouseEvent, game: LibraryGame) => void; | ||||
|   getGameTitle: (game: LibraryGame) => string; | ||||
| } | ||||
| 
 | ||||
| export function SidebarGameItem({ | ||||
|   game, | ||||
|   handleSidebarGameClick, | ||||
|   getGameTitle, | ||||
| }: Readonly<SidebarGameItemProps>) { | ||||
|   const location = useLocation(); | ||||
| 
 | ||||
|   return ( | ||||
|     <li | ||||
|       key={game.id} | ||||
|       className={cn("sidebar__menu-item", { | ||||
|         "sidebar__menu-item--active": | ||||
|           location.pathname === `/game/${game.shop}/${game.objectId}`, | ||||
|         "sidebar__menu-item--muted": game.download?.status === "removed", | ||||
|       })} | ||||
|     > | ||||
|       <button | ||||
|         type="button" | ||||
|         className="sidebar__menu-item-button" | ||||
|         onClick={(event) => handleSidebarGameClick(event, game)} | ||||
|       > | ||||
|         {game.iconUrl ? ( | ||||
|           <img | ||||
|             className="sidebar__game-icon" | ||||
|             src={game.iconUrl} | ||||
|             alt={game.title} | ||||
|             loading="lazy" | ||||
|           /> | ||||
|         ) : ( | ||||
|           <SteamLogo className="sidebar__game-icon" /> | ||||
|         )} | ||||
| 
 | ||||
|         <span className="sidebar__menu-item-button-label"> | ||||
|           {getGameTitle(game)} | ||||
|         </span> | ||||
|       </button> | ||||
|     </li> | ||||
|   ); | ||||
| } | ||||
|  | @ -18,11 +18,11 @@ import "./sidebar.scss"; | |||
| 
 | ||||
| import { buildGameDetailsPath } from "@renderer/helpers"; | ||||
| 
 | ||||
| import SteamLogo from "@renderer/assets/steam-logo.svg?react"; | ||||
| import { SidebarProfile } from "./sidebar-profile"; | ||||
| import { sortBy } from "lodash-es"; | ||||
| import cn from "classnames"; | ||||
| import { CommentDiscussionIcon } from "@primer/octicons-react"; | ||||
| import { SidebarGameItem } from "./sidebar-game-item"; | ||||
| 
 | ||||
| const SIDEBAR_MIN_WIDTH = 200; | ||||
| const SIDEBAR_INITIAL_WIDTH = 250; | ||||
|  | @ -206,6 +206,23 @@ export function Sidebar() { | |||
|             </ul> | ||||
|           </section> | ||||
| 
 | ||||
|           <section className="sidebar__section"> | ||||
|             <small className="sidebar__section-title">{t("favorites")}</small> | ||||
| 
 | ||||
|             <ul className="sidebar__menu"> | ||||
|               {sortedLibrary | ||||
|                 .filter((game) => game.favorite) | ||||
|                 .map((game) => ( | ||||
|                   <SidebarGameItem | ||||
|                     key={game.id} | ||||
|                     game={game} | ||||
|                     handleSidebarGameClick={handleSidebarGameClick} | ||||
|                     getGameTitle={getGameTitle} | ||||
|                   /> | ||||
|                 ))} | ||||
|             </ul> | ||||
|           </section> | ||||
| 
 | ||||
|           <section className="sidebar__section"> | ||||
|             <small className="sidebar__section-title">{t("my_library")}</small> | ||||
| 
 | ||||
|  | @ -217,39 +234,16 @@ export function Sidebar() { | |||
|             /> | ||||
| 
 | ||||
|             <ul className="sidebar__menu"> | ||||
|               {filteredLibrary.map((game) => ( | ||||
|                 <li | ||||
|                   key={`${game.shop}-${game.objectId}`} | ||||
|                   className={cn("sidebar__menu-item", { | ||||
|                     "sidebar__menu-item--active": | ||||
|                       location.pathname === | ||||
|                       `/game/${game.shop}/${game.objectId}`, | ||||
|                     "sidebar__menu-item--muted": | ||||
|                       game.download?.status === "removed", | ||||
|                   })} | ||||
|                 > | ||||
|                   <button | ||||
|                     type="button" | ||||
|                     className="sidebar__menu-item-button" | ||||
|                     onClick={(event) => handleSidebarGameClick(event, game)} | ||||
|                   > | ||||
|                     {game.iconUrl ? ( | ||||
|                       <img | ||||
|                         className="sidebar__game-icon" | ||||
|                         src={game.iconUrl} | ||||
|                         alt={game.title} | ||||
|                         loading="lazy" | ||||
|                       /> | ||||
|                     ) : ( | ||||
|                       <SteamLogo className="sidebar__game-icon" /> | ||||
|                     )} | ||||
| 
 | ||||
|                     <span className="sidebar__menu-item-button-label"> | ||||
|                       {getGameTitle(game)} | ||||
|                     </span> | ||||
|                   </button> | ||||
|                 </li> | ||||
|               ))} | ||||
|               {filteredLibrary | ||||
|                 .filter((game) => !game.favorite) | ||||
|                 .map((game) => ( | ||||
|                   <SidebarGameItem | ||||
|                     key={game.id} | ||||
|                     game={game} | ||||
|                     handleSidebarGameClick={handleSidebarGameClick} | ||||
|                     getGameTitle={getGameTitle} | ||||
|                   /> | ||||
|                 ))} | ||||
|             </ul> | ||||
|           </section> | ||||
|         </div> | ||||
|  |  | |||
|  | @ -55,11 +55,9 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>( | |||
|     }, [props.type, isPasswordVisible]); | ||||
| 
 | ||||
|     const hintContent = useMemo(() => { | ||||
|       if (error && typeof error === "object" && "message" in error) | ||||
|       if (error) | ||||
|         return ( | ||||
|           <small className="text-field-container__error-label"> | ||||
|             {error.message as string} | ||||
|           </small> | ||||
|           <small className="text-field-container__error-label">{error}</small> | ||||
|         ); | ||||
| 
 | ||||
|       if (hint) return <small>{hint}</small>; | ||||
|  |  | |||
|  | @ -1,30 +1,26 @@ | |||
| @use "../../scss/globals.scss"; | ||||
| 
 | ||||
| $toast-height: 80px; | ||||
| 
 | ||||
| .toast { | ||||
|   animation-duration: 0.2s; | ||||
|   animation-timing-function: ease-in-out; | ||||
|   max-height: $toast-height; | ||||
|   position: fixed; | ||||
|   background-color: globals.$background-color; | ||||
|   position: absolute; | ||||
|   background-color: globals.$dark-background-color; | ||||
|   border-radius: 4px; | ||||
|   border: solid 1px globals.$border-color; | ||||
|   right: calc(globals.$spacing-unit * 2); | ||||
|   //bottom panel height + 16px | ||||
|   bottom: calc(26px + #{globals.$spacing-unit * 2}); | ||||
|   right: 16px; | ||||
|   bottom: 26px + globals.$spacing-unit; | ||||
|   overflow: hidden; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   justify-content: space-between; | ||||
|   z-index: globals.$toast-z-index; | ||||
|   max-width: 500px; | ||||
|   animation-name: slide-in; | ||||
|   max-width: 420px; | ||||
|   animation-name: enter; | ||||
|   transform: translateY(0); | ||||
| 
 | ||||
|   &--closing { | ||||
|     animation-name: slide-out; | ||||
|     transform: translateY(calc($toast-height + #{globals.$spacing-unit * 2})); | ||||
|     animation-name: exit; | ||||
|     transform: translateY(100%); | ||||
|   } | ||||
| 
 | ||||
|   &__content { | ||||
|  | @ -38,6 +34,7 @@ $toast-height: 80px; | |||
|   &__message-container { | ||||
|     display: flex; | ||||
|     gap: globals.$spacing-unit; | ||||
|     flex-direction: column; | ||||
|   } | ||||
| 
 | ||||
|   &__message { | ||||
|  | @ -62,37 +59,38 @@ $toast-height: 80px; | |||
|     cursor: pointer; | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
|     transition: color 0.2s ease-in-out; | ||||
| 
 | ||||
|     &:hover { | ||||
|       color: globals.$muted-color; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &__success-icon { | ||||
|     color: globals.$success-color; | ||||
|   } | ||||
|   &__icon { | ||||
|     &--success { | ||||
|       color: globals.$success-color; | ||||
|     } | ||||
| 
 | ||||
|   &__error-icon { | ||||
|     color: globals.$danger-color; | ||||
|   } | ||||
|     &--error { | ||||
|       color: globals.$danger-color; | ||||
|     } | ||||
| 
 | ||||
|   &__warning-icon { | ||||
|     color: globals.$warning-color; | ||||
|     &--warning { | ||||
|       color: globals.$warning-color; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @keyframes slide-in { | ||||
| @keyframes enter { | ||||
|   0% { | ||||
|     transform: translateY(calc($toast-height + #{globals.$spacing-unit * 2})); | ||||
|     opacity: 0; | ||||
|     transform: translateY(100%); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|   100% { | ||||
| @keyframes exit { | ||||
|   0% { | ||||
|     opacity: 1; | ||||
|     transform: translateY(0); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @keyframes slide-out { | ||||
|   0% { | ||||
|     transform: translateY(0); | ||||
|   } | ||||
| 
 | ||||
|   100% { | ||||
|     transform: translateY(calc($toast-height + #{globals.$spacing-unit * 2})); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -11,14 +11,23 @@ import cn from "classnames"; | |||
| 
 | ||||
| export interface ToastProps { | ||||
|   visible: boolean; | ||||
|   message: string; | ||||
|   title: string; | ||||
|   message?: string; | ||||
|   type: "success" | "error" | "warning"; | ||||
|   duration?: number; | ||||
|   onClose: () => void; | ||||
| } | ||||
| 
 | ||||
| const INITIAL_PROGRESS = 100; | ||||
| 
 | ||||
| export function Toast({ visible, message, type, onClose }: ToastProps) { | ||||
| export function Toast({ | ||||
|   visible, | ||||
|   title, | ||||
|   message, | ||||
|   type, | ||||
|   duration = 2500, | ||||
|   onClose, | ||||
| }: Readonly<ToastProps>) { | ||||
|   const [isClosing, setIsClosing] = useState(false); | ||||
|   const [progress, setProgress] = useState(INITIAL_PROGRESS); | ||||
| 
 | ||||
|  | @ -31,7 +40,7 @@ export function Toast({ visible, message, type, onClose }: ToastProps) { | |||
| 
 | ||||
|     closingAnimation.current = requestAnimationFrame( | ||||
|       function animateClosing(time) { | ||||
|         if (time - zero <= 200) { | ||||
|         if (time - zero <= 150) { | ||||
|           closingAnimation.current = requestAnimationFrame(animateClosing); | ||||
|         } else { | ||||
|           onClose(); | ||||
|  | @ -43,17 +52,13 @@ export function Toast({ visible, message, type, onClose }: ToastProps) { | |||
|   useEffect(() => { | ||||
|     if (visible) { | ||||
|       const zero = performance.now(); | ||||
| 
 | ||||
|       progressAnimation.current = requestAnimationFrame( | ||||
|         function animateProgress(time) { | ||||
|           const elapsed = time - zero; | ||||
| 
 | ||||
|           const progress = Math.min(elapsed / 2500, 1); | ||||
|           const progress = Math.min(elapsed / duration, 1); | ||||
|           const currentValue = | ||||
|             INITIAL_PROGRESS + (0 - INITIAL_PROGRESS) * progress; | ||||
| 
 | ||||
|           setProgress(currentValue); | ||||
| 
 | ||||
|           if (progress < 1) { | ||||
|             progressAnimation.current = requestAnimationFrame(animateProgress); | ||||
|           } else { | ||||
|  | @ -70,9 +75,8 @@ export function Toast({ visible, message, type, onClose }: ToastProps) { | |||
|         setIsClosing(false); | ||||
|       }; | ||||
|     } | ||||
| 
 | ||||
|     return () => {}; | ||||
|   }, [startAnimateClosing, visible]); | ||||
|   }, [startAnimateClosing, duration, visible]); | ||||
| 
 | ||||
|   if (!visible) return null; | ||||
| 
 | ||||
|  | @ -84,26 +88,40 @@ export function Toast({ visible, message, type, onClose }: ToastProps) { | |||
|     > | ||||
|       <div className="toast__content"> | ||||
|         <div className="toast__message-container"> | ||||
|           {type === "success" && ( | ||||
|             <CheckCircleFillIcon className="toast__success-icon" /> | ||||
|           )} | ||||
|           <div | ||||
|             style={{ | ||||
|               display: "flex", | ||||
|               justifyContent: "space-between", | ||||
|               alignItems: "center", | ||||
|               gap: `8px`, | ||||
|             }} | ||||
|           > | ||||
|             {type === "success" && ( | ||||
|               <CheckCircleFillIcon className="toast__icon--success" /> | ||||
|             )} | ||||
| 
 | ||||
|           {type === "error" && ( | ||||
|             <XCircleFillIcon className="toast__error-icon" /> | ||||
|           )} | ||||
|             {type === "error" && ( | ||||
|               <XCircleFillIcon className="toast__icon--error" /> | ||||
|             )} | ||||
| 
 | ||||
|           {type === "warning" && <AlertIcon className="toast__warning-icon" />} | ||||
|           <span className="toast__message">{message}</span> | ||||
|             {type === "warning" && ( | ||||
|               <AlertIcon className="toast__icon--warning" /> | ||||
|             )} | ||||
| 
 | ||||
|             <span style={{ fontWeight: "bold", flex: 1 }}>{title}</span> | ||||
| 
 | ||||
|             <button | ||||
|               type="button" | ||||
|               className="toast__close-button" | ||||
|               onClick={startAnimateClosing} | ||||
|               aria-label="Close toast" | ||||
|             > | ||||
|               <XIcon /> | ||||
|             </button> | ||||
|           </div> | ||||
| 
 | ||||
|           {message && <p>{message}</p>} | ||||
|         </div> | ||||
| 
 | ||||
|         <button | ||||
|           type="button" | ||||
|           className="toast__close-button" | ||||
|           onClick={startAnimateClosing} | ||||
|           aria-label="Close toast" | ||||
|         > | ||||
|           <XIcon /> | ||||
|         </button> | ||||
|       </div> | ||||
| 
 | ||||
|       <progress className="toast__progress" value={progress} max={100} /> | ||||
|  |  | |||
|  | @ -9,6 +9,8 @@ export const DOWNLOADER_NAME = { | |||
|   [Downloader.PixelDrain]: "PixelDrain", | ||||
|   [Downloader.Qiwi]: "Qiwi", | ||||
|   [Downloader.Datanodes]: "Datanodes", | ||||
|   [Downloader.Mediafire]: "Mediafire", | ||||
|   [Downloader.TorBox]: "TorBox", | ||||
| }; | ||||
| 
 | ||||
| export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; | ||||
|  |  | |||
							
								
								
									
										14
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -28,6 +28,7 @@ import type { | |||
|   CatalogueSearchPayload, | ||||
|   LibraryGame, | ||||
|   GameRunning, | ||||
|   TorBoxUser, | ||||
| } from "@types"; | ||||
| import type { AxiosProgressEvent } from "axios"; | ||||
| import type disk from "diskusage"; | ||||
|  | @ -40,14 +41,16 @@ declare global { | |||
| 
 | ||||
|   interface Electron { | ||||
|     /* Torrenting */ | ||||
|     startGameDownload: (payload: StartGameDownloadPayload) => Promise<void>; | ||||
|     startGameDownload: ( | ||||
|       payload: StartGameDownloadPayload | ||||
|     ) => Promise<{ ok: boolean; error?: string }>; | ||||
|     cancelGameDownload: (shop: GameShop, objectId: string) => Promise<void>; | ||||
|     pauseGameDownload: (shop: GameShop, objectId: string) => Promise<void>; | ||||
|     resumeGameDownload: (shop: GameShop, objectId: string) => Promise<void>; | ||||
|     pauseGameSeed: (shop: GameShop, objectId: string) => Promise<void>; | ||||
|     resumeGameSeed: (shop: GameShop, objectId: string) => Promise<void>; | ||||
|     onDownloadProgress: ( | ||||
|       cb: (value: DownloadProgress) => void | ||||
|       cb: (value: DownloadProgress | null) => void | ||||
|     ) => () => Electron.IpcRenderer; | ||||
|     onSeedingStatus: ( | ||||
|       cb: (value: SeedingStatus[]) => void | ||||
|  | @ -93,6 +96,11 @@ declare global { | |||
|       objectId: string, | ||||
|       executablePath: string | null | ||||
|     ) => Promise<void>; | ||||
|     addGameToFavorites: (shop: GameShop, objectId: string) => Promise<void>; | ||||
|     removeGameFromFavorites: ( | ||||
|       shop: GameShop, | ||||
|       objectId: string | ||||
|     ) => Promise<void>; | ||||
|     updateLaunchOptions: ( | ||||
|       shop: GameShop, | ||||
|       objectId: string, | ||||
|  | @ -142,6 +150,8 @@ declare global { | |||
|       minimized: boolean; | ||||
|     }) => Promise<void>; | ||||
|     authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>; | ||||
|     authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>; | ||||
|     onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer; | ||||
| 
 | ||||
|     /* Download sources */ | ||||
|     putDownloadSource: ( | ||||
|  |  | |||
|  | @ -18,9 +18,9 @@ export const downloadSlice = createSlice({ | |||
|   name: "download", | ||||
|   initialState, | ||||
|   reducers: { | ||||
|     setLastPacket: (state, action: PayloadAction<DownloadProgress>) => { | ||||
|     setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => { | ||||
|       state.lastPacket = action.payload; | ||||
|       if (!state.gameId) state.gameId = action.payload.gameId; | ||||
|       if (!state.gameId && action.payload) state.gameId = action.payload.gameId; | ||||
|     }, | ||||
|     clearDownload: (state) => { | ||||
|       state.lastPacket = null; | ||||
|  |  | |||
|  | @ -3,14 +3,18 @@ import type { PayloadAction } from "@reduxjs/toolkit"; | |||
| import { ToastProps } from "@renderer/components/toast/toast"; | ||||
| 
 | ||||
| export interface ToastState { | ||||
|   message: string; | ||||
|   title: string; | ||||
|   message?: string; | ||||
|   type: ToastProps["type"]; | ||||
|   duration?: number; | ||||
|   visible: boolean; | ||||
| } | ||||
| 
 | ||||
| const initialState: ToastState = { | ||||
|   title: "", | ||||
|   message: "", | ||||
|   type: "success", | ||||
|   duration: 5000, | ||||
|   visible: false, | ||||
| }; | ||||
| 
 | ||||
|  | @ -19,8 +23,10 @@ export const toastSlice = createSlice({ | |||
|   initialState, | ||||
|   reducers: { | ||||
|     showToast: (state, action: PayloadAction<Omit<ToastState, "visible">>) => { | ||||
|       state.title = action.payload.title; | ||||
|       state.message = action.payload.message; | ||||
|       state.type = action.payload.type; | ||||
|       state.duration = action.payload.duration ?? 2000; | ||||
|       state.visible = true; | ||||
|     }, | ||||
|     closeToast: (state) => { | ||||
|  |  | |||
|  | @ -29,10 +29,11 @@ export function useDownload() { | |||
|   const startDownload = async (payload: StartGameDownloadPayload) => { | ||||
|     dispatch(clearDownload()); | ||||
| 
 | ||||
|     const game = await window.electron.startGameDownload(payload); | ||||
|     const response = await window.electron.startGameDownload(payload); | ||||
| 
 | ||||
|     await updateLibrary(); | ||||
|     return game; | ||||
|     if (response.ok) updateLibrary(); | ||||
| 
 | ||||
|     return response; | ||||
|   }; | ||||
| 
 | ||||
|   const pauseDownload = async (shop: GameShop, objectId: string) => { | ||||
|  | @ -113,7 +114,7 @@ export function useDownload() { | |||
|     pauseSeeding, | ||||
|     resumeSeeding, | ||||
|     clearDownload: () => dispatch(clearDownload()), | ||||
|     setLastPacket: (packet: DownloadProgress) => | ||||
|     setLastPacket: (packet: DownloadProgress | null) => | ||||
|       dispatch(setLastPacket(packet)), | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -6,11 +6,13 @@ export function useToast() { | |||
|   const dispatch = useAppDispatch(); | ||||
| 
 | ||||
|   const showSuccessToast = useCallback( | ||||
|     (message: string) => { | ||||
|     (title: string, message?: string, duration?: number) => { | ||||
|       dispatch( | ||||
|         showToast({ | ||||
|           title, | ||||
|           message, | ||||
|           type: "success", | ||||
|           duration, | ||||
|         }) | ||||
|       ); | ||||
|     }, | ||||
|  | @ -18,11 +20,13 @@ export function useToast() { | |||
|   ); | ||||
| 
 | ||||
|   const showErrorToast = useCallback( | ||||
|     (message: string) => { | ||||
|     (title: string, message?: string, duration?: number) => { | ||||
|       dispatch( | ||||
|         showToast({ | ||||
|           title, | ||||
|           message, | ||||
|           type: "error", | ||||
|           duration, | ||||
|         }) | ||||
|       ); | ||||
|     }, | ||||
|  | @ -30,11 +34,13 @@ export function useToast() { | |||
|   ); | ||||
| 
 | ||||
|   const showWarningToast = useCallback( | ||||
|     (message: string) => { | ||||
|     (title: string, message?: string, duration?: number) => { | ||||
|       dispatch( | ||||
|         showToast({ | ||||
|           title, | ||||
|           message, | ||||
|           type: "warning", | ||||
|           duration, | ||||
|         }) | ||||
|       ); | ||||
|     }, | ||||
|  |  | |||
|  | @ -78,9 +78,15 @@ export function useUserDetails() { | |||
|         ...response, | ||||
|         username: userDetails?.username || "", | ||||
|         subscription: userDetails?.subscription || null, | ||||
|         featurebaseJwt: userDetails?.featurebaseJwt || "", | ||||
|       }); | ||||
|     }, | ||||
|     [updateUserDetails, userDetails?.username, userDetails?.subscription] | ||||
|     [ | ||||
|       updateUserDetails, | ||||
|       userDetails?.username, | ||||
|       userDetails?.subscription, | ||||
|       userDetails?.featurebaseJwt, | ||||
|     ] | ||||
|   ); | ||||
| 
 | ||||
|   const syncFriendRequests = useCallback(async () => { | ||||
|  |  | |||
|  | @ -48,6 +48,7 @@ Sentry.init({ | |||
|   tracesSampleRate: 1.0, | ||||
|   replaysSessionSampleRate: 0.1, | ||||
|   replaysOnErrorSampleRate: 1.0, | ||||
|   release: await window.electron.getVersion(), | ||||
| }); | ||||
| 
 | ||||
| console.log = logger.log; | ||||
|  |  | |||
|  | @ -10,7 +10,9 @@ interface AchievementListProps { | |||
|   achievements: UserAchievement[]; | ||||
| } | ||||
| 
 | ||||
| export function AchievementList({ achievements }: AchievementListProps) { | ||||
| export function AchievementList({ | ||||
|   achievements, | ||||
| }: Readonly<AchievementListProps>) { | ||||
|   const { t } = useTranslation("achievement"); | ||||
|   const { showHydraCloudModal } = useSubscription(); | ||||
|   const { formatDateTime } = useDate(); | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| .achievement-panel { | ||||
|   width: 100%; | ||||
|   padding: globals.$spacing-unit * 2 globals.$spacing-unit * 3; | ||||
|   background-color: globals.$dark-background-color; | ||||
|   background-color: globals.$background-color; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: start; | ||||
|  |  | |||
|  | @ -66,9 +66,9 @@ $logo-max-width: 200px; | |||
| 
 | ||||
|   &__table-header { | ||||
|     width: 100%; | ||||
|     background-color: var(--color-dark-background); | ||||
|     background-color: globals.$dark-background-color; | ||||
|     transition: all ease 0.2s; | ||||
|     border-bottom: solid 1px var(--color-border); | ||||
|     border-bottom: solid 1px globals.$border-color; | ||||
|     position: sticky; | ||||
|     top: 0; | ||||
|     z-index: 1; | ||||
|  | @ -86,13 +86,13 @@ $logo-max-width: 200px; | |||
|     gap: globals.$spacing-unit * 2; | ||||
|     padding: globals.$spacing-unit * 2; | ||||
|     width: 100%; | ||||
|     background-color: var(--color-background); | ||||
|     background-color: globals.$background-color; | ||||
|   } | ||||
| 
 | ||||
|   &__item { | ||||
|     display: flex; | ||||
|     transition: all ease 0.1s; | ||||
|     color: var(--color-muted); | ||||
|     color: globals.$muted-color; | ||||
|     width: 100%; | ||||
|     overflow: hidden; | ||||
|     border-radius: 4px; | ||||
|  | @ -102,7 +102,7 @@ $logo-max-width: 200px; | |||
|     text-align: left; | ||||
| 
 | ||||
|     &:hover { | ||||
|       background-color: rgba(255, 255, 255, 0.15); | ||||
|       background-color: globals.$border-color; | ||||
|       text-decoration: none; | ||||
|     } | ||||
| 
 | ||||
|  | @ -129,7 +129,7 @@ $logo-max-width: 200px; | |||
| 
 | ||||
|     &-hidden-icon { | ||||
|       display: flex; | ||||
|       color: var(--color-warning); | ||||
|       color: globals.$warning-color; | ||||
|       opacity: 0.8; | ||||
| 
 | ||||
|       &:hover { | ||||
|  | @ -164,7 +164,7 @@ $logo-max-width: 200px; | |||
| 
 | ||||
|       &--locked { | ||||
|         cursor: pointer; | ||||
|         color: var(--color-warning); | ||||
|         color: globals.$warning-color; | ||||
|       } | ||||
| 
 | ||||
|       &-icon { | ||||
|  | @ -219,12 +219,12 @@ $logo-max-width: 200px; | |||
|     transition: all ease 0.2s; | ||||
| 
 | ||||
|     &::-webkit-progress-bar { | ||||
|       background-color: rgba(255, 255, 255, 0.15); | ||||
|       background-color: globals.$border-color; | ||||
|       border-radius: 4px; | ||||
|     } | ||||
| 
 | ||||
|     &::-webkit-progress-value { | ||||
|       background-color: var(--color-muted); | ||||
|       background-color: globals.$muted-color; | ||||
|       border-radius: 4px; | ||||
|     } | ||||
|   } | ||||
|  | @ -236,7 +236,7 @@ $logo-max-width: 200px; | |||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     background-color: var(--color-background); | ||||
|     background-color: globals.$background-color; | ||||
|     position: relative; | ||||
|     object-fit: cover; | ||||
| 
 | ||||
|  | @ -252,7 +252,7 @@ $logo-max-width: 200px; | |||
|     justify-content: center; | ||||
|     width: 100%; | ||||
|     gap: math.div(globals.$spacing-unit, 2); | ||||
|     color: var(--color-body); | ||||
|     color: globals.$muted-color; | ||||
|     cursor: pointer; | ||||
| 
 | ||||
|     &:hover { | ||||
|  |  | |||
|  | @ -30,6 +30,8 @@ import { | |||
|   XCircleIcon, | ||||
| } from "@primer/octicons-react"; | ||||
| 
 | ||||
| import torBoxLogo from "@renderer/assets/icons/torbox.webp"; | ||||
| 
 | ||||
| export interface DownloadGroupProps { | ||||
|   library: LibraryGame[]; | ||||
|   title: string; | ||||
|  | @ -235,12 +237,16 @@ export function DownloadGroup({ | |||
|       ]; | ||||
|     } | ||||
| 
 | ||||
|     const isResumeDisabled = | ||||
|       (download?.downloader === Downloader.RealDebrid && | ||||
|         !userPreferences?.realDebridApiToken) || | ||||
|       (download?.downloader === Downloader.TorBox && | ||||
|         !userPreferences?.torBoxApiToken); | ||||
| 
 | ||||
|     return [ | ||||
|       { | ||||
|         label: t("resume"), | ||||
|         disabled: | ||||
|           download?.downloader === Downloader.RealDebrid && | ||||
|           !userPreferences?.realDebridApiToken, | ||||
|         disabled: isResumeDisabled, | ||||
|         onClick: () => { | ||||
|           resumeDownload(game.shop, game.objectId); | ||||
|         }, | ||||
|  | @ -279,13 +285,20 @@ export function DownloadGroup({ | |||
|                   /> | ||||
| 
 | ||||
|                   <div className="download-group__cover-content"> | ||||
|                     <Badge> | ||||
|                       { | ||||
|                         DOWNLOADER_NAME[ | ||||
|                           game?.download?.downloader as Downloader | ||||
|                         ] | ||||
|                       } | ||||
|                     </Badge> | ||||
|                     {game.download?.downloader === Downloader.TorBox ? ( | ||||
|                       <Badge> | ||||
|                         <img | ||||
|                           src={torBoxLogo} | ||||
|                           alt="TorBox" | ||||
|                           style={{ width: 13 }} | ||||
|                         /> | ||||
|                         <span>TorBox</span> | ||||
|                       </Badge> | ||||
|                     ) : ( | ||||
|                       <Badge> | ||||
|                         {DOWNLOADER_NAME[game.download!.downloader]} | ||||
|                       </Badge> | ||||
|                     )} | ||||
|                   </div> | ||||
|                 </div> | ||||
|               </div> | ||||
|  |  | |||
|  | @ -8,7 +8,7 @@ import "./downloads.scss"; | |||
| import { DeleteGameModal } from "./delete-game-modal"; | ||||
| import { DownloadGroup } from "./download-group"; | ||||
| import type { GameShop, LibraryGame, SeedingStatus } from "@types"; | ||||
| import { orderBy } from "lodash-es"; | ||||
| import { orderBy, sortBy } from "lodash-es"; | ||||
| import { ArrowDownIcon } from "@primer/octicons-react"; | ||||
| 
 | ||||
| export default function Downloads() { | ||||
|  | @ -58,21 +58,24 @@ export default function Downloads() { | |||
|       complete: [], | ||||
|     }; | ||||
| 
 | ||||
|     const result = library.reduce((prev, next) => { | ||||
|       /* Game has been manually added to the library or has been canceled */ | ||||
|       if (!next.download?.status || next.download?.status === "removed") | ||||
|         return prev; | ||||
|     const result = sortBy(library, (game) => game.download?.timestamp).reduce( | ||||
|       (prev, next) => { | ||||
|         /* Game has been manually added to the library or has been canceled */ | ||||
|         if (!next.download?.status || next.download?.status === "removed") | ||||
|           return prev; | ||||
| 
 | ||||
|       /* Is downloading */ | ||||
|       if (lastPacket?.gameId === next.id) | ||||
|         return { ...prev, downloading: [...prev.downloading, next] }; | ||||
|         /* Is downloading */ | ||||
|         if (lastPacket?.gameId === next.id) | ||||
|           return { ...prev, downloading: [...prev.downloading, next] }; | ||||
| 
 | ||||
|       /* Is either queued or paused */ | ||||
|       if (next.download.queued || next.download?.status === "paused") | ||||
|         return { ...prev, queued: [...prev.queued, next] }; | ||||
|         /* Is either queued or paused */ | ||||
|         if (next.download.queued || next.download?.status === "paused") | ||||
|           return { ...prev, queued: [...prev.queued, next] }; | ||||
| 
 | ||||
|       return { ...prev, complete: [...prev.complete, next] }; | ||||
|     }, initialValue); | ||||
|         return { ...prev, complete: [...prev.complete, next] }; | ||||
|       }, | ||||
|       initialValue | ||||
|     ); | ||||
| 
 | ||||
|     const queued = orderBy(result.queued, (game) => game.download?.timestamp, [ | ||||
|       "desc", | ||||
|  |  | |||
|  | @ -189,14 +189,6 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { | |||
|         </Button> | ||||
|       </div> | ||||
| 
 | ||||
|       {uploadingBackup && ( | ||||
|         <progress | ||||
|           className="cloud-sync-modal__progress" | ||||
|           value={backupDownloadProgress?.progress ?? 0} | ||||
|           max={100} | ||||
|         /> | ||||
|       )} | ||||
| 
 | ||||
|       <div className="cloud-sync-modal__backups-header"> | ||||
|         <h2>{t("backups")}</h2> | ||||
|         <small> | ||||
|  |  | |||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue