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,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  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,9 +76,10 @@ const startGameDownload = async (
 | 
			
		|||
    queued: true,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  await downloadsSublevel.put(gameKey, download);
 | 
			
		||||
 | 
			
		||||
  await DownloadManager.startDownload(download);
 | 
			
		||||
  try {
 | 
			
		||||
    await DownloadManager.startDownload(download).then(() => {
 | 
			
		||||
      return downloadsSublevel.put(gameKey, download);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const updatedGame = await gamesSublevel.get(gameKey);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -92,6 +94,37 @@ const startGameDownload = async (
 | 
			
		|||
        { 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,7 +111,9 @@ const migrateFromSqlite = async () => {
 | 
			
		|||
      if (userPreferences.length > 0) {
 | 
			
		||||
        const { realDebridApiToken, ...rest } = userPreferences[0];
 | 
			
		||||
 | 
			
		||||
        await db.put(levelKeys.userPreferences, {
 | 
			
		||||
        await db.put<string, UserPreferences>(
 | 
			
		||||
          levelKeys.userPreferences,
 | 
			
		||||
          {
 | 
			
		||||
            ...rest,
 | 
			
		||||
            realDebridApiToken,
 | 
			
		||||
            preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1,
 | 
			
		||||
| 
						 | 
				
			
			@ -106,12 +123,15 @@ const migrateFromSqlite = async () => {
 | 
			
		|||
            seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1,
 | 
			
		||||
            showHiddenAchievementsDescription:
 | 
			
		||||
              rest.showHiddenAchievementsDescription === 1,
 | 
			
		||||
          downloadNotificationsEnabled: rest.downloadNotificationsEnabled === 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,7 +35,6 @@ export const mergeWithRemoteGames = async () => {
 | 
			
		|||
            name: "getById",
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          if (steamGame) {
 | 
			
		||||
          const iconUrl = steamGame?.clientIcon
 | 
			
		||||
            ? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
 | 
			
		||||
            : null;
 | 
			
		||||
| 
						 | 
				
			
			@ -52,7 +51,6 @@ export const mergeWithRemoteGames = async () => {
 | 
			
		|||
          });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    .catch(() => {});
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
      <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,38 +234,15 @@ 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"
 | 
			
		||||
              {filteredLibrary
 | 
			
		||||
                .filter((game) => !game.favorite)
 | 
			
		||||
                .map((game) => (
 | 
			
		||||
                  <SidebarGameItem
 | 
			
		||||
                    key={game.id}
 | 
			
		||||
                    game={game}
 | 
			
		||||
                    handleSidebarGameClick={handleSidebarGameClick}
 | 
			
		||||
                    getGameTitle={getGameTitle}
 | 
			
		||||
                  />
 | 
			
		||||
                    ) : (
 | 
			
		||||
                      <SteamLogo className="sidebar__game-icon" />
 | 
			
		||||
                    )}
 | 
			
		||||
 | 
			
		||||
                    <span className="sidebar__menu-item-button-label">
 | 
			
		||||
                      {getGameTitle(game)}
 | 
			
		||||
                    </span>
 | 
			
		||||
                  </button>
 | 
			
		||||
                </li>
 | 
			
		||||
                ))}
 | 
			
		||||
            </ul>
 | 
			
		||||
          </section>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
  &__icon {
 | 
			
		||||
    &--success {
 | 
			
		||||
      color: globals.$success-color;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  &__error-icon {
 | 
			
		||||
    &--error {
 | 
			
		||||
      color: globals.$danger-color;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  &__warning-icon {
 | 
			
		||||
    &--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,17 +88,27 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
 | 
			
		|||
    >
 | 
			
		||||
      <div className="toast__content">
 | 
			
		||||
        <div className="toast__message-container">
 | 
			
		||||
          <div
 | 
			
		||||
            style={{
 | 
			
		||||
              display: "flex",
 | 
			
		||||
              justifyContent: "space-between",
 | 
			
		||||
              alignItems: "center",
 | 
			
		||||
              gap: `8px`,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            {type === "success" && (
 | 
			
		||||
            <CheckCircleFillIcon className="toast__success-icon" />
 | 
			
		||||
              <CheckCircleFillIcon className="toast__icon--success" />
 | 
			
		||||
            )}
 | 
			
		||||
 | 
			
		||||
            {type === "error" && (
 | 
			
		||||
            <XCircleFillIcon className="toast__error-icon" />
 | 
			
		||||
              <XCircleFillIcon className="toast__icon--error" />
 | 
			
		||||
            )}
 | 
			
		||||
 | 
			
		||||
          {type === "warning" && <AlertIcon className="toast__warning-icon" />}
 | 
			
		||||
          <span className="toast__message">{message}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
            {type === "warning" && (
 | 
			
		||||
              <AlertIcon className="toast__icon--warning" />
 | 
			
		||||
            )}
 | 
			
		||||
 | 
			
		||||
            <span style={{ fontWeight: "bold", flex: 1 }}>{title}</span>
 | 
			
		||||
 | 
			
		||||
            <button
 | 
			
		||||
              type="button"
 | 
			
		||||
| 
						 | 
				
			
			@ -106,6 +120,10 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
 | 
			
		|||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {message && <p>{message}</p>}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <progress className="toast__progress" value={progress} max={100} />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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">
 | 
			
		||||
                    {game.download?.downloader === Downloader.TorBox ? (
 | 
			
		||||
                      <Badge>
 | 
			
		||||
                      {
 | 
			
		||||
                        DOWNLOADER_NAME[
 | 
			
		||||
                          game?.download?.downloader as Downloader
 | 
			
		||||
                        ]
 | 
			
		||||
                      }
 | 
			
		||||
                        <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,7 +58,8 @@ export default function Downloads() {
 | 
			
		|||
      complete: [],
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const result = library.reduce((prev, next) => {
 | 
			
		||||
    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;
 | 
			
		||||
| 
						 | 
				
			
			@ -72,7 +73,9 @@ export default function Downloads() {
 | 
			
		|||
          return { ...prev, queued: [...prev.queued, next] };
 | 
			
		||||
 | 
			
		||||
        return { ...prev, complete: [...prev.complete, next] };
 | 
			
		||||
    }, initialValue);
 | 
			
		||||
      },
 | 
			
		||||
      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