diff --git a/.gitignore b/.gitignore index 36e620fc..f9f32977 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ aria2/ # Sentry Config File .env.sentry-build-plugin + +*storybook.log diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d7362c91..e2af60ce 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -37,6 +37,13 @@ export default defineConfig(({ mode }) => { build: { sourcemap: true, }, + css: { + preprocessorOptions: { + scss: { + api: "modern", + }, + }, + }, resolve: { alias: { "@renderer": resolve("src/renderer/src"), diff --git a/package.json b/package.json index 1cbb77bb..c8bd338f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "auto-launch": "^5.0.6", "axios": "^1.7.9", "better-sqlite3": "^11.7.0", + "classic-level": "^2.0.0", "classnames": "^2.5.1", "color": "^4.2.3", "color.js": "^1.2.0", @@ -71,7 +72,6 @@ "sound-play": "^1.1.0", "sudo-prompt": "^9.2.1", "tar": "^7.4.3", - "typeorm": "^0.3.20", "user-agents": "^1.1.387", "yaml": "^2.6.1", "yup": "^1.5.0", diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py index 40e30ccd..71e4b57e 100644 --- a/python_rpc/http_downloader.py +++ b/python_rpc/http_downloader.py @@ -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): diff --git a/python_rpc/main.py b/python_rpc/main.py index 03df83de..2deb2029 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -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: diff --git a/python_rpc/torrent_downloader.py b/python_rpc/torrent_downloader.py index ca4c2fa8..8de8764e 100644 --- a/python_rpc/torrent_downloader.py +++ b/python_rpc/torrent_downloader.py @@ -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() diff --git a/src/locales/ar/translation.json b/src/locales/ar/translation.json index 15a24a7d..a12b3559 100644 --- a/src/locales/ar/translation.json +++ b/src/locales/ar/translation.json @@ -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>هنا", + "debrid_invalid_token": "رمز API غير صالح", + "debrid_api_token_hint": "يمكنك الحصول على رمز API الخاص بك <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 يحتوي على روابط التنزيل.", diff --git a/src/locales/bg/translation.json b/src/locales/bg/translation.json index b68e60da..857d3ed4 100644 --- a/src/locales/bg/translation.json +++ b/src/locales/bg/translation.json @@ -230,13 +230,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>тук", + "debrid_invalid_token": "Невалиден API токен", + "debrid_api_token_hint": "Вземете своя API токен <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 файл, съдържащ връзките за изтегляне.", diff --git a/src/locales/ca/translation.json b/src/locales/ca/translation.json index acf4b3c7..6d689f2b 100644 --- a/src/locales/ca/translation.json +++ b/src/locales/ca/translation.json @@ -161,13 +161,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í.", + "debrid_invalid_token": "Invalida el testimoni de l'API", + "debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí.", "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.", diff --git a/src/locales/cs/translation.json b/src/locales/cs/translation.json index c1291444..b3543f94 100644 --- a/src/locales/cs/translation.json +++ b/src/locales/cs/translation.json @@ -214,13 +214,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", + "debrid_invalid_token": "Neplatný API token", + "debrid_api_token_hint": "API token můžeš sehnat <0>zde", "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.", diff --git a/src/locales/da/translation.json b/src/locales/da/translation.json index 711c81a3..9a7c700f 100644 --- a/src/locales/da/translation.json +++ b/src/locales/da/translation.json @@ -177,13 +177,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", + "debrid_invalid_token": "Ugyldig API nøgle", + "debrid_api_token_hint": "Du kan få din API nøgle <0>her", "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.", diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index bf1eff60..c94b0913 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -161,13 +161,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 kannst du dir deinen API Token holen", + "debrid_invalid_token": "API token nicht gültig", + "debrid_api_token_hint": "<0>Hier 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.", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 1baf4614..bbe922f2 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -184,7 +184,11 @@ "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", @@ -236,13 +240,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", + "debrid_invalid_token": "Invalid API token", + "debrid_api_token_hint": "You can get your API token <0>here", "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.", @@ -296,7 +300,11 @@ "become_subscriber": "Be Hydra Cloud", "subscription_renew_cancelled": "Automatic renewal is disabled", "subscription_renews_on": "Your subscription renews on {{date}}", - "bill_sent_until": "Your next bill will be sent until this day" + "bill_sent_until": "Your next bill will be sent until this day", + "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", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 931ee058..43839e9e 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -175,7 +175,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 +236,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í", + "debrid_invalid_token": "Token de API inválido", + "debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí", "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 +280,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", diff --git a/src/locales/et/translation.json b/src/locales/et/translation.json index 91b4a63a..97e69a90 100644 --- a/src/locales/et/translation.json +++ b/src/locales/et/translation.json @@ -213,13 +213,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", + "debrid_invalid_token": "Vigane API võti", + "debrid_api_token_hint": "Sa saad oma API võtme <0>siit", "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.", diff --git a/src/locales/fa/translation.json b/src/locales/fa/translation.json index 2409635f..9d4f7280 100644 --- a/src/locales/fa/translation.json +++ b/src/locales/fa/translation.json @@ -110,7 +110,7 @@ "general": "کلی", "behavior": "رفتار", "enable_real_debrid": "فعال‌سازی Real-Debrid", - "real_debrid_api_token_hint": "کلید API خود را از <ب0>اینجا بگیرید.", + "debrid_api_token_hint": "کلید API خود را از <ب0>اینجا بگیرید.", "save_changes": "ذخیره تغییرات" }, "notifications": { diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index ba4a06f1..a813d770 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -161,13 +161,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", + "debrid_invalid_token": "Token API tidak valid", + "debrid_api_token_hint": "Kamu bisa dapatkan token API di <0>sini", "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.", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index cf763320..742f889f 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -118,7 +118,7 @@ "general": "Generale", "behavior": "Comportamento", "enable_real_debrid": "Abilita Real Debrid", - "real_debrid_api_token_hint": "Puoi trovare la tua chiave API <0>here", + "debrid_api_token_hint": "Puoi trovare la tua chiave API <0>here", "save_changes": "Salva modifiche" }, "notifications": { diff --git a/src/locales/kk/translation.json b/src/locales/kk/translation.json index 6d5d8404..e66cd5ed 100644 --- a/src/locales/kk/translation.json +++ b/src/locales/kk/translation.json @@ -159,13 +159,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>осы жерден алуға болады", + "debrid_invalid_token": "Қате API кілті", + "debrid_api_token_hint": "API кілтін <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 файлына тікелей сілтеме болуы керек.", diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index 2656bb45..bc8e6056 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -110,7 +110,7 @@ "general": "일반", "behavior": "행동", "enable_real_debrid": "Real-Debrid 활성화", - "real_debrid_api_token_hint": "API 키를 <0>이곳에서 얻으세요.", + "debrid_api_token_hint": "API 키를 <0>이곳에서 얻으세요.", "save_changes": "변경 사항 저장" }, "notifications": { diff --git a/src/locales/nb/translation.json b/src/locales/nb/translation.json index 5c5f6882..a6fb1bcc 100644 --- a/src/locales/nb/translation.json +++ b/src/locales/nb/translation.json @@ -177,13 +177,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", + "debrid_invalid_token": "Ugyldig API nøkkel", + "debrid_api_token_hint": "Du kan få API nøkkelen din <0>her", "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.", diff --git a/src/locales/nl/translation.json b/src/locales/nl/translation.json index bb7abf59..6d9de5fa 100644 --- a/src/locales/nl/translation.json +++ b/src/locales/nl/translation.json @@ -111,7 +111,7 @@ "general": "Algemeen", "behavior": "Gedrag", "enable_real_debrid": "Enable Real-Debrid", - "real_debrid_api_token_hint": "U kunt uw API-sleutel <0>hier verkrijgen.", + "debrid_api_token_hint": "U kunt uw API-sleutel <0>hier verkrijgen.", "save_changes": "Wijzigingen opslaan" }, "notifications": { diff --git a/src/locales/pl/translation.json b/src/locales/pl/translation.json index 945c857a..bdaf822c 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -119,7 +119,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", + "debrid_api_token_hint": "Możesz uzyskać swój klucz API <0>tutaj", "save_changes": "Zapisz zmiany" }, "notifications": { diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 9e1021fc..b34f1874 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -172,7 +172,12 @@ "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", @@ -224,13 +229,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", + "debrid_api_token_hint": "Você pode obter seu token de API <0>aqui", "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 +289,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", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index ce081b3f..3b8496ae 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -205,13 +205,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", + "debrid_api_token_hint": "Podes obter o teu token de API <0>aqui", "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.", diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json index 69d14071..9003ecc6 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -124,13 +124,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", + "debrid_invalid_token": "Token API invalid", + "debrid_api_token_hint": "Poți obține token-ul tău API <0>aici", "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" }, diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 1b48c5e0..18b8f1f5 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -237,13 +237,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>здесь", + "debrid_invalid_token": "Неверный API ключ", + "debrid_api_token_hint": "API ключ можно получить <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-файл с ссылками для загрузок.", diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index 6fa89c03..9095b693 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -236,13 +236,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 alabilirsiniz", + "debrid_invalid_token": "Geçersiz API anahtarı", + "debrid_api_token_hint": "API anahtarınızı <0>buradan 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.", diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index ed4b3d58..174e768c 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -174,13 +174,13 @@ "import": "Імпортувати", "insert_valid_json_url": "Вставте дійсний URL JSON-файлу", "language": "Мова", - "real_debrid_api_token": "API-токен", - "real_debrid_api_token_hint": "API токен можливо отримати <0>тут", + "api_token": "API-токен", + "debrid_api_token_hint": "API токен можливо отримати <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": "Зберегти зміни", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 664877fa..ba48a21b 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -213,13 +213,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>这里获取API密钥.", + "debrid_invalid_token": "无效的 API 令牌", + "debrid_api_token_hint": "您可以从<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 文件。", diff --git a/src/main/constants.ts b/src/main/constants.ts index b98b5935..66bf7af9 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -7,13 +7,18 @@ export const defaultDownloadsPath = app.getPath("downloads"); export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging"); +export const levelDatabasePath = path.join( + app.getPath("userData"), + `hydra-db${isStaging ? "-staging" : ""}` +); + export const databaseDirectory = path.join(app.getPath("appData"), "hydra"); export const databasePath = path.join( databaseDirectory, isStaging ? "hydra_test.db" : "hydra.db" ); -export const logsPath = path.join(app.getPath("appData"), "hydra", "logs"); +export const logsPath = path.join(app.getPath("userData"), "logs"); export const seedsPath = app.isPackaged ? path.join(process.resourcesPath, "seeds") diff --git a/src/main/data-source.ts b/src/main/data-source.ts deleted file mode 100644 index 51c8522e..00000000 --- a/src/main/data-source.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { DataSource } from "typeorm"; -import { - DownloadQueue, - Game, - GameShopCache, - UserPreferences, - UserAuth, - GameAchievement, - UserSubscription, -} from "@main/entity"; - -import { databasePath } from "./constants"; - -export const dataSource = new DataSource({ - type: "better-sqlite3", - entities: [ - Game, - UserAuth, - UserPreferences, - UserSubscription, - GameShopCache, - DownloadQueue, - GameAchievement, - ], - synchronize: false, - database: databasePath, -}); diff --git a/src/main/entity/download-queue.entity.ts b/src/main/entity/download-queue.entity.ts deleted file mode 100644 index cf618947..00000000 --- a/src/main/entity/download-queue.entity.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - CreateDateColumn, - UpdateDateColumn, - OneToOne, - JoinColumn, -} from "typeorm"; -import type { Game } from "./game.entity"; - -@Entity("download_queue") -export class DownloadQueue { - @PrimaryGeneratedColumn() - id: number; - - @OneToOne("Game", "downloadQueue") - @JoinColumn() - game: Game; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/entity/game-achievements.entity.ts b/src/main/entity/game-achievements.entity.ts deleted file mode 100644 index 0cb15f6e..00000000 --- a/src/main/entity/game-achievements.entity.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; - -@Entity("game_achievement") -export class GameAchievement { - @PrimaryGeneratedColumn() - id: number; - - @Column("text") - objectId: string; - - @Column("text") - shop: string; - - @Column("text", { nullable: true }) - unlockedAchievements: string | null; - - @Column("text", { nullable: true }) - achievements: string | null; -} diff --git a/src/main/entity/game-shop-cache.entity.ts b/src/main/entity/game-shop-cache.entity.ts deleted file mode 100644 index 3382da1c..00000000 --- a/src/main/entity/game-shop-cache.entity.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - Entity, - PrimaryColumn, - Column, - CreateDateColumn, - UpdateDateColumn, -} from "typeorm"; -import type { GameShop } from "@types"; - -@Entity("game_shop_cache") -export class GameShopCache { - @PrimaryColumn("text", { unique: true }) - objectID: string; - - @Column("text") - shop: GameShop; - - @Column("text", { nullable: true }) - serializedData: string; - - /** - * @deprecated Use IndexedDB's `howLongToBeatEntries` instead - */ - @Column("text", { nullable: true }) - howLongToBeatSerializedData: string; - - @Column("text", { nullable: true }) - language: string; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts deleted file mode 100644 index 0fcdcc77..00000000 --- a/src/main/entity/game.entity.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToOne, -} from "typeorm"; - -import type { GameShop, GameStatus } from "@types"; -import { Downloader } from "@shared"; -import type { DownloadQueue } from "./download-queue.entity"; - -@Entity("game") -export class Game { - @PrimaryGeneratedColumn() - id: number; - - @Column("text", { unique: true }) - objectID: string; - - @Column("text", { unique: true, nullable: true }) - remoteId: string | null; - - @Column("text") - title: string; - - @Column("text", { nullable: true }) - iconUrl: string | null; - - @Column("text", { nullable: true }) - folderName: string | null; - - @Column("text", { nullable: true }) - downloadPath: string | null; - - @Column("text", { nullable: true }) - executablePath: string | null; - - @Column("text", { nullable: true }) - launchOptions: string | null; - - @Column("text", { nullable: true }) - winePrefixPath: string | null; - - @Column("int", { default: 0 }) - playTimeInMilliseconds: number; - - @Column("text") - shop: GameShop; - - @Column("text", { nullable: true }) - status: GameStatus | null; - - @Column("int", { default: Downloader.Torrent }) - downloader: Downloader; - - /** - * Progress is a float between 0 and 1 - */ - @Column("float", { default: 0 }) - progress: number; - - @Column("int", { default: 0 }) - bytesDownloaded: number; - - @Column("datetime", { nullable: true }) - lastTimePlayed: Date | null; - - @Column("float", { default: 0 }) - fileSize: number; - - @Column("text", { nullable: true }) - uri: string | null; - - @OneToOne("DownloadQueue", "game") - downloadQueue: DownloadQueue; - - @Column("boolean", { default: false }) - isDeleted: boolean; - - @Column("boolean", { default: false }) - shouldSeed: boolean; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/entity/index.ts b/src/main/entity/index.ts deleted file mode 100644 index 1625ac8a..00000000 --- a/src/main/entity/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from "./game.entity"; -export * from "./user-auth.entity"; -export * from "./user-preferences.entity"; -export * from "./user-subscription.entity"; -export * from "./game-shop-cache.entity"; -export * from "./game.entity"; -export * from "./game-achievements.entity"; -export * from "./download-queue.entity"; diff --git a/src/main/entity/user-auth.entity.ts b/src/main/entity/user-auth.entity.ts deleted file mode 100644 index f34e23ec..00000000 --- a/src/main/entity/user-auth.entity.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToOne, -} from "typeorm"; -import { UserSubscription } from "./user-subscription.entity"; - -@Entity("user_auth") -export class UserAuth { - @PrimaryGeneratedColumn() - id: number; - - @Column("text", { default: "" }) - userId: string; - - @Column("text", { default: "" }) - displayName: string; - - @Column("text", { nullable: true }) - profileImageUrl: string | null; - - @Column("text", { nullable: true }) - backgroundImageUrl: string | null; - - @Column("text", { default: "" }) - accessToken: string; - - @Column("text", { default: "" }) - refreshToken: string; - - @Column("int", { default: 0 }) - tokenExpirationTimestamp: number; - - @OneToOne("UserSubscription", "user") - subscription: UserSubscription | null; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/entity/user-preferences.entity.ts b/src/main/entity/user-preferences.entity.ts deleted file mode 100644 index a850b42f..00000000 --- a/src/main/entity/user-preferences.entity.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, -} from "typeorm"; - -@Entity("user_preferences") -export class UserPreferences { - @PrimaryGeneratedColumn() - id: number; - - @Column("text", { nullable: true }) - downloadsPath: string | null; - - @Column("text", { default: "en" }) - language: string; - - @Column("text", { nullable: true }) - realDebridApiToken: string | null; - - @Column("boolean", { default: false }) - downloadNotificationsEnabled: boolean; - - @Column("boolean", { default: false }) - repackUpdatesNotificationsEnabled: boolean; - - @Column("boolean", { default: true }) - achievementNotificationsEnabled: boolean; - - @Column("boolean", { default: false }) - preferQuitInsteadOfHiding: boolean; - - @Column("boolean", { default: false }) - runAtStartup: boolean; - - @Column("boolean", { default: false }) - startMinimized: boolean; - - @Column("boolean", { default: false }) - disableNsfwAlert: boolean; - - @Column("boolean", { default: true }) - seedAfterDownloadComplete: boolean; - - @Column("boolean", { default: false }) - showHiddenAchievementsDescription: boolean; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/entity/user-subscription.entity.ts b/src/main/entity/user-subscription.entity.ts deleted file mode 100644 index e74ada48..00000000 --- a/src/main/entity/user-subscription.entity.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { SubscriptionStatus } from "@types"; -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToOne, - JoinColumn, -} from "typeorm"; -import { UserAuth } from "./user-auth.entity"; - -@Entity("user_subscription") -export class UserSubscription { - @PrimaryGeneratedColumn() - id: number; - - @Column("text", { default: "" }) - subscriptionId: string; - - @OneToOne("UserAuth", "subscription") - @JoinColumn() - user: UserAuth; - - @Column("text", { default: "" }) - status: SubscriptionStatus; - - @Column("text", { default: "" }) - planId: string; - - @Column("text", { default: "" }) - planName: string; - - @Column("datetime", { nullable: true }) - expiresAt: Date | null; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/events/auth/get-session-hash.ts b/src/main/events/auth/get-session-hash.ts index c9dd39cc..c81e0965 100644 --- a/src/main/events/auth/get-session-hash.ts +++ b/src/main/events/auth/get-session-hash.ts @@ -1,13 +1,19 @@ import jwt from "jsonwebtoken"; -import { userAuthRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { db, levelKeys } from "@main/level"; +import type { Auth } from "@types"; +import { Crypto } from "@main/services"; const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => { - const auth = await userAuthRepository.findOne({ where: { id: 1 } }); + const auth = await db.get(levelKeys.auth, { + valueEncoding: "json", + }); if (!auth) return null; - const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload; + const payload = jwt.decode( + Crypto.decrypt(auth.accessToken) + ) as jwt.JwtPayload; if (!payload) return null; diff --git a/src/main/events/auth/sign-out.ts b/src/main/events/auth/sign-out.ts index 6b720015..2ab5e458 100644 --- a/src/main/events/auth/sign-out.ts +++ b/src/main/events/auth/sign-out.ts @@ -1,35 +1,29 @@ import { registerEvent } from "../register-event"; import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services"; -import { dataSource } from "@main/data-source"; -import { DownloadQueue, Game, UserAuth, UserSubscription } from "@main/entity"; -import { PythonRPC } from "@main/services/python-rpc"; +import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; const signOut = async (_event: Electron.IpcMainInvokeEvent) => { - const databaseOperations = dataSource - .transaction(async (transactionalEntityManager) => { - await transactionalEntityManager.getRepository(DownloadQueue).delete({}); - - await transactionalEntityManager.getRepository(Game).delete({}); - - await transactionalEntityManager - .getRepository(UserAuth) - .delete({ id: 1 }); - - await transactionalEntityManager - .getRepository(UserSubscription) - .delete({ id: 1 }); - }) + const databaseOperations = db + .batch([ + { + type: "del", + key: levelKeys.auth, + }, + { + type: "del", + key: levelKeys.user, + }, + ]) .then(() => { /* Removes all games being played */ gamesPlaytime.clear(); + + return Promise.all([gamesSublevel.clear(), downloadsSublevel.clear()]); }); /* Cancels any ongoing downloads */ DownloadManager.cancelDownload(); - /* Disconnects libtorrent */ - PythonRPC.kill(); - HydraApi.handleSignOut(); await Promise.all([ diff --git a/src/main/events/autoupdater/check-for-updates.ts b/src/main/events/autoupdater/check-for-updates.ts index 1dcc80f3..7ea60d0b 100644 --- a/src/main/events/autoupdater/check-for-updates.ts +++ b/src/main/events/autoupdater/check-for-updates.ts @@ -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); diff --git a/src/main/events/catalogue/get-game-shop-details.ts b/src/main/events/catalogue/get-game-shop-details.ts index 08366abc..39f8425b 100644 --- a/src/main/events/catalogue/get-game-shop-details.ts +++ b/src/main/events/catalogue/get-game-shop-details.ts @@ -1,10 +1,10 @@ -import { gameShopCacheRepository } from "@main/repository"; -import { getSteamAppDetails } from "@main/services"; +import { getSteamAppDetails, logger } from "@main/services"; -import type { ShopDetails, GameShop, SteamAppDetails } from "@types"; +import type { ShopDetails, GameShop } from "@types"; import { registerEvent } from "../register-event"; import { steamGamesWorker } from "@main/workers"; +import { gamesShopCacheSublevel, levelKeys } from "@main/level"; const getLocalizedSteamAppDetails = async ( objectId: string, @@ -39,35 +39,27 @@ const getGameShopDetails = async ( language: string ): Promise => { if (shop === "steam") { - const cachedData = await gameShopCacheRepository.findOne({ - where: { objectID: objectId, language }, - }); + const cachedData = await gamesShopCacheSublevel.get( + levelKeys.gameShopCacheItem(shop, objectId, language) + ); const appDetails = getLocalizedSteamAppDetails(objectId, language).then( (result) => { if (result) { - gameShopCacheRepository.upsert( - { - objectID: objectId, - shop: "steam", - language, - serializedData: JSON.stringify(result), - }, - ["objectID"] - ); + gamesShopCacheSublevel + .put(levelKeys.gameShopCacheItem(shop, objectId, language), result) + .catch((err) => { + logger.error("Could not cache game details", err); + }); } return result; } ); - const cachedGame = cachedData?.serializedData - ? (JSON.parse(cachedData?.serializedData) as SteamAppDetails) - : null; - - if (cachedGame) { + if (cachedData) { return { - ...cachedGame, + ...cachedData, objectId, } as ShopDetails; } diff --git a/src/main/events/catalogue/get-trending-games.ts b/src/main/events/catalogue/get-trending-games.ts index acfebfd6..364ceeb9 100644 --- a/src/main/events/catalogue/get-trending-games.ts +++ b/src/main/events/catalogue/get-trending-games.ts @@ -1,14 +1,14 @@ +import { db, levelKeys } from "@main/level"; import { registerEvent } from "../register-event"; import { HydraApi } from "@main/services"; -import { userPreferencesRepository } from "@main/repository"; import type { TrendingGame } from "@types"; const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); - - const language = userPreferences?.language || "en"; + const language = await db + .get(levelKeys.language, { + valueEncoding: "utf-8", + }) + .then((language) => language || "en"); const trendingGames = await HydraApi.get( "/games/trending", diff --git a/src/main/events/cloud-save/get-game-backup-preview.ts b/src/main/events/cloud-save/get-game-backup-preview.ts index daffa487..0dc471e3 100644 --- a/src/main/events/cloud-save/get-game-backup-preview.ts +++ b/src/main/events/cloud-save/get-game-backup-preview.ts @@ -1,19 +1,14 @@ import { registerEvent } from "../register-event"; import type { GameShop } from "@types"; import { Ludusavi } from "@main/services"; -import { gameRepository } from "@main/repository"; +import { gamesSublevel, levelKeys } from "@main/level"; const getGameBackupPreview = async ( _event: Electron.IpcMainInvokeEvent, objectId: string, shop: GameShop ) => { - const game = await gameRepository.findOne({ - where: { - objectID: objectId, - shop, - }, - }); + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); return Ludusavi.getBackupPreview(shop, objectId, game?.winePrefixPath); }; diff --git a/src/main/events/cloud-save/upload-save-game.ts b/src/main/events/cloud-save/upload-save-game.ts index b3a514f5..0c18d0a6 100644 --- a/src/main/events/cloud-save/upload-save-game.ts +++ b/src/main/events/cloud-save/upload-save-game.ts @@ -10,7 +10,7 @@ import os from "node:os"; import { backupsPath } from "@main/constants"; import { app } from "electron"; import { normalizePath } from "@main/helpers"; -import { gameRepository } from "@main/repository"; +import { gamesSublevel, levelKeys } from "@main/level"; const bundleBackup = async ( shop: GameShop, @@ -46,12 +46,7 @@ const uploadSaveGame = async ( shop: GameShop, downloadOptionTitle: string | null ) => { - const game = await gameRepository.findOne({ - where: { - objectID: objectId, - shop, - }, - }); + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); const bundleLocation = await bundleBackup( shop, diff --git a/src/main/events/hardware/check-folder-write-permission.ts b/src/main/events/hardware/check-folder-write-permission.ts index c74f01e7..af896e98 100644 --- a/src/main/events/hardware/check-folder-write-permission.ts +++ b/src/main/events/hardware/check-folder-write-permission.ts @@ -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); diff --git a/src/main/events/helpers/generate-lutris-yaml.ts b/src/main/events/helpers/generate-lutris-yaml.ts deleted file mode 100644 index f47a2a68..00000000 --- a/src/main/events/helpers/generate-lutris-yaml.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Document as YMLDocument } from "yaml"; -import { Game } from "@main/entity"; -import path from "node:path"; - -export const generateYML = (game: Game) => { - const slugifiedGameTitle = game.title.replace(/\s/g, "-").toLocaleLowerCase(); - - const doc = new YMLDocument({ - name: game.title, - game_slug: slugifiedGameTitle, - slug: `${slugifiedGameTitle}-installer`, - version: "Installer", - runner: "wine", - script: { - game: { - prefix: "$GAMEDIR", - arch: "win64", - working_dir: "$GAMEDIR", - }, - installer: [ - { - task: { - name: "create_prefix", - arch: "win64", - prefix: "$GAMEDIR", - }, - }, - { - task: { - executable: path.join( - game.downloadPath!, - game.folderName!, - "setup.exe" - ), - name: "wineexec", - prefix: "$GAMEDIR", - }, - }, - ], - }, - }); - - return doc.toString(); -}; diff --git a/src/main/events/helpers/get-downloads-path.ts b/src/main/events/helpers/get-downloads-path.ts index c78a0ede..0403095f 100644 --- a/src/main/events/helpers/get-downloads-path.ts +++ b/src/main/events/helpers/get-downloads-path.ts @@ -1,15 +1,16 @@ -import { userPreferencesRepository } from "@main/repository"; import { defaultDownloadsPath } from "@main/constants"; +import { db, levelKeys } from "@main/level"; +import type { UserPreferences } from "@types"; export const getDownloadsPath = async () => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { - id: 1, - }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); - if (userPreferences && userPreferences.downloadsPath) - return userPreferences.downloadsPath; + if (userPreferences?.downloadsPath) return userPreferences.downloadsPath; return defaultDownloadsPath; }; diff --git a/src/main/events/helpers/parse-launch-options.ts b/src/main/events/helpers/parse-launch-options.ts new file mode 100644 index 00000000..89a0c611 --- /dev/null +++ b/src/main/events/helpers/parse-launch-options.ts @@ -0,0 +1,7 @@ +export const parseLaunchOptions = (params?: string | null): string[] => { + if (!params) { + return []; + } + + return params.split(" "); +}; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 25882c3f..570fa378 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -46,6 +46,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"; diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 898c25cd..e27709e9 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -1,57 +1,55 @@ -import { gameRepository } from "@main/repository"; - import { registerEvent } from "../register-event"; -import type { GameShop } from "@types"; +import type { Game, GameShop } from "@types"; import { steamGamesWorker } from "@main/workers"; import { createGame } from "@main/services/library-sync"; import { steamUrlBuilder } from "@shared"; import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements"; +import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; const addGameToLibrary = async ( _event: Electron.IpcMainInvokeEvent, + shop: GameShop, objectId: string, - title: string, - shop: GameShop + title: string ) => { - return gameRepository - .update( - { - objectID: objectId, - }, - { - shop, - status: null, - isDeleted: false, - } - ) - .then(async ({ affected }) => { - if (!affected) { - const steamGame = await steamGamesWorker.run(Number(objectId), { - name: "getById", - }); + const gameKey = levelKeys.game(shop, objectId); + const game = await gamesSublevel.get(gameKey); - const iconUrl = steamGame?.clientIcon - ? steamUrlBuilder.icon(objectId, steamGame.clientIcon) - : null; + if (game) { + await downloadsSublevel.del(gameKey); - await gameRepository.insert({ - title, - iconUrl, - objectID: objectId, - shop, - }); - } - - const game = await gameRepository.findOne({ - where: { objectID: objectId }, - }); - - updateLocalUnlockedAchivements(game!); - - createGame(game!).catch(() => {}); + await gamesSublevel.put(gameKey, { + ...game, + isDeleted: false, }); + } else { + const steamGame = await steamGamesWorker.run(Number(objectId), { + name: "getById", + }); + + const iconUrl = steamGame?.clientIcon + ? steamUrlBuilder.icon(objectId, steamGame.clientIcon) + : null; + + const game: Game = { + title, + iconUrl, + objectId, + shop, + remoteId: null, + isDeleted: false, + playTimeInMilliseconds: 0, + lastTimePlayed: null, + }; + + await gamesSublevel.put(levelKeys.game(shop, objectId), game); + + updateLocalUnlockedAchivements(game!); + + createGame(game!).catch(() => {}); + } }; registerEvent("addGameToLibrary", addGameToLibrary); diff --git a/src/main/events/library/close-game.ts b/src/main/events/library/close-game.ts index f69bf120..d01f3f4f 100644 --- a/src/main/events/library/close-game.ts +++ b/src/main/events/library/close-game.ts @@ -1,10 +1,11 @@ -import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import { logger } from "@main/services"; import sudo from "sudo-prompt"; import { app } from "electron"; import { PythonRPC } from "@main/services/python-rpc"; import { ProcessPayload } from "@main/services/download/types"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; const getKillCommand = (pid: number) => { if (process.platform == "win32") { @@ -16,15 +17,14 @@ const getKillCommand = (pid: number) => { const closeGame = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { const processes = (await PythonRPC.rpc.get("/process-list")).data || []; - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - }); + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); if (!game) return; diff --git a/src/main/events/library/create-game-shortcut.ts b/src/main/events/library/create-game-shortcut.ts index 4e6935f4..6e278871 100644 --- a/src/main/events/library/create-game-shortcut.ts +++ b/src/main/events/library/create-game-shortcut.ts @@ -1,18 +1,18 @@ -import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; -import { IsNull, Not } from "typeorm"; import createDesktopShortcut from "create-desktop-shortcuts"; import path from "node:path"; import { app } from "electron"; import { removeSymbolsFromName } from "@shared"; +import { GameShop } from "@types"; +import { gamesSublevel, levelKeys } from "@main/level"; const createGameShortcut = async ( _event: Electron.IpcMainInvokeEvent, - id: number + shop: GameShop, + objectId: string ): Promise => { - const game = await gameRepository.findOne({ - where: { id, executablePath: Not(IsNull()) }, - }); + const gameKey = levelKeys.game(shop, objectId); + const game = await gamesSublevel.get(gameKey); if (game) { const filePath = game.executablePath; diff --git a/src/main/events/library/delete-game-folder.ts b/src/main/events/library/delete-game-folder.ts index bdae9b3e..9c290fe0 100644 --- a/src/main/events/library/delete-game-folder.ts +++ b/src/main/events/library/delete-game-folder.ts @@ -1,37 +1,27 @@ import path from "node:path"; import fs from "node:fs"; -import { gameRepository } from "@main/repository"; - import { getDownloadsPath } from "../helpers/get-downloads-path"; import { logger } from "@main/services"; import { registerEvent } from "../register-event"; +import { GameShop } from "@types"; +import { downloadsSublevel, levelKeys } from "@main/level"; const deleteGameFolder = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ): Promise => { - const game = await gameRepository.findOne({ - where: [ - { - id: gameId, - isDeleted: false, - status: "removed", - }, - { - id: gameId, - progress: 1, - isDeleted: false, - }, - ], - }); + const downloadKey = levelKeys.game(shop, objectId); - if (!game) return; + const download = await downloadsSublevel.get(downloadKey); - if (game.folderName) { + if (!download) return; + + if (download.folderName) { const folderPath = path.join( - game.downloadPath ?? (await getDownloadsPath()), - game.folderName + download.downloadPath ?? (await getDownloadsPath()), + download.folderName ); if (fs.existsSync(folderPath)) { @@ -52,10 +42,7 @@ const deleteGameFolder = async ( } } - await gameRepository.update( - { id: gameId }, - { downloadPath: null, folderName: null, status: null, progress: 0 } - ); + await downloadsSublevel.del(downloadKey); }; registerEvent("deleteGameFolder", deleteGameFolder); diff --git a/src/main/events/library/get-game-by-object-id.ts b/src/main/events/library/get-game-by-object-id.ts index d68aac69..239bcb8d 100644 --- a/src/main/events/library/get-game-by-object-id.ts +++ b/src/main/events/library/get-game-by-object-id.ts @@ -1,16 +1,21 @@ -import { gameRepository } from "@main/repository"; - import { registerEvent } from "../register-event"; +import { gamesSublevel, downloadsSublevel, levelKeys } from "@main/level"; +import type { GameShop } from "@types"; const getGameByObjectId = async ( _event: Electron.IpcMainInvokeEvent, + shop: GameShop, objectId: string -) => - gameRepository.findOne({ - where: { - objectID: objectId, - isDeleted: false, - }, - }); +) => { + const gameKey = levelKeys.game(shop, objectId); + const [game, download] = await Promise.all([ + gamesSublevel.get(gameKey), + downloadsSublevel.get(gameKey), + ]); + + if (!game || game.isDeleted) return null; + + return { id: gameKey, ...game, download }; +}; registerEvent("getGameByObjectId", getGameByObjectId); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index ad982308..86c0fd29 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -1,17 +1,26 @@ -import { gameRepository } from "@main/repository"; +import type { LibraryGame } from "@types"; import { registerEvent } from "../register-event"; +import { downloadsSublevel, gamesSublevel } from "@main/level"; -const getLibrary = async () => - gameRepository.find({ - where: { - isDeleted: false, - }, - relations: { - downloadQueue: true, - }, - order: { - createdAt: "desc", - }, - }); +const getLibrary = async (): Promise => { + return gamesSublevel + .iterator() + .all() + .then((results) => { + return Promise.all( + results + .filter(([_key, game]) => game.isDeleted === false) + .map(async ([key, game]) => { + const download = await downloadsSublevel.get(key); + + return { + id: key, + ...game, + download: download ?? null, + }; + }) + ); + }); +}; registerEvent("getLibrary", getLibrary); diff --git a/src/main/events/library/open-game-executable-path.ts b/src/main/events/library/open-game-executable-path.ts index 09a0837c..96a993a6 100644 --- a/src/main/events/library/open-game-executable-path.ts +++ b/src/main/events/library/open-game-executable-path.ts @@ -1,14 +1,14 @@ import { shell } from "electron"; -import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; const openGameExecutablePath = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - }); + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); if (!game || !game.executablePath) return; diff --git a/src/main/events/library/open-game-installer-path.ts b/src/main/events/library/open-game-installer-path.ts index dd7383ad..b61246fa 100644 --- a/src/main/events/library/open-game-installer-path.ts +++ b/src/main/events/library/open-game-installer-path.ts @@ -1,22 +1,22 @@ import { shell } from "electron"; import path from "node:path"; -import { gameRepository } from "@main/repository"; import { getDownloadsPath } from "../helpers/get-downloads-path"; import { registerEvent } from "../register-event"; +import { GameShop } from "@types"; +import { downloadsSublevel, levelKeys } from "@main/level"; const openGameInstallerPath = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - }); + const download = await downloadsSublevel.get(levelKeys.game(shop, objectId)); - if (!game || !game.folderName || !game.downloadPath) return true; + if (!download || !download.folderName || !download.downloadPath) return true; const gamePath = path.join( - game.downloadPath ?? (await getDownloadsPath()), - game.folderName! + download.downloadPath ?? (await getDownloadsPath()), + download.folderName! ); shell.showItemInFolder(gamePath); diff --git a/src/main/events/library/open-game-installer.ts b/src/main/events/library/open-game-installer.ts index b21a6f16..9cf1d978 100644 --- a/src/main/events/library/open-game-installer.ts +++ b/src/main/events/library/open-game-installer.ts @@ -1,14 +1,12 @@ import { shell } from "electron"; import path from "node:path"; import fs from "node:fs"; -import { writeFile } from "node:fs/promises"; import { spawnSync, exec } from "node:child_process"; -import { gameRepository } from "@main/repository"; - -import { generateYML } from "../helpers/generate-lutris-yaml"; import { getDownloadsPath } from "../helpers/get-downloads-path"; import { registerEvent } from "../register-event"; +import { downloadsSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; const executeGameInstaller = (filePath: string) => { if (process.platform === "win32") { @@ -26,21 +24,21 @@ const executeGameInstaller = (filePath: string) => { const openGameInstaller = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - }); + const downloadKey = levelKeys.game(shop, objectId); + const download = await downloadsSublevel.get(downloadKey); - if (!game || !game.folderName) return true; + if (!download?.folderName) return true; const gamePath = path.join( - game.downloadPath ?? (await getDownloadsPath()), - game.folderName! + download.downloadPath ?? (await getDownloadsPath()), + download.folderName ); if (!fs.existsSync(gamePath)) { - await gameRepository.update({ id: gameId }, { status: null }); + await downloadsSublevel.del(downloadKey); return true; } @@ -70,13 +68,6 @@ const openGameInstaller = async ( ); } - if (spawnSync("which", ["lutris"]).status === 0) { - const ymlPath = path.join(gamePath, "setup.yml"); - await writeFile(ymlPath, generateYML(game)); - exec(`lutris --install "${ymlPath}"`); - return true; - } - shell.openPath(gamePath); return true; }; diff --git a/src/main/events/library/open-game.ts b/src/main/events/library/open-game.ts index cf73c810..64e3d5fb 100644 --- a/src/main/events/library/open-game.ts +++ b/src/main/events/library/open-game.ts @@ -1,24 +1,39 @@ -import { gameRepository } from "@main/repository"; - 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, - gameId: number, + shop: GameShop, + objectId: string, executablePath: string, - launchOptions: string | null + launchOptions?: string | null ) => { - // TODO: revisit this for launchOptions const parsedPath = parseExecutablePath(executablePath); + const parsedParams = parseLaunchOptions(launchOptions); - await gameRepository.update( - { id: gameId }, - { executablePath: parsedPath, launchOptions } - ); + const gameKey = levelKeys.game(shop, objectId); - shell.openPath(parsedPath); + const game = await gamesSublevel.get(gameKey); + + if (!game) return; + + await gamesSublevel.put(gameKey, { + ...game, + executablePath: parsedPath, + launchOptions, + }); + + if (parsedParams.length === 0) { + shell.openPath(parsedPath); + return; + } + + spawn(parsedPath, parsedParams, { shell: false, detached: true }); }; registerEvent("openGame", openGame); diff --git a/src/main/events/library/remove-game-from-library.ts b/src/main/events/library/remove-game-from-library.ts index a8fc8b01..6a33ffaf 100644 --- a/src/main/events/library/remove-game-from-library.ts +++ b/src/main/events/library/remove-game-from-library.ts @@ -1,26 +1,26 @@ import { registerEvent } from "../register-event"; -import { gameRepository } from "../../repository"; -import { HydraApi, logger } from "@main/services"; +import { HydraApi } from "@main/services"; +import { gamesSublevel, levelKeys } from "@main/level"; +import type { GameShop } from "@types"; const removeGameFromLibrary = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - gameRepository.update( - { id: gameId }, - { isDeleted: true, executablePath: null } - ); + const gameKey = levelKeys.game(shop, objectId); + const game = await gamesSublevel.get(gameKey); - removeRemoveGameFromLibrary(gameId).catch((err) => { - logger.error("removeRemoveGameFromLibrary", err); - }); -}; + if (game) { + await gamesSublevel.put(gameKey, { + ...game, + isDeleted: true, + executablePath: null, + }); -const removeRemoveGameFromLibrary = async (gameId: number) => { - const game = await gameRepository.findOne({ where: { id: gameId } }); - - if (game?.remoteId) { - HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); + if (game?.remoteId) { + HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); + } } }; diff --git a/src/main/events/library/remove-game.ts b/src/main/events/library/remove-game.ts index 687366c5..a5310bc9 100644 --- a/src/main/events/library/remove-game.ts +++ b/src/main/events/library/remove-game.ts @@ -1,21 +1,14 @@ import { registerEvent } from "../register-event"; -import { gameRepository } from "../../repository"; +import { levelKeys, downloadsSublevel } from "@main/level"; +import { GameShop } from "@types"; const removeGame = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - await gameRepository.update( - { - id: gameId, - }, - { - status: "removed", - downloadPath: null, - bytesDownloaded: 0, - progress: 0, - } - ); + const downloadKey = levelKeys.game(shop, objectId); + await downloadsSublevel.del(downloadKey); }; registerEvent("removeGame", removeGame); diff --git a/src/main/events/library/reset-game-achievements.ts b/src/main/events/library/reset-game-achievements.ts index 8d52a3a6..b3d2daa2 100644 --- a/src/main/events/library/reset-game-achievements.ts +++ b/src/main/events/library/reset-game-achievements.ts @@ -1,16 +1,22 @@ -import { gameAchievementRepository, gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import { findAchievementFiles } from "@main/services/achievements/find-achivement-files"; import fs from "fs"; import { achievementsLogger, HydraApi, WindowManager } from "@main/services"; import { getUnlockedAchievements } from "../user/get-unlocked-achievements"; +import { + gameAchievementsSublevel, + gamesSublevel, + levelKeys, +} from "@main/level"; +import type { GameShop } from "@types"; const resetGameAchievements = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { try { - const game = await gameRepository.findOne({ where: { id: gameId } }); + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); if (!game) return; @@ -23,28 +29,34 @@ const resetGameAchievements = async ( } } - await gameAchievementRepository.update( - { objectId: game.objectID }, - { - unlockedAchievements: null, - } - ); + const levelKey = levelKeys.game(game.shop, game.objectId); + + await gameAchievementsSublevel + .get(levelKey) + .then(async (gameAchievements) => { + if (gameAchievements) { + await gameAchievementsSublevel.put(levelKey, { + ...gameAchievements, + unlockedAchievements: [], + }); + } + }); await HydraApi.delete(`/profile/games/achievements/${game.remoteId}`).then( () => achievementsLogger.log( - `Deleted achievements from ${game.remoteId} - ${game.objectID} - ${game.title}` + `Deleted achievements from ${game.remoteId} - ${game.objectId} - ${game.title}` ) ); const gameAchievements = await getUnlockedAchievements( - game.objectID, + game.objectId, game.shop, true ); WindowManager.mainWindow?.webContents.send( - `on-update-achievements-${game.objectID}-${game.shop}`, + `on-update-achievements-${game.objectId}-${game.shop}`, gameAchievements ); } catch (error) { diff --git a/src/main/events/library/select-game-wine-prefix.ts b/src/main/events/library/select-game-wine-prefix.ts index d9f01c08..c085dbad 100644 --- a/src/main/events/library/select-game-wine-prefix.ts +++ b/src/main/events/library/select-game-wine-prefix.ts @@ -1,13 +1,23 @@ -import { gameRepository } from "@main/repository"; - import { registerEvent } from "../register-event"; +import { levelKeys, gamesSublevel } from "@main/level"; +import type { GameShop } from "@types"; const selectGameWinePrefix = async ( _event: Electron.IpcMainInvokeEvent, - id: number, + shop: GameShop, + objectId: string, winePrefixPath: string | null ) => { - return gameRepository.update({ id }, { winePrefixPath: winePrefixPath }); + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + + if (!game) return; + + await gamesSublevel.put(gameKey, { + ...game, + winePrefixPath: winePrefixPath, + }); }; registerEvent("selectGameWinePrefix", selectGameWinePrefix); diff --git a/src/main/events/library/update-executable-path.ts b/src/main/events/library/update-executable-path.ts index aee80771..e753706b 100644 --- a/src/main/events/library/update-executable-path.ts +++ b/src/main/events/library/update-executable-path.ts @@ -1,25 +1,27 @@ -import { gameRepository } from "@main/repository"; - import { registerEvent } from "../register-event"; import { parseExecutablePath } from "../helpers/parse-executable-path"; +import { gamesSublevel, levelKeys } from "@main/level"; +import type { GameShop } from "@types"; const updateExecutablePath = async ( _event: Electron.IpcMainInvokeEvent, - id: number, + shop: GameShop, + objectId: string, executablePath: string | null ) => { const parsedPath = executablePath ? parseExecutablePath(executablePath) : null; - return gameRepository.update( - { - id, - }, - { - executablePath: parsedPath, - } - ); + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + if (!game) return; + + await gamesSublevel.put(gameKey, { + ...game, + executablePath: parsedPath, + }); }; registerEvent("updateExecutablePath", updateExecutablePath); diff --git a/src/main/events/library/update-launch-options.ts b/src/main/events/library/update-launch-options.ts index b33d031c..3e6c15cf 100644 --- a/src/main/events/library/update-launch-options.ts +++ b/src/main/events/library/update-launch-options.ts @@ -1,19 +1,23 @@ -import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; const updateLaunchOptions = async ( _event: Electron.IpcMainInvokeEvent, - id: number, + shop: GameShop, + objectId: string, launchOptions: string | null ) => { - return gameRepository.update( - { - id, - }, - { + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + + if (game) { + await gamesSublevel.put(gameKey, { + ...game, launchOptions: launchOptions?.trim() != "" ? launchOptions : null, - } - ); + }); + } }; registerEvent("updateLaunchOptions", updateLaunchOptions); diff --git a/src/main/events/library/verify-executable-path.ts b/src/main/events/library/verify-executable-path.ts index 22295ac7..a48a0d38 100644 --- a/src/main/events/library/verify-executable-path.ts +++ b/src/main/events/library/verify-executable-path.ts @@ -1,13 +1,17 @@ -import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { gamesSublevel } from "@main/level"; const verifyExecutablePathInUse = async ( _event: Electron.IpcMainInvokeEvent, executablePath: string ) => { - return gameRepository.findOne({ - where: { executablePath }, - }); + for await (const game of gamesSublevel.values()) { + if (game.executablePath === executablePath) { + return true; + } + } + + return false; }; registerEvent("verifyExecutablePathInUse", verifyExecutablePathInUse); diff --git a/src/main/events/misc/open-checkout.ts b/src/main/events/misc/open-checkout.ts index ba48f03b..76316a6e 100644 --- a/src/main/events/misc/open-checkout.ts +++ b/src/main/events/misc/open-checkout.ts @@ -1,17 +1,20 @@ import { shell } from "electron"; import { registerEvent } from "../register-event"; -import { userAuthRepository } from "@main/repository"; -import { HydraApi } from "@main/services"; +import { Crypto, HydraApi } from "@main/services"; +import { db, levelKeys } from "@main/level"; +import type { Auth } from "@types"; const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => { - const userAuth = await userAuthRepository.findOne({ where: { id: 1 } }); + const auth = await db.get(levelKeys.auth, { + valueEncoding: "json", + }); - if (!userAuth) { + if (!auth) { return; } const paymentToken = await HydraApi.post("/auth/payment", { - refreshToken: userAuth.refreshToken, + refreshToken: Crypto.decrypt(auth.refreshToken), }).then((response) => response.accessToken); const params = new URLSearchParams({ diff --git a/src/main/events/notifications/publish-new-repacks-notification.ts b/src/main/events/notifications/publish-new-repacks-notification.ts index 5230c209..356b1b16 100644 --- a/src/main/events/notifications/publish-new-repacks-notification.ts +++ b/src/main/events/notifications/publish-new-repacks-notification.ts @@ -1,7 +1,8 @@ import { Notification } from "electron"; import { registerEvent } from "../register-event"; -import { userPreferencesRepository } from "@main/repository"; import { t } from "i18next"; +import { db, levelKeys } from "@main/level"; +import type { UserPreferences } from "@types"; const publishNewRepacksNotification = async ( _event: Electron.IpcMainInvokeEvent, @@ -9,9 +10,12 @@ const publishNewRepacksNotification = async ( ) => { if (newRepacksCount < 1) return; - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); if (userPreferences?.repackUpdatesNotificationsEnabled) { new Notification({ diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index 7b90e483..f5a04f0d 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -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("/profile", updateProfile); }; diff --git a/src/main/events/torrenting/cancel-game-download.ts b/src/main/events/torrenting/cancel-game-download.ts index fbdf2761..5d80337f 100644 --- a/src/main/events/torrenting/cancel-game-download.ts +++ b/src/main/events/torrenting/cancel-game-download.ts @@ -1,31 +1,19 @@ import { registerEvent } from "../register-event"; import { DownloadManager } from "@main/services"; -import { dataSource } from "@main/data-source"; -import { DownloadQueue, Game } from "@main/entity"; +import { GameShop } from "@types"; +import { downloadsSublevel, levelKeys } from "@main/level"; const cancelGameDownload = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - await dataSource.transaction(async (transactionalEntityManager) => { - await DownloadManager.cancelDownload(gameId); + const downloadKey = levelKeys.game(shop, objectId); - await transactionalEntityManager.getRepository(DownloadQueue).delete({ - game: { id: gameId }, - }); + await DownloadManager.cancelDownload(downloadKey); - await transactionalEntityManager.getRepository(Game).update( - { - id: gameId, - }, - { - status: "removed", - bytesDownloaded: 0, - progress: 0, - } - ); - }); + await downloadsSublevel.del(downloadKey); }; registerEvent("cancelGameDownload", cancelGameDownload); diff --git a/src/main/events/torrenting/pause-game-download.ts b/src/main/events/torrenting/pause-game-download.ts index 03bb2781..e3e14dec 100644 --- a/src/main/events/torrenting/pause-game-download.ts +++ b/src/main/events/torrenting/pause-game-download.ts @@ -1,24 +1,27 @@ import { registerEvent } from "../register-event"; import { DownloadManager } from "@main/services"; -import { dataSource } from "@main/data-source"; -import { DownloadQueue, Game } from "@main/entity"; +import { GameShop } from "@types"; +import { downloadsSublevel, levelKeys } from "@main/level"; const pauseGameDownload = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - await dataSource.transaction(async (transactionalEntityManager) => { - await DownloadManager.pauseDownload(); + const gameKey = levelKeys.game(shop, objectId); - await transactionalEntityManager.getRepository(DownloadQueue).delete({ - game: { id: gameId }, + const download = await downloadsSublevel.get(gameKey); + + if (download) { + await DownloadManager.pauseDownload(gameKey); + + await downloadsSublevel.put(gameKey, { + ...download, + status: "paused", + queued: false, }); - - await transactionalEntityManager - .getRepository(Game) - .update({ id: gameId }, { status: "paused" }); - }); + } }; registerEvent("pauseGameDownload", pauseGameDownload); diff --git a/src/main/events/torrenting/pause-game-seed.ts b/src/main/events/torrenting/pause-game-seed.ts index df2af756..b19da525 100644 --- a/src/main/events/torrenting/pause-game-seed.ts +++ b/src/main/events/torrenting/pause-game-seed.ts @@ -1,17 +1,24 @@ +import { downloadsSublevel, levelKeys } from "@main/level"; import { registerEvent } from "../register-event"; import { DownloadManager } from "@main/services"; -import { gameRepository } from "@main/repository"; +import type { GameShop } from "@types"; const pauseGameSeed = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - await gameRepository.update(gameId, { - status: "complete", + const downloadKey = levelKeys.game(shop, objectId); + const download = await downloadsSublevel.get(downloadKey); + + if (!download) return; + + await downloadsSublevel.put(downloadKey, { + ...download, shouldSeed: false, }); - await DownloadManager.pauseSeeding(gameId); + await DownloadManager.pauseSeeding(downloadKey); }; registerEvent("pauseGameSeed", pauseGameSeed); diff --git a/src/main/events/torrenting/resume-game-download.ts b/src/main/events/torrenting/resume-game-download.ts index c8c75545..48bb1c12 100644 --- a/src/main/events/torrenting/resume-game-download.ts +++ b/src/main/events/torrenting/resume-game-download.ts @@ -1,46 +1,37 @@ -import { Not } from "typeorm"; - import { registerEvent } from "../register-event"; -import { gameRepository } from "../../repository"; import { DownloadManager } from "@main/services"; -import { dataSource } from "@main/data-source"; -import { DownloadQueue, Game } from "@main/entity"; +import { downloadsSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; const resumeGameDownload = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - const game = await gameRepository.findOne({ - where: { - id: gameId, - isDeleted: false, - }, - }); + const gameKey = levelKeys.game(shop, objectId); - if (!game) return; + const download = await downloadsSublevel.get(gameKey); - if (game.status === "paused") { - await dataSource.transaction(async (transactionalEntityManager) => { - await DownloadManager.pauseDownload(); + if (download?.status === "paused") { + await DownloadManager.pauseDownload(); - await transactionalEntityManager - .getRepository(Game) - .update({ status: "active", progress: Not(1) }, { status: "paused" }); + for await (const [key, value] of downloadsSublevel.iterator()) { + if (value.status === "active" && value.progress !== 1) { + await downloadsSublevel.put(key, { + ...value, + status: "paused", + }); + } + } - await DownloadManager.resumeDownload(game); + await DownloadManager.resumeDownload(download); - await transactionalEntityManager - .getRepository(DownloadQueue) - .delete({ game: { id: gameId } }); - - await transactionalEntityManager - .getRepository(DownloadQueue) - .insert({ game: { id: gameId } }); - - await transactionalEntityManager - .getRepository(Game) - .update({ id: gameId }, { status: "active" }); + await downloadsSublevel.put(gameKey, { + ...download, + status: "active", + timestamp: Date.now(), + queued: true, }); } }; diff --git a/src/main/events/torrenting/resume-game-seed.ts b/src/main/events/torrenting/resume-game-seed.ts index 9f79e53a..63bab952 100644 --- a/src/main/events/torrenting/resume-game-seed.ts +++ b/src/main/events/torrenting/resume-game-seed.ts @@ -1,29 +1,23 @@ +import { downloadsSublevel, levelKeys } from "@main/level"; import { registerEvent } from "../register-event"; -import { gameRepository } from "../../repository"; import { DownloadManager } from "@main/services"; -import { Downloader } from "@shared"; +import type { GameShop } from "@types"; const resumeGameSeed = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - const game = await gameRepository.findOne({ - where: { - id: gameId, - isDeleted: false, - downloader: Downloader.Torrent, - progress: 1, - }, - }); + const download = await downloadsSublevel.get(levelKeys.game(shop, objectId)); - if (!game) return; + if (!download) return; - await gameRepository.update(gameId, { - status: "seeding", + await downloadsSublevel.put(levelKeys.game(shop, objectId), { + ...download, shouldSeed: true, }); - await DownloadManager.resumeSeeding(game); + await DownloadManager.resumeSeeding(download); }; registerEvent("resumeGameSeed", resumeGameSeed); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index de10b07d..8b5f1918 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -1,13 +1,12 @@ import { registerEvent } from "../register-event"; -import type { StartGameDownloadPayload } from "@types"; -import { DownloadManager, HydraApi } from "@main/services"; +import type { Download, StartGameDownloadPayload } from "@types"; +import { DownloadManager, HydraApi, logger } from "@main/services"; -import { Not } from "typeorm"; import { steamGamesWorker } from "@main/workers"; import { createGame } from "@main/services/library-sync"; -import { steamUrlBuilder } from "@shared"; -import { dataSource } from "@main/data-source"; -import { DownloadQueue, Game } from "@main/entity"; +import { Downloader, DownloadError, steamUrlBuilder } from "@shared"; +import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; +import { AxiosError } from "axios"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -15,85 +14,117 @@ const startGameDownload = async ( ) => { const { objectId, title, shop, downloadPath, downloader, uri } = payload; - return dataSource.transaction(async (transactionalEntityManager) => { - const gameRepository = transactionalEntityManager.getRepository(Game); - const downloadQueueRepository = - transactionalEntityManager.getRepository(DownloadQueue); + const gameKey = levelKeys.game(shop, objectId); - const game = await gameRepository.findOne({ - where: { - objectID: objectId, - shop, - }, - }); + await DownloadManager.pauseDownload(); - await DownloadManager.pauseDownload(); - - await gameRepository.update( - { status: "active", progress: Not(1) }, - { status: "paused" } - ); - - if (game) { - await gameRepository.update( - { - id: game.id, - }, - { - status: "active", - progress: 0, - bytesDownloaded: 0, - downloadPath, - downloader, - uri, - isDeleted: false, - } - ); - } else { - const steamGame = await steamGamesWorker.run(Number(objectId), { - name: "getById", - }); - - const iconUrl = steamGame?.clientIcon - ? steamUrlBuilder.icon(objectId, steamGame.clientIcon) - : null; - - await gameRepository.insert({ - title, - iconUrl, - objectID: objectId, - downloader, - shop, - status: "active", - downloadPath, - uri, + for await (const [key, value] of downloadsSublevel.iterator()) { + if (value.status === "active" && value.progress !== 1) { + await downloadsSublevel.put(key, { + ...value, + status: "paused", }); } + } - const updatedGame = await gameRepository.findOne({ - where: { - objectID: objectId, - }, + const game = await gamesSublevel.get(gameKey); + + /* Delete any previous download */ + await downloadsSublevel.del(gameKey); + + if (game?.isDeleted) { + await gamesSublevel.put(gameKey, { + ...game, + isDeleted: false, + }); + } else { + const steamGame = await steamGamesWorker.run(Number(objectId), { + name: "getById", }); - await DownloadManager.cancelDownload(updatedGame!.id); - await DownloadManager.startDownload(updatedGame!); + const iconUrl = steamGame?.clientIcon + ? steamUrlBuilder.icon(objectId, steamGame.clientIcon) + : null; - await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); - await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); + await gamesSublevel.put(gameKey, { + title, + iconUrl, + objectId, + shop, + remoteId: null, + playTimeInMilliseconds: 0, + lastTimePlayed: null, + isDeleted: false, + }); + } + + await DownloadManager.cancelDownload(gameKey); + + const download: Download = { + shop, + objectId, + status: "active", + progress: 0, + bytesDownloaded: 0, + downloadPath, + downloader, + uri, + folderName: null, + fileSize: null, + shouldSeed: false, + timestamp: Date.now(), + queued: true, + }; + + try { + await DownloadManager.startDownload(download).then(() => { + return downloadsSublevel.put(gameKey, download); + }); + + const updatedGame = await gamesSublevel.get(gameKey); await Promise.all([ createGame(updatedGame!).catch(() => {}), HydraApi.post( "/games/download", { - objectId: updatedGame!.objectID, - shop: updatedGame!.shop, + objectId, + shop, }, { needsAuth: false } ).catch(() => {}), ]); - }); + + return { ok: true }; + } catch (err: unknown) { + logger.error("Failed to start download", err); + + if (err instanceof AxiosError) { + if (err.response?.status === 429 && downloader === Downloader.Gofile) { + return { ok: false, error: DownloadError.GofileQuotaExceeded }; + } + + if ( + err.response?.status === 403 && + downloader === Downloader.RealDebrid + ) { + return { + ok: false, + error: DownloadError.RealDebridAccountNotAuthorized, + }; + } + + if (downloader === Downloader.TorBox) { + return { ok: false, error: err.response?.data?.detail }; + } + } + + if (err instanceof Error) { + return { ok: false, error: err.message }; + } + + return { ok: false }; + } }; registerEvent("startGameDownload", startGameDownload); diff --git a/src/main/events/user-preferences/authenticate-torbox.ts b/src/main/events/user-preferences/authenticate-torbox.ts new file mode 100644 index 00000000..87fc4ba8 --- /dev/null +++ b/src/main/events/user-preferences/authenticate-torbox.ts @@ -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); diff --git a/src/main/events/user-preferences/get-user-preferences.ts b/src/main/events/user-preferences/get-user-preferences.ts index 2a2df254..c67f72b9 100644 --- a/src/main/events/user-preferences/get-user-preferences.ts +++ b/src/main/events/user-preferences/get-user-preferences.ts @@ -1,9 +1,27 @@ -import { userPreferencesRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { db, levelKeys } from "@main/level"; +import { Crypto } from "@main/services"; +import type { UserPreferences } from "@types"; const getUserPreferences = async () => - userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + db + .get(levelKeys.userPreferences, { + valueEncoding: "json", + }) + .then((userPreferences) => { + if (userPreferences?.realDebridApiToken) { + userPreferences.realDebridApiToken = Crypto.decrypt( + userPreferences.realDebridApiToken + ); + } + + if (userPreferences?.torBoxApiToken) { + userPreferences.torBoxApiToken = Crypto.decrypt( + userPreferences.torBoxApiToken + ); + } + + return userPreferences; + }); registerEvent("getUserPreferences", getUserPreferences); diff --git a/src/main/events/user-preferences/update-user-preferences.ts b/src/main/events/user-preferences/update-user-preferences.ts index f45af519..275a6f27 100644 --- a/src/main/events/user-preferences/update-user-preferences.ts +++ b/src/main/events/user-preferences/update-user-preferences.ts @@ -1,23 +1,52 @@ -import { userPreferencesRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import type { UserPreferences } from "@types"; import i18next from "i18next"; +import { db, levelKeys } from "@main/level"; +import { Crypto } from "@main/services"; +import { patchUserProfile } from "../profile/update-profile"; const updateUserPreferences = async ( _event: Electron.IpcMainInvokeEvent, preferences: Partial ) => { + const userPreferences = await db.get( + levelKeys.userPreferences, + { valueEncoding: "json" } + ); + if (preferences.language) { + await db.put(levelKeys.language, preferences.language, { + valueEncoding: "utf-8", + }); + i18next.changeLanguage(preferences.language); + patchUserProfile({ language: preferences.language }).catch(() => {}); } - return userPreferencesRepository.upsert( + if (preferences.realDebridApiToken) { + preferences.realDebridApiToken = Crypto.encrypt( + preferences.realDebridApiToken + ); + } + + if (preferences.torBoxApiToken) { + preferences.torBoxApiToken = Crypto.encrypt(preferences.torBoxApiToken); + } + + if (!preferences.downloadsPath) { + preferences.downloadsPath = null; + } + + await db.put( + levelKeys.userPreferences, { - id: 1, + ...userPreferences, ...preferences, }, - ["id"] + { + valueEncoding: "json", + } ); }; diff --git a/src/main/events/user/get-compared-unlocked-achievements.ts b/src/main/events/user/get-compared-unlocked-achievements.ts index 0b665212..697ad716 100644 --- a/src/main/events/user/get-compared-unlocked-achievements.ts +++ b/src/main/events/user/get-compared-unlocked-achievements.ts @@ -1,7 +1,8 @@ -import type { ComparedAchievements, GameShop } from "@types"; +import type { ComparedAchievements, GameShop, UserPreferences } from "@types"; import { registerEvent } from "../register-event"; -import { userPreferencesRepository } from "@main/repository"; + import { HydraApi } from "@main/services"; +import { db, levelKeys } from "@main/level"; const getComparedUnlockedAchievements = async ( _event: Electron.IpcMainInvokeEvent, @@ -9,9 +10,12 @@ const getComparedUnlockedAchievements = async ( shop: GameShop, userId: string ) => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); const showHiddenAchievementsDescription = userPreferences?.showHiddenAchievementsDescription || false; @@ -21,7 +25,7 @@ const getComparedUnlockedAchievements = async ( { shop, objectId, - language: userPreferences?.language || "en", + language: userPreferences?.language ?? "en", } ).then((achievements) => { const sortedAchievements = achievements.achievements diff --git a/src/main/events/user/get-unlocked-achievements.ts b/src/main/events/user/get-unlocked-achievements.ts index ffa25399..6deecbad 100644 --- a/src/main/events/user/get-unlocked-achievements.ts +++ b/src/main/events/user/get-unlocked-achievements.ts @@ -1,23 +1,23 @@ -import type { GameShop, UnlockedAchievement, UserAchievement } from "@types"; +import type { GameShop, UserAchievement, UserPreferences } from "@types"; import { registerEvent } from "../register-event"; -import { - gameAchievementRepository, - userPreferencesRepository, -} from "@main/repository"; import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data"; +import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; export const getUnlockedAchievements = async ( objectId: string, shop: GameShop, useCachedData: boolean ): Promise => { - const cachedAchievements = await gameAchievementRepository.findOne({ - where: { objectId, shop }, - }); + const cachedAchievements = await gameAchievementsSublevel.get( + levelKeys.game(shop, objectId) + ); - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); const showHiddenAchievementsDescription = userPreferences?.showHiddenAchievementsDescription || false; @@ -25,12 +25,10 @@ export const getUnlockedAchievements = async ( const achievementsData = await getGameAchievementData( objectId, shop, - useCachedData ? cachedAchievements : null + useCachedData ); - const unlockedAchievements = JSON.parse( - cachedAchievements?.unlockedAchievements || "[]" - ) as UnlockedAchievement[]; + const unlockedAchievements = cachedAchievements?.unlockedAchievements ?? []; return achievementsData .map((achievementData) => { diff --git a/src/main/events/user/get-user-friends.ts b/src/main/events/user/get-user-friends.ts index 9a6f156c..aefc7052 100644 --- a/src/main/events/user/get-user-friends.ts +++ b/src/main/events/user/get-user-friends.ts @@ -1,16 +1,19 @@ -import { userAuthRepository } from "@main/repository"; +import { db } from "@main/level"; import { registerEvent } from "../register-event"; import { HydraApi } from "@main/services"; -import type { UserFriends } from "@types"; +import type { User, UserFriends } from "@types"; +import { levelKeys } from "@main/level/sublevels"; export const getUserFriends = async ( userId: string, take: number, skip: number ): Promise => { - const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } }); + const user = await db.get(levelKeys.user, { + valueEncoding: "json", + }); - if (loggedUser?.userId === userId) { + if (user?.id === userId) { return HydraApi.get(`/profile/friends`, { take, skip }); } diff --git a/src/main/index.ts b/src/main/index.ts index ca49a9fb..2a18fa31 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,16 +3,13 @@ import updater from "electron-updater"; import i18n from "i18next"; import path from "node:path"; import url from "node:url"; -import fs from "node:fs"; import { electronApp, optimizer } from "@electron-toolkit/utils"; import { logger, WindowManager } from "@main/services"; -import { dataSource } from "@main/data-source"; import resources from "@locales"; -import { userPreferencesRepository } from "@main/repository"; -import { knexClient, migrationConfig } from "./knex-client"; -import { databaseDirectory } from "./constants"; import { PythonRPC } from "./services/python-rpc"; import { Aria2 } from "./services/aria2"; +import { db, levelKeys } from "./level"; +import { loadState } from "./main"; const { autoUpdater } = updater; @@ -50,21 +47,6 @@ if (process.defaultApp) { app.setAsDefaultProtocolClient(PROTOCOL); } -const runMigrations = async () => { - if (!fs.existsSync(databaseDirectory)) { - fs.mkdirSync(databaseDirectory, { recursive: true }); - } - - await knexClient.migrate.list(migrationConfig).then((result) => { - logger.log( - "Migrations to run:", - result[1].map((migration) => migration.name) - ); - }); - - await knexClient.migrate.latest(migrationConfig); -}; - // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. @@ -76,31 +58,19 @@ app.whenReady().then(async () => { return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); }); - await runMigrations() - .then(() => { - logger.log("Migrations executed successfully"); - }) - .catch((err) => { - logger.log("Migrations failed to run:", err); - }); + await loadState(); - await dataSource.initialize(); - - await import("./main"); - - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, + const language = await db.get(levelKeys.language, { + valueEncoding: "utf-8", }); - if (userPreferences?.language) { - i18n.changeLanguage(userPreferences.language); - } + if (language) i18n.changeLanguage(language); if (!process.argv.includes("--hidden")) { WindowManager.createMainWindow(); } - WindowManager.createSystemTray(userPreferences?.language || "en"); + WindowManager.createSystemTray(language || "en"); }); app.on("browser-window-created", (_, window) => { diff --git a/src/main/knex-client.ts b/src/main/knex-client.ts index 821efc80..57982332 100644 --- a/src/main/knex-client.ts +++ b/src/main/knex-client.ts @@ -1,53 +1,6 @@ -import knex, { Knex } from "knex"; +import knex from "knex"; import { databasePath } from "./constants"; -import { Hydra2_0_3 } from "./migrations/20240830143811_Hydra_2_0_3"; -import { RepackUris } from "./migrations/20240830143906_RepackUris"; -import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_language"; -import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris"; import { app } from "electron"; -import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns"; -import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement"; -import { AddAchievementNotificationPreference } from "./migrations/20241013012900_add_achievement_notification_preference"; -import { CreateUserSubscription } from "./migrations/20241015235142_create_user_subscription"; -import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_background_image_url"; -import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game"; -import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_column"; -import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disable_nsfw_alert_column"; -import { AddShouldSeedColumn } from "./migrations/20241108200154_add_should_seed_colum"; -import { AddSeedAfterDownloadColumn } from "./migrations/20241108201806_add_seed_after_download"; -import { AddHiddenAchievementDescriptionColumn } from "./migrations/20241216140633_add_hidden_achievement_description_column "; -import { AddLaunchOptionsColumnToGame } from "./migrations/20241226044022_add_launch_options_column_to_game"; - -export type HydraMigration = Knex.Migration & { name: string }; - -class MigrationSource implements Knex.MigrationSource { - getMigrations(): Promise { - return Promise.resolve([ - Hydra2_0_3, - RepackUris, - UpdateUserLanguage, - EnsureRepackUris, - FixMissingColumns, - CreateGameAchievement, - AddAchievementNotificationPreference, - CreateUserSubscription, - AddBackgroundImageUrl, - AddWinePrefixToGame, - AddStartMinimizedColumn, - AddDisableNsfwAlertColumn, - AddShouldSeedColumn, - AddSeedAfterDownloadColumn, - AddHiddenAchievementDescriptionColumn, - AddLaunchOptionsColumnToGame, - ]); - } - getMigrationName(migration: HydraMigration): string { - return migration.name; - } - getMigration(migration: HydraMigration): Promise { - return Promise.resolve(migration); - } -} export const knexClient = knex({ debug: !app.isPackaged, @@ -56,7 +9,3 @@ export const knexClient = knex({ filename: databasePath, }, }); - -export const migrationConfig: Knex.MigratorConfig = { - migrationSource: new MigrationSource(), -}; diff --git a/src/main/level/index.ts b/src/main/level/index.ts new file mode 100644 index 00000000..90a34be3 --- /dev/null +++ b/src/main/level/index.ts @@ -0,0 +1,3 @@ +export { db } from "./level"; + +export * from "./sublevels"; diff --git a/src/main/level/level.ts b/src/main/level/level.ts new file mode 100644 index 00000000..9819efad --- /dev/null +++ b/src/main/level/level.ts @@ -0,0 +1,6 @@ +import { levelDatabasePath } from "@main/constants"; +import { ClassicLevel } from "classic-level"; + +export const db = new ClassicLevel(levelDatabasePath, { + valueEncoding: "json", +}); diff --git a/src/main/level/sublevels/downloads.ts b/src/main/level/sublevels/downloads.ts new file mode 100644 index 00000000..23030670 --- /dev/null +++ b/src/main/level/sublevels/downloads.ts @@ -0,0 +1,11 @@ +import type { Download } from "@types"; + +import { db } from "../level"; +import { levelKeys } from "./keys"; + +export const downloadsSublevel = db.sublevel( + levelKeys.downloads, + { + valueEncoding: "json", + } +); diff --git a/src/main/level/sublevels/game-achievements.ts b/src/main/level/sublevels/game-achievements.ts new file mode 100644 index 00000000..4b1fa0c8 --- /dev/null +++ b/src/main/level/sublevels/game-achievements.ts @@ -0,0 +1,11 @@ +import type { GameAchievement } from "@types"; + +import { db } from "../level"; +import { levelKeys } from "./keys"; + +export const gameAchievementsSublevel = db.sublevel( + levelKeys.gameAchievements, + { + valueEncoding: "json", + } +); diff --git a/src/main/level/sublevels/game-shop-cache.ts b/src/main/level/sublevels/game-shop-cache.ts new file mode 100644 index 00000000..8187e5c0 --- /dev/null +++ b/src/main/level/sublevels/game-shop-cache.ts @@ -0,0 +1,11 @@ +import type { ShopDetails } from "@types"; + +import { db } from "../level"; +import { levelKeys } from "./keys"; + +export const gamesShopCacheSublevel = db.sublevel( + levelKeys.gameShopCache, + { + valueEncoding: "json", + } +); diff --git a/src/main/level/sublevels/games.ts b/src/main/level/sublevels/games.ts new file mode 100644 index 00000000..ce7492f1 --- /dev/null +++ b/src/main/level/sublevels/games.ts @@ -0,0 +1,8 @@ +import type { Game } from "@types"; + +import { db } from "../level"; +import { levelKeys } from "./keys"; + +export const gamesSublevel = db.sublevel(levelKeys.games, { + valueEncoding: "json", +}); diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts new file mode 100644 index 00000000..a96a464c --- /dev/null +++ b/src/main/level/sublevels/index.ts @@ -0,0 +1,6 @@ +export * from "./downloads"; +export * from "./games"; +export * from "./game-shop-cache"; +export * from "./game-achievements"; + +export * from "./keys"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts new file mode 100644 index 00000000..53eae44b --- /dev/null +++ b/src/main/level/sublevels/keys.ts @@ -0,0 +1,16 @@ +import type { GameShop } from "@types"; + +export const levelKeys = { + games: "games", + game: (shop: GameShop, objectId: string) => `${shop}:${objectId}`, + user: "user", + auth: "auth", + gameShopCache: "gameShopCache", + gameShopCacheItem: (shop: GameShop, objectId: string, language: string) => + `${shop}:${objectId}:${language}`, + gameAchievements: "gameAchievements", + downloads: "downloads", + userPreferences: "userPreferences", + language: "language", + sqliteMigrationDone: "sqliteMigrationDone", +}; diff --git a/src/main/main.ts b/src/main/main.ts index add619e1..4824a1a5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,24 +1,50 @@ -import { DownloadManager, Ludusavi, startMainLoop } from "./services"; import { - downloadQueueRepository, - gameRepository, - userPreferencesRepository, -} from "./repository"; -import { UserPreferences } from "./entity"; + Crypto, + DownloadManager, + logger, + Ludusavi, + startMainLoop, +} from "./services"; import { RealDebridClient } from "./services/download/real-debrid"; import { HydraApi } from "./services/hydra-api"; import { uploadGamesBatch } from "./services/library-sync"; import { Aria2 } from "./services/aria2"; +import { downloadsSublevel } from "./level/sublevels/downloads"; +import { sortBy } from "lodash-es"; import { Downloader } from "@shared"; -import { IsNull, Not } from "typeorm"; +import { + gameAchievementsSublevel, + gamesSublevel, + levelKeys, + db, +} 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(levelKeys.sqliteMigrationDone, true, { + valueEncoding: "json", + }); + + return db.get(levelKeys.userPreferences, { + valueEncoding: "json", + }); + }); + + await import("./events"); Aria2.spawn(); if (userPreferences?.realDebridApiToken) { - RealDebridClient.authorize(userPreferences?.realDebridApiToken); + RealDebridClient.authorize( + Crypto.decrypt(userPreferences.realDebridApiToken) + ); + } + + if (userPreferences?.torBoxApiToken) { + TorBoxClient.authorize(Crypto.decrypt(userPreferences.torBoxApiToken)); } Ludusavi.addManifestToLudusaviConfig(); @@ -27,33 +53,162 @@ const loadState = async (userPreferences: UserPreferences | null) => { uploadGamesBatch(); }); - const [nextQueueItem] = await downloadQueueRepository.find({ - order: { - id: "DESC", - }, - relations: { - game: true, - }, - }); + const downloads = await downloadsSublevel + .values() + .all() + .then((games) => { + return sortBy( + games.filter((game) => game.queued), + "timestamp", + "DESC" + ); + }); - const seedList = await gameRepository.find({ - where: { - shouldSeed: true, - downloader: Downloader.Torrent, - progress: 1, - uri: Not(IsNull()), - }, - }); + const [nextItemOnQueue] = downloads; - await DownloadManager.startRPC(nextQueueItem?.game, seedList); + const downloadsToSeed = downloads.filter( + (download) => + download.shouldSeed && + download.downloader === Downloader.Torrent && + download.progress === 1 && + download.uri !== null + ); + + await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed); startMainLoop(); }; -userPreferencesRepository - .findOne({ - where: { id: 1 }, - }) - .then((userPreferences) => { - loadState(userPreferences); - }); +const migrateFromSqlite = async () => { + const sqliteMigrationDone = await db.get(levelKeys.sqliteMigrationDone); + + if (sqliteMigrationDone) { + return; + } + + const migrateGames = knexClient("game") + .select("*") + .then((games) => { + return gamesSublevel.batch( + games.map((game) => ({ + type: "put", + key: levelKeys.game(game.shop, game.objectID), + value: { + objectId: game.objectID, + shop: game.shop, + title: game.title, + iconUrl: game.iconUrl, + playTimeInMilliseconds: game.playTimeInMilliseconds, + lastTimePlayed: game.lastTimePlayed, + remoteId: game.remoteId, + winePrefixPath: game.winePrefixPath, + launchOptions: game.launchOptions, + executablePath: game.executablePath, + isDeleted: game.isDeleted === 1, + }, + })) + ); + }) + .then(() => { + logger.info("Games migrated successfully"); + }); + + const migrateUserPreferences = knexClient("user_preferences") + .select("*") + .then(async (userPreferences) => { + if (userPreferences.length > 0) { + const { realDebridApiToken, ...rest } = userPreferences[0]; + + await db.put( + levelKeys.userPreferences, + { + ...rest, + realDebridApiToken: realDebridApiToken + ? Crypto.encrypt(realDebridApiToken) + : null, + preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1, + runAtStartup: rest.runAtStartup === 1, + startMinimized: rest.startMinimized === 1, + disableNsfwAlert: rest.disableNsfwAlert === 1, + seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1, + showHiddenAchievementsDescription: + rest.showHiddenAchievementsDescription === 1, + downloadNotificationsEnabled: + rest.downloadNotificationsEnabled === 1, + repackUpdatesNotificationsEnabled: + rest.repackUpdatesNotificationsEnabled === 1, + achievementNotificationsEnabled: + rest.achievementNotificationsEnabled === 1, + }, + { valueEncoding: "json" } + ); + + if (rest.language) { + await db.put(levelKeys.language, rest.language); + } + } + }) + .then(() => { + logger.info("User preferences migrated successfully"); + }); + + const migrateAchievements = knexClient("game_achievement") + .select("*") + .then((achievements) => { + return gameAchievementsSublevel.batch( + achievements.map((achievement) => ({ + type: "put", + key: levelKeys.game(achievement.shop, achievement.objectId), + value: { + achievements: JSON.parse(achievement.achievements), + unlockedAchievements: JSON.parse(achievement.unlockedAchievements), + }, + })) + ); + }) + .then(() => { + logger.info("Achievements migrated successfully"); + }); + + const migrateUser = knexClient("user_auth") + .select("*") + .then(async (users) => { + if (users.length > 0) { + await db.put( + levelKeys.user, + { + id: users[0].userId, + displayName: users[0].displayName, + profileImageUrl: users[0].profileImageUrl, + backgroundImageUrl: users[0].backgroundImageUrl, + subscription: users[0].subscription, + }, + { + valueEncoding: "json", + } + ); + + await db.put( + levelKeys.auth, + { + accessToken: Crypto.encrypt(users[0].accessToken), + refreshToken: Crypto.encrypt(users[0].refreshToken), + tokenExpirationTimestamp: users[0].tokenExpirationTimestamp, + }, + { + valueEncoding: "json", + } + ); + } + }) + .then(() => { + logger.info("User data migrated successfully"); + }); + + return Promise.allSettled([ + migrateGames, + migrateUserPreferences, + migrateAchievements, + migrateUser, + ]); +}; diff --git a/src/main/migrations/20240830143811_Hydra_2_0_3.ts b/src/main/migrations/20240830143811_Hydra_2_0_3.ts deleted file mode 100644 index 6013f714..00000000 --- a/src/main/migrations/20240830143811_Hydra_2_0_3.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const Hydra2_0_3: HydraMigration = { - name: "Hydra_2_0_3", - up: async (knex: Knex) => { - const timestamp = new Date().getTime(); - - await knex.schema.hasTable("migrations").then(async (exists) => { - if (exists) { - await knex.schema.dropTable("migrations"); - } - }); - - await knex.schema.hasTable("download_source").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("download_source", (table) => { - table.increments("id").primary(); - table - .text("url") - .unique({ indexName: "download_source_url_unique_" + timestamp }); - table.text("name").notNullable(); - table.text("etag"); - table.integer("downloadCount").notNullable().defaultTo(0); - table.text("status").notNullable().defaultTo(0); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - }); - } - }); - - await knex.schema.hasTable("repack").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("repack", (table) => { - table.increments("id").primary(); - table - .text("title") - .notNullable() - .unique({ indexName: "repack_title_unique_" + timestamp }); - table - .text("magnet") - .notNullable() - .unique({ indexName: "repack_magnet_unique_" + timestamp }); - table.integer("page"); - table.text("repacker").notNullable(); - table.text("fileSize").notNullable(); - table.datetime("uploadDate").notNullable(); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - table - .integer("downloadSourceId") - .references("download_source.id") - .onDelete("CASCADE"); - }); - } - }); - - await knex.schema.hasTable("game").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("game", (table) => { - table.increments("id").primary(); - table - .text("objectID") - .notNullable() - .unique({ indexName: "game_objectID_unique_" + timestamp }); - table - .text("remoteId") - .unique({ indexName: "game_remoteId_unique_" + timestamp }); - table.text("title").notNullable(); - table.text("iconUrl"); - table.text("folderName"); - table.text("downloadPath"); - table.text("executablePath"); - table.integer("playTimeInMilliseconds").notNullable().defaultTo(0); - table.text("shop").notNullable(); - table.text("status"); - table.integer("downloader").notNullable().defaultTo(1); - table.float("progress").notNullable().defaultTo(0); - table.integer("bytesDownloaded").notNullable().defaultTo(0); - table.datetime("lastTimePlayed"); - table.float("fileSize").notNullable().defaultTo(0); - table.text("uri"); - table.boolean("isDeleted").notNullable().defaultTo(0); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - table - .integer("repackId") - .references("repack.id") - .unique("repack_repackId_unique_" + timestamp); - }); - } - }); - - await knex.schema.hasTable("user_preferences").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("user_preferences", (table) => { - table.increments("id").primary(); - table.text("downloadsPath"); - table.text("language").notNullable().defaultTo("en"); - table.text("realDebridApiToken"); - table - .boolean("downloadNotificationsEnabled") - .notNullable() - .defaultTo(0); - table - .boolean("repackUpdatesNotificationsEnabled") - .notNullable() - .defaultTo(0); - table.boolean("preferQuitInsteadOfHiding").notNullable().defaultTo(0); - table.boolean("runAtStartup").notNullable().defaultTo(0); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - }); - } - }); - - await knex.schema.hasTable("game_shop_cache").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("game_shop_cache", (table) => { - table.text("objectID").primary().notNullable(); - table.text("shop").notNullable(); - table.text("serializedData"); - table.text("howLongToBeatSerializedData"); - table.text("language"); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - }); - } - }); - - await knex.schema.hasTable("download_queue").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("download_queue", (table) => { - table.increments("id").primary(); - table - .integer("gameId") - .references("game.id") - .unique("download_queue_gameId_unique_" + timestamp); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - }); - } - }); - - await knex.schema.hasTable("user_auth").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("user_auth", (table) => { - table.increments("id").primary(); - table.text("userId").notNullable().defaultTo(""); - table.text("displayName").notNullable().defaultTo(""); - table.text("profileImageUrl"); - table.text("accessToken").notNullable().defaultTo(""); - table.text("refreshToken").notNullable().defaultTo(""); - table.integer("tokenExpirationTimestamp").notNullable().defaultTo(0); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - }); - } - }); - }, - - down: async (knex: Knex) => { - await knex.schema.dropTableIfExists("game"); - await knex.schema.dropTableIfExists("repack"); - await knex.schema.dropTableIfExists("download_queue"); - await knex.schema.dropTableIfExists("user_auth"); - await knex.schema.dropTableIfExists("game_shop_cache"); - await knex.schema.dropTableIfExists("user_preferences"); - await knex.schema.dropTableIfExists("download_source"); - }, -}; diff --git a/src/main/migrations/20240830143906_RepackUris.ts b/src/main/migrations/20240830143906_RepackUris.ts deleted file mode 100644 index 18bb9a59..00000000 --- a/src/main/migrations/20240830143906_RepackUris.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const RepackUris: HydraMigration = { - name: "RepackUris", - up: async (knex: Knex) => { - await knex.schema.alterTable("repack", (table) => { - table.text("uris").notNullable().defaultTo("[]"); - }); - }, - - down: async (knex: Knex) => { - await knex.schema.alterTable("repack", (table) => { - table.integer("page"); - table.dropColumn("uris"); - }); - }, -}; diff --git a/src/main/migrations/20240913213944_update_user_language.ts b/src/main/migrations/20240913213944_update_user_language.ts deleted file mode 100644 index 3297eb0d..00000000 --- a/src/main/migrations/20240913213944_update_user_language.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const UpdateUserLanguage: HydraMigration = { - name: "UpdateUserLanguage", - up: async (knex: Knex) => { - await knex("user_preferences") - .update("language", "pt-BR") - .where("language", "pt"); - }, - - down: async (_knex: Knex) => {}, -}; diff --git a/src/main/migrations/20240915035339_ensure_repack_uris.ts b/src/main/migrations/20240915035339_ensure_repack_uris.ts deleted file mode 100644 index 64fbcd2e..00000000 --- a/src/main/migrations/20240915035339_ensure_repack_uris.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const EnsureRepackUris: HydraMigration = { - name: "EnsureRepackUris", - up: async (knex: Knex) => { - await knex.schema.hasColumn("repack", "uris").then(async (exists) => { - if (!exists) { - await knex.schema.table("repack", (table) => { - table.text("uris").notNullable().defaultTo("[]"); - }); - } - }); - }, - - down: async (_knex: Knex) => {}, -}; diff --git a/src/main/migrations/20240918001920_FixMissingColumns.ts b/src/main/migrations/20240918001920_FixMissingColumns.ts deleted file mode 100644 index d23662ed..00000000 --- a/src/main/migrations/20240918001920_FixMissingColumns.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const FixMissingColumns: HydraMigration = { - name: "FixMissingColumns", - up: async (knex: Knex) => { - const timestamp = new Date().getTime(); - await knex.schema - .hasColumn("repack", "downloadSourceId") - .then(async (exists) => { - if (!exists) { - await knex.schema.table("repack", (table) => { - table - .integer("downloadSourceId") - .references("download_source.id") - .onDelete("CASCADE"); - }); - } - }); - - await knex.schema.hasColumn("game", "remoteId").then(async (exists) => { - if (!exists) { - await knex.schema.table("game", (table) => { - table - .text("remoteId") - .unique({ indexName: "game_remoteId_unique_" + timestamp }); - }); - } - }); - - await knex.schema.hasColumn("game", "uri").then(async (exists) => { - if (!exists) { - await knex.schema.table("game", (table) => { - table.text("uri"); - }); - } - }); - }, - - down: async (_knex: Knex) => {}, -}; diff --git a/src/main/migrations/20240919030940_create_game_achievement.ts b/src/main/migrations/20240919030940_create_game_achievement.ts deleted file mode 100644 index 791eeb29..00000000 --- a/src/main/migrations/20240919030940_create_game_achievement.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const CreateGameAchievement: HydraMigration = { - name: "CreateGameAchievement", - up: (knex: Knex) => { - return knex.schema.createTable("game_achievement", (table) => { - table.increments("id").primary(); - table.text("objectId").notNullable(); - table.text("shop").notNullable(); - table.text("achievements"); - table.text("unlockedAchievements"); - table.unique(["objectId", "shop"]); - }); - }, - - down: (knex: Knex) => { - return knex.schema.dropTable("game_achievement"); - }, -}; diff --git a/src/main/migrations/20241013012900_add_achievement_notification_preference.ts b/src/main/migrations/20241013012900_add_achievement_notification_preference.ts deleted file mode 100644 index a4f48265..00000000 --- a/src/main/migrations/20241013012900_add_achievement_notification_preference.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddAchievementNotificationPreference: HydraMigration = { - name: "AddAchievementNotificationPreference", - up: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.boolean("achievementNotificationsEnabled").defaultTo(true); - }); - }, - - down: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.dropColumn("achievementNotificationsEnabled"); - }); - }, -}; diff --git a/src/main/migrations/20241015235142_create_user_subscription.ts b/src/main/migrations/20241015235142_create_user_subscription.ts deleted file mode 100644 index 5f9ecab1..00000000 --- a/src/main/migrations/20241015235142_create_user_subscription.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const CreateUserSubscription: HydraMigration = { - name: "CreateUserSubscription", - up: async (knex: Knex) => { - return knex.schema.createTable("user_subscription", (table) => { - table.increments("id").primary(); - table.string("subscriptionId").defaultTo(""); - table - .text("userId") - .notNullable() - .references("user_auth.id") - .onDelete("CASCADE"); - table.string("status").defaultTo(""); - table.string("planId").defaultTo(""); - table.string("planName").defaultTo(""); - table.dateTime("expiresAt").nullable(); - table.dateTime("createdAt").defaultTo(knex.fn.now()); - table.dateTime("updatedAt").defaultTo(knex.fn.now()); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.dropTable("user_subscription"); - }, -}; diff --git a/src/main/migrations/20241016100249_add_background_image_url.ts b/src/main/migrations/20241016100249_add_background_image_url.ts deleted file mode 100644 index b377c650..00000000 --- a/src/main/migrations/20241016100249_add_background_image_url.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddBackgroundImageUrl: HydraMigration = { - name: "AddBackgroundImageUrl", - up: (knex: Knex) => { - return knex.schema.alterTable("user_auth", (table) => { - return table.text("backgroundImageUrl").nullable(); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("user_auth", (table) => { - return table.dropColumn("backgroundImageUrl"); - }); - }, -}; diff --git a/src/main/migrations/20241019081648_add_wine_prefix_to_game.ts b/src/main/migrations/20241019081648_add_wine_prefix_to_game.ts deleted file mode 100644 index 517f6fb5..00000000 --- a/src/main/migrations/20241019081648_add_wine_prefix_to_game.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddWinePrefixToGame: HydraMigration = { - name: "AddWinePrefixToGame", - up: (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.text("winePrefixPath").nullable(); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.dropColumn("winePrefixPath"); - }); - }, -}; diff --git a/src/main/migrations/20241030171454_add_start_minimized_column.ts b/src/main/migrations/20241030171454_add_start_minimized_column.ts deleted file mode 100644 index 69ede189..00000000 --- a/src/main/migrations/20241030171454_add_start_minimized_column.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddStartMinimizedColumn: HydraMigration = { - name: "AddStartMinimizedColumn", - up: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.boolean("startMinimized").notNullable().defaultTo(0); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.dropColumn("startMinimized"); - }); - }, -}; diff --git a/src/main/migrations/20241106053733_add_disable_nsfw_alert_column.ts b/src/main/migrations/20241106053733_add_disable_nsfw_alert_column.ts deleted file mode 100644 index a248dd2b..00000000 --- a/src/main/migrations/20241106053733_add_disable_nsfw_alert_column.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddDisableNsfwAlertColumn: HydraMigration = { - name: "AddDisableNsfwAlertColumn", - up: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.boolean("disableNsfwAlert").notNullable().defaultTo(0); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.dropColumn("disableNsfwAlert"); - }); - }, -}; diff --git a/src/main/migrations/20241108200154_add_should_seed_colum.ts b/src/main/migrations/20241108200154_add_should_seed_colum.ts deleted file mode 100644 index 7e90a3b1..00000000 --- a/src/main/migrations/20241108200154_add_should_seed_colum.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddShouldSeedColumn: HydraMigration = { - name: "AddShouldSeedColumn", - up: (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.boolean("shouldSeed").notNullable().defaultTo(true); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.dropColumn("shouldSeed"); - }); - }, -}; diff --git a/src/main/migrations/20241108201806_add_seed_after_download.ts b/src/main/migrations/20241108201806_add_seed_after_download.ts deleted file mode 100644 index 75b94577..00000000 --- a/src/main/migrations/20241108201806_add_seed_after_download.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddSeedAfterDownloadColumn: HydraMigration = { - name: "AddSeedAfterDownloadColumn", - up: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table - .boolean("seedAfterDownloadComplete") - .notNullable() - .defaultTo(true); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.dropColumn("seedAfterDownloadComplete"); - }); - }, -}; diff --git a/src/main/migrations/20241216140633_add_hidden_achievement_description_column .ts b/src/main/migrations/20241216140633_add_hidden_achievement_description_column .ts deleted file mode 100644 index 36771c43..00000000 --- a/src/main/migrations/20241216140633_add_hidden_achievement_description_column .ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddHiddenAchievementDescriptionColumn: HydraMigration = { - name: "AddHiddenAchievementDescriptionColumn", - up: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table - .boolean("showHiddenAchievementsDescription") - .notNullable() - .defaultTo(0); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.dropColumn("showHiddenAchievementsDescription"); - }); - }, -}; diff --git a/src/main/migrations/20241226044022_add_launch_options_column_to_game.ts b/src/main/migrations/20241226044022_add_launch_options_column_to_game.ts deleted file mode 100644 index 417eeb63..00000000 --- a/src/main/migrations/20241226044022_add_launch_options_column_to_game.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddLaunchOptionsColumnToGame: HydraMigration = { - name: "AddLaunchOptionsColumnToGame", - up: (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.string("launchOptions").nullable(); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.dropColumn("launchOptions"); - }); - }, -}; diff --git a/src/main/migrations/migration.stub b/src/main/migrations/migration.stub deleted file mode 100644 index 299b3fc2..00000000 --- a/src/main/migrations/migration.stub +++ /dev/null @@ -1,11 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const MigrationName: HydraMigration = { - name: "MigrationName", - up: (knex: Knex) => { - return knex.schema.createTable("table_name", async (table) => {}); - }, - - down: async (knex: Knex) => {}, -}; diff --git a/src/main/repository.ts b/src/main/repository.ts deleted file mode 100644 index e0c4204e..00000000 --- a/src/main/repository.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { dataSource } from "./data-source"; -import { - DownloadQueue, - Game, - GameShopCache, - UserPreferences, - UserAuth, - GameAchievement, - UserSubscription, -} from "@main/entity"; - -export const gameRepository = dataSource.getRepository(Game); - -export const userPreferencesRepository = - dataSource.getRepository(UserPreferences); - -export const gameShopCacheRepository = dataSource.getRepository(GameShopCache); - -export const downloadQueueRepository = dataSource.getRepository(DownloadQueue); - -export const userAuthRepository = dataSource.getRepository(UserAuth); - -export const userSubscriptionRepository = - dataSource.getRepository(UserSubscription); - -export const gameAchievementRepository = - dataSource.getRepository(GameAchievement); diff --git a/src/main/services/achievements/achievement-watcher-manager.ts b/src/main/services/achievements/achievement-watcher-manager.ts index 6a1eb11c..8b076d9e 100644 --- a/src/main/services/achievements/achievement-watcher-manager.ts +++ b/src/main/services/achievements/achievement-watcher-manager.ts @@ -1,6 +1,4 @@ -import { gameRepository } from "@main/repository"; import { parseAchievementFile } from "./parse-achievement-file"; -import { Game } from "@main/entity"; import { mergeAchievements } from "./merge-achievements"; import fs, { readdirSync } from "node:fs"; import { @@ -9,21 +7,20 @@ import { findAllAchievementFiles, getAlternativeObjectIds, } from "./find-achivement-files"; -import type { AchievementFile, UnlockedAchievement } from "@types"; +import type { AchievementFile, Game, UnlockedAchievement } from "@types"; import { achievementsLogger } from "../logger"; import { Cracker } from "@shared"; -import { IsNull, Not } from "typeorm"; import { publishCombinedNewAchievementNotification } from "../notifications"; +import { gamesSublevel } from "@main/level"; const fileStats: Map = new Map(); const fltFiles: Map> = new Map(); const watchAchievementsWindows = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((games) => games.filter((game) => !game.isDeleted)); if (games.length === 0) return; @@ -32,7 +29,7 @@ const watchAchievementsWindows = async () => { for (const game of games) { const gameAchievementFiles: AchievementFile[] = []; - for (const objectId of getAlternativeObjectIds(game.objectID)) { + for (const objectId of getAlternativeObjectIds(game.objectId)) { gameAchievementFiles.push(...(achievementFiles.get(objectId) || [])); gameAchievementFiles.push( @@ -47,12 +44,12 @@ const watchAchievementsWindows = async () => { }; const watchAchievementsWithWine = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - winePrefixPath: Not(IsNull()), - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((games) => + games.filter((game) => !game.isDeleted && game.winePrefixPath) + ); for (const game of games) { const gameAchievementFiles = findAchievementFiles(game); @@ -144,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") { @@ -152,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( @@ -185,14 +182,13 @@ export class AchievementWatcherManager { } return mergeAchievements(game, unlockedAchievements, false); - }; + } private static preSearchAchievementsWindows = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((games) => games.filter((game) => !game.isDeleted)); const gameAchievementFilesMap = findAllAchievementFiles(); @@ -200,7 +196,7 @@ export class AchievementWatcherManager { games.map((game) => { const gameAchievementFiles: AchievementFile[] = []; - for (const objectId of getAlternativeObjectIds(game.objectID)) { + for (const objectId of getAlternativeObjectIds(game.objectId)) { gameAchievementFiles.push( ...(gameAchievementFilesMap.get(objectId) || []) ); @@ -216,11 +212,10 @@ export class AchievementWatcherManager { }; private static preSearchAchievementsWithWine = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((games) => games.filter((game) => !game.isDeleted)); return Promise.all( games.map((game) => { @@ -235,7 +230,7 @@ export class AchievementWatcherManager { ); }; - public static preSearchAchievements = async () => { + public static async preSearchAchievements() { try { const newAchievementsCount = process.platform === "win32" @@ -261,5 +256,5 @@ export class AchievementWatcherManager { } this.hasFinishedMergingWithRemote = true; - }; + } } diff --git a/src/main/services/achievements/find-achivement-files.ts b/src/main/services/achievements/find-achivement-files.ts index 4fc6a4cd..7c0660cc 100644 --- a/src/main/services/achievements/find-achivement-files.ts +++ b/src/main/services/achievements/find-achivement-files.ts @@ -1,9 +1,8 @@ import path from "node:path"; import fs from "node:fs"; import { app } from "electron"; -import type { AchievementFile } from "@types"; +import type { Game, AchievementFile } from "@types"; import { Cracker } from "@shared"; -import { Game } from "@main/entity"; import { achievementsLogger } from "../logger"; const getAppDataPath = () => { @@ -254,7 +253,7 @@ export const findAchievementFiles = (game: Game) => { for (const cracker of crackers) { for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) { - for (const objectId of getAlternativeObjectIds(game.objectID)) { + for (const objectId of getAlternativeObjectIds(game.objectId)) { const filePath = path.join( game.winePrefixPath ?? "", folderPath, diff --git a/src/main/services/achievements/get-game-achievement-data.ts b/src/main/services/achievements/get-game-achievement-data.ts index daac7e11..0d0c58f9 100644 --- a/src/main/services/achievements/get-game-achievement-data.ts +++ b/src/main/services/achievements/get-game-achievement-data.ts @@ -1,40 +1,37 @@ -import { - gameAchievementRepository, - userPreferencesRepository, -} from "@main/repository"; import { HydraApi } from "../hydra-api"; -import type { AchievementData, GameShop } from "@types"; +import type { GameShop, SteamAchievement } from "@types"; import { UserNotLoggedInError } from "@shared"; import { logger } from "../logger"; -import { GameAchievement } from "@main/entity"; +import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; export const getGameAchievementData = async ( objectId: string, shop: GameShop, - cachedAchievements: GameAchievement | null + useCachedData: boolean ) => { - if (cachedAchievements && cachedAchievements.achievements) { - return JSON.parse(cachedAchievements.achievements) as AchievementData[]; - } + const cachedAchievements = await gameAchievementsSublevel.get( + levelKeys.game(shop, objectId) + ); - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + if (cachedAchievements && useCachedData) + return cachedAchievements.achievements; - return HydraApi.get("/games/achievements", { + const language = await db + .get(levelKeys.language, { + valueEncoding: "utf-8", + }) + .then((language) => language || "en"); + + return HydraApi.get("/games/achievements", { shop, objectId, - language: userPreferences?.language || "en", + language, }) - .then((achievements) => { - gameAchievementRepository.upsert( - { - objectId, - shop, - achievements: JSON.stringify(achievements), - }, - ["objectId", "shop"] - ); + .then(async (achievements) => { + await gameAchievementsSublevel.put(levelKeys.game(shop, objectId), { + unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [], + achievements, + }); return achievements; }) @@ -42,15 +39,9 @@ export const getGameAchievementData = async ( if (err instanceof UserNotLoggedInError) { throw err; } - logger.error("Failed to get game achievements", err); - return gameAchievementRepository - .findOne({ - where: { objectId, shop }, - }) - .then((gameAchievements) => { - return JSON.parse( - gameAchievements?.achievements || "[]" - ) as AchievementData[]; - }); + + logger.error("Failed to get game achievements for", objectId, err); + + return []; }); }; diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index dd8c877d..7e6ebf0a 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -1,42 +1,45 @@ -import { - gameAchievementRepository, - userPreferencesRepository, -} from "@main/repository"; -import type { AchievementData, GameShop, UnlockedAchievement } from "@types"; +import type { + Game, + GameShop, + UnlockedAchievement, + UserPreferences, +} from "@types"; import { WindowManager } from "../window-manager"; import { HydraApi } from "../hydra-api"; import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements"; -import { Game } from "@main/entity"; import { publishNewAchievementNotification } from "../notifications"; import { SubscriptionRequiredError } from "@shared"; import { achievementsLogger } from "../logger"; +import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; const saveAchievementsOnLocal = async ( objectId: string, shop: GameShop, - achievements: UnlockedAchievement[], + unlockedAchievements: UnlockedAchievement[], sendUpdateEvent: boolean ) => { - return gameAchievementRepository - .upsert( - { - objectId, - shop, - unlockedAchievements: JSON.stringify(achievements), - }, - ["objectId", "shop"] - ) - .then(() => { - if (!sendUpdateEvent) return; + const levelKey = levelKeys.game(shop, objectId); - return getUnlockedAchievements(objectId, shop, true) - .then((achievements) => { - WindowManager.mainWindow?.webContents.send( - `on-update-achievements-${objectId}-${shop}`, - achievements - ); - }) - .catch(() => {}); + return gameAchievementsSublevel + .get(levelKey) + .then(async (gameAchievement) => { + if (gameAchievement) { + await gameAchievementsSublevel.put(levelKey, { + ...gameAchievement, + unlockedAchievements: unlockedAchievements, + }); + + if (!sendUpdateEvent) return; + + return getUnlockedAchievements(objectId, shop, true) + .then((achievements) => { + WindowManager.mainWindow?.webContents.send( + `on-update-achievements-${objectId}-${shop}`, + achievements + ); + }) + .catch(() => {}); + } }); }; @@ -46,25 +49,17 @@ export const mergeAchievements = async ( publishNotification: boolean ) => { const [localGameAchievement, userPreferences] = await Promise.all([ - gameAchievementRepository.findOne({ - where: { - objectId: game.objectID, - shop: game.shop, - }, + gameAchievementsSublevel.get(levelKeys.game(game.shop, game.objectId)), + db.get(levelKeys.userPreferences, { + valueEncoding: "json", }), - userPreferencesRepository.findOne({ where: { id: 1 } }), ]); - const achievementsData = JSON.parse( - localGameAchievement?.achievements || "[]" - ) as AchievementData[]; - - const unlockedAchievements = JSON.parse( - localGameAchievement?.unlockedAchievements || "[]" - ).filter((achievement) => achievement.name) as UnlockedAchievement[]; + const achievementsData = localGameAchievement?.achievements ?? []; + const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? []; const newAchievementsMap = new Map( - achievements.reverse().map((achievement) => { + achievements.toReversed().map((achievement) => { return [achievement.name.toUpperCase(), achievement]; }) ); @@ -92,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) => { @@ -141,13 +136,13 @@ export const mergeAchievements = async ( if (err! instanceof SubscriptionRequiredError) { achievementsLogger.log( "Achievements not synchronized on API due to lack of subscription", - game.objectID, + game.objectId, game.title ); } return saveAchievementsOnLocal( - game.objectID, + game.objectId, game.shop, mergedLocalAchievements, publishNotification @@ -155,7 +150,7 @@ export const mergeAchievements = async ( }); } else { await saveAchievementsOnLocal( - game.objectID, + game.objectId, game.shop, mergedLocalAchievements, publishNotification diff --git a/src/main/services/achievements/update-local-unlocked-achivements.ts b/src/main/services/achievements/update-local-unlocked-achivements.ts index 0393477c..8832a475 100644 --- a/src/main/services/achievements/update-local-unlocked-achivements.ts +++ b/src/main/services/achievements/update-local-unlocked-achivements.ts @@ -4,8 +4,7 @@ import { } from "./find-achivement-files"; import { parseAchievementFile } from "./parse-achievement-file"; import { mergeAchievements } from "./merge-achievements"; -import type { UnlockedAchievement } from "@types"; -import { Game } from "@main/entity"; +import type { Game, UnlockedAchievement } from "@types"; export const updateLocalUnlockedAchivements = async (game: Game) => { const gameAchievementFiles = findAchievementFiles(game); diff --git a/src/main/services/crypto.ts b/src/main/services/crypto.ts new file mode 100644 index 00000000..63a50668 --- /dev/null +++ b/src/main/services/crypto.ts @@ -0,0 +1,28 @@ +import { safeStorage } from "electron"; +import { logger } from "./logger"; + +export class Crypto { + public static encrypt(str: string) { + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.encryptString(str).toString("base64"); + } else { + logger.warn( + "Encrypt method returned raw string because encryption is not available" + ); + + return str; + } + } + + public static decrypt(b64: string) { + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.decryptString(Buffer.from(b64, "base64")); + } else { + logger.warn( + "Decrypt method returned raw string because encryption is not available" + ); + + return b64; + } + } +} diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 134a74e6..5c19c1b1 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -1,14 +1,8 @@ -import { Game } from "@main/entity"; -import { Downloader } from "@shared"; +import { Downloader, DownloadError } from "@shared"; import { WindowManager } from "../window-manager"; -import { - downloadQueueRepository, - gameRepository, - userPreferencesRepository, -} from "@main/repository"; import { publishDownloadCompleteNotification } from "../notifications"; -import type { DownloadProgress } from "@types"; -import { GofileApi, QiwiApi, DatanodesApi } from "../hosters"; +import type { Download, DownloadProgress, UserPreferences } from "@types"; +import { GofileApi, QiwiApi, DatanodesApi, MediafireApi } from "../hosters"; import { PythonRPC } from "../python-rpc"; import { LibtorrentPayload, @@ -16,37 +10,45 @@ import { PauseDownloadPayload, } from "./types"; import { calculateETA, getDirSize } from "./helpers"; -import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; import { RealDebridClient } from "./real-debrid"; 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: number | null = null; + private static downloadingGameId: string | null = null; - public static async startRPC(game?: Game, initialSeeding?: Game[]) { + public static async startRPC( + download?: Download, + downloadsToSeed?: Download[] + ) { PythonRPC.spawn( - game?.status === "active" - ? await this.getDownloadPayload(game).catch(() => undefined) + download?.status === "active" + ? await this.getDownloadPayload(download).catch((err) => { + logger.error("Error getting download payload", err); + return undefined; + }) : undefined, - initialSeeding?.map((game) => ({ - game_id: game.id, - url: game.uri!, - save_path: game.downloadPath!, + downloadsToSeed?.map((download) => ({ + game_id: levelKeys.game(download.shop, download.objectId), + url: download.uri, + save_path: download.downloadPath, })) ); - this.downloadingGameId = game?.id ?? null; + if (download) { + this.downloadingGameId = levelKeys.game(download.shop, download.objectId); + } } private static async getDownloadStatus() { const response = await PythonRPC.rpc.get( "/status" ); - if (response.data === null || !this.downloadingGameId) return null; - - const gameId = this.downloadingGameId; + const downloadId = this.downloadingGameId; try { const { @@ -62,24 +64,21 @@ export class DownloadManager { const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata; - const isCheckingFiles = status === LibtorrentStatus.CheckingFiles; + const download = await downloadsSublevel.get(downloadId); + if (!isDownloadingMetadata && !isCheckingFiles) { - const update: QueryDeepPartialEntity = { + if (!download) return null; + + await downloadsSublevel.put(downloadId, { + ...download, bytesDownloaded, fileSize, progress, + folderName, status: "active", - }; - - await gameRepository.update( - { id: gameId }, - { - ...update, - folderName, - } - ); + }); } return { @@ -90,7 +89,8 @@ export class DownloadManager { isDownloadingMetadata, isCheckingFiles, progress, - gameId, + gameId: downloadId, + download, } as DownloadProgress; } catch (err) { return null; @@ -102,14 +102,22 @@ export class DownloadManager { if (status) { const { gameId, progress } = status; - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - }); - const userPreferences = await userPreferencesRepository.findOneBy({ - id: 1, - }); - if (WindowManager.mainWindow && game) { + const [download, game] = await Promise.all([ + downloadsSublevel.get(gameId), + gamesSublevel.get(gameId), + ]); + + if (!download || !game) return; + + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); + + if (WindowManager.mainWindow && download) { WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); WindowManager.mainWindow.webContents.send( "on-download-progress", @@ -121,39 +129,48 @@ export class DownloadManager { ) ); } - if (progress === 1 && game) { + + if (progress === 1 && download) { publishDownloadCompleteNotification(game); if ( userPreferences?.seedAfterDownloadComplete && - game.downloader === Downloader.Torrent + download.downloader === Downloader.Torrent ) { - gameRepository.update( - { id: gameId }, - { status: "seeding", shouldSeed: true } - ); + downloadsSublevel.put(gameId, { + ...download, + status: "seeding", + shouldSeed: true, + queued: false, + }); } else { - gameRepository.update( - { id: gameId }, - { status: "complete", shouldSeed: false } - ); + downloadsSublevel.put(gameId, { + ...download, + status: "complete", + shouldSeed: false, + queued: false, + }); this.cancelDownload(gameId); } - await downloadQueueRepository.delete({ game }); - const [nextQueueItem] = await downloadQueueRepository.find({ - order: { - id: "DESC", - }, - relations: { - game: true, - }, - }); - if (nextQueueItem) { - this.resumeDownload(nextQueueItem.game); + const downloads = await downloadsSublevel + .values() + .all() + .then((games) => { + return sortBy( + games.filter((game) => game.status === "paused" && game.queued), + "timestamp", + "DESC" + ); + }); + + const [nextItemOnQueue] = downloads; + + if (nextItemOnQueue) { + this.resumeDownload(nextItemOnQueue); } else { - this.downloadingGameId = -1; + this.downloadingGameId = null; } } } @@ -169,20 +186,19 @@ export class DownloadManager { logger.log(seedStatus); seedStatus.forEach(async (status) => { - const game = await gameRepository.findOne({ - where: { id: status.gameId }, - }); + const download = await downloadsSublevel.get(status.gameId); - if (!game) return; + if (!download) return; const totalSize = await getDirSize( - path.join(game.downloadPath!, status.folderName) + path.join(download.downloadPath, status.folderName) ); if (totalSize < status.fileSize) { - await this.cancelDownload(game.id); + await this.cancelDownload(status.gameId); - await gameRepository.update(game.id, { + await downloadsSublevel.put(status.gameId, { + ...download, status: "paused", shouldSeed: false, progress: totalSize / status.fileSize, @@ -195,123 +211,144 @@ export class DownloadManager { WindowManager.mainWindow?.webContents.send("on-seeding-status", seedStatus); } - static async pauseDownload() { + static async pauseDownload(downloadKey = this.downloadingGameId) { await PythonRPC.rpc .post("/action", { action: "pause", - game_id: this.downloadingGameId, + game_id: downloadKey, } as PauseDownloadPayload) .catch(() => {}); WindowManager.mainWindow?.setProgressBar(-1); - this.downloadingGameId = null; } - static async resumeDownload(game: Game) { - return this.startDownload(game); + static async resumeDownload(download: Download) { + return this.startDownload(download); } - static async cancelDownload(gameId = this.downloadingGameId!) { + static async cancelDownload(downloadKey = this.downloadingGameId) { await PythonRPC.rpc.post("/action", { action: "cancel", - game_id: gameId, + game_id: downloadKey, }); WindowManager.mainWindow?.setProgressBar(-1); - - if (gameId === this.downloadingGameId) { + if (downloadKey === this.downloadingGameId) { this.downloadingGameId = null; } } - static async resumeSeeding(game: Game) { + static async resumeSeeding(download: Download) { await PythonRPC.rpc.post("/action", { action: "resume_seeding", - game_id: game.id, - url: game.uri, - save_path: game.downloadPath, + game_id: levelKeys.game(download.shop, download.objectId), + url: download.uri, + save_path: download.downloadPath, }); } - static async pauseSeeding(gameId: number) { + static async pauseSeeding(downloadKey: string) { await PythonRPC.rpc.post("/action", { action: "pause_seeding", - game_id: gameId, + game_id: downloadKey, }); } - private static async getDownloadPayload(game: Game) { - switch (game.downloader) { - case Downloader.Gofile: { - const id = game.uri!.split("/").pop(); + private static async getDownloadPayload(download: Download) { + const downloadId = levelKeys.game(download.shop, download.objectId); + switch (download.downloader) { + case Downloader.Gofile: { + const id = download.uri.split("/").pop(); const token = await GofileApi.authorize(); const downloadLink = await GofileApi.getDownloadLink(id!); + await GofileApi.checkDownloadUrl(downloadLink); + return { action: "start", - game_id: game.id, + game_id: downloadId, url: downloadLink, - save_path: game.downloadPath!, + save_path: download.downloadPath, header: `Cookie: accountToken=${token}`, }; } case Downloader.PixelDrain: { - const id = game.uri!.split("/").pop(); + const id = download.uri.split("/").pop(); return { action: "start", - game_id: game.id, - url: `https://pixeldrain.com/api/file/${id}?download`, - save_path: game.downloadPath!, + game_id: downloadId, + url: `https://cdn.pd5-gamedriveorg.workers.dev/api/file/${id}`, + save_path: download.downloadPath, }; } case Downloader.Qiwi: { - const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!); - + const downloadUrl = await QiwiApi.getDownloadUrl(download.uri); return { action: "start", - game_id: game.id, + game_id: downloadId, url: downloadUrl, - save_path: game.downloadPath!, + save_path: download.downloadPath, }; } case Downloader.Datanodes: { - const downloadUrl = await DatanodesApi.getDownloadUrl(game.uri!); + const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); + return { + action: "start", + game_id: downloadId, + url: downloadUrl, + save_path: download.downloadPath, + }; + } + case Downloader.Mediafire: { + const downloadUrl = await MediafireApi.getDownloadUrl(download.uri); return { action: "start", - game_id: game.id, + game_id: downloadId, url: downloadUrl, - save_path: game.downloadPath!, + save_path: download.downloadPath, }; } case Downloader.Torrent: return { action: "start", - game_id: game.id, - url: game.uri!, - save_path: game.downloadPath!, + game_id: downloadId, + url: download.uri, + save_path: download.downloadPath, }; case Downloader.RealDebrid: { - const downloadUrl = await RealDebridClient.getDownloadUrl(game.uri!); + const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); + + if (!downloadUrl) throw new Error(DownloadError.NotCachedInRealDebrid); return { action: "start", - game_id: game.id, - url: downloadUrl!, - save_path: game.downloadPath!, + game_id: downloadId, + url: downloadUrl, + 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, }; } } } - static async startDownload(game: Game) { - const payload = await this.getDownloadPayload(game); - + static async startDownload(download: Download) { + const payload = await this.getDownloadPayload(download); await PythonRPC.rpc.post("/action", payload); - - this.downloadingGameId = game.id; + this.downloadingGameId = levelKeys.game(download.shop, download.objectId); } } diff --git a/src/main/services/download/torbox.ts b/src/main/services/download/torbox.ts index 3eade81d..8011cae8 100644 --- a/src/main/services/download/torbox.ts +++ b/src/main/services/download/torbox.ts @@ -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( "/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 }; } } diff --git a/src/main/services/download/types.ts b/src/main/services/download/types.ts index 8cacdcb7..0e868318 100644 --- a/src/main/services/download/types.ts +++ b/src/main/services/download/types.ts @@ -1,9 +1,9 @@ export interface PauseDownloadPayload { - game_id: number; + game_id: string; } export interface CancelDownloadPayload { - game_id: number; + game_id: string; } export enum LibtorrentStatus { @@ -24,7 +24,7 @@ export interface LibtorrentPayload { fileSize: number; folderName: string; status: LibtorrentStatus; - gameId: number; + gameId: string; } export interface ProcessPayload { diff --git a/src/main/services/hosters/gofile.ts b/src/main/services/hosters/gofile.ts index 2c23556f..5560ad31 100644 --- a/src/main/services/hosters/gofile.ts +++ b/src/main/services/hosters/gofile.ts @@ -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}`, + }, + }); + } } diff --git a/src/main/services/hosters/index.ts b/src/main/services/hosters/index.ts index 8cff7bd2..556897cd 100644 --- a/src/main/services/hosters/index.ts +++ b/src/main/services/hosters/index.ts @@ -1,3 +1,4 @@ export * from "./gofile"; export * from "./qiwi"; export * from "./datanodes"; +export * from "./mediafire"; diff --git a/src/main/services/hosters/mediafire.ts b/src/main/services/hosters/mediafire.ts new file mode 100644 index 00000000..babb7e7d --- /dev/null +++ b/src/main/services/hosters/mediafire.ts @@ -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 { + 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"); + } +} diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 16bbc21f..ba972b44 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -1,18 +1,18 @@ -import { - userAuthRepository, - userSubscriptionRepository, -} from "@main/repository"; import axios, { AxiosError, AxiosInstance } from "axios"; 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"; import { getUserData } from "./user/get-user-data"; import { isFuture, isToday } from "date-fns"; +import { db } from "@main/level"; +import { levelKeys } from "@main/level/sublevels"; +import type { Auth, User } from "@types"; +import { Crypto } from "./crypto"; interface HydraApiOptions { needsAuth?: boolean; @@ -32,7 +32,8 @@ export class HydraApi { private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes private static readonly ADD_LOG_INTERCEPTOR = true; - private static secondsToMilliseconds = (seconds: number) => seconds * 1000; + private static readonly secondsToMilliseconds = (seconds: number) => + seconds * 1000; private static userAuth: HydraApiUserAuth = { authToken: "", @@ -77,14 +78,14 @@ export class HydraApi { tokenExpirationTimestamp ); - await userAuthRepository.upsert( + db.put( + levelKeys.auth, { - id: 1, - accessToken, + accessToken: Crypto.encrypt(accessToken), + refreshToken: Crypto.encrypt(refreshToken), tokenExpirationTimestamp, - refreshToken, }, - ["id"] + { valueEncoding: "json" } ); await getUserData().then((userDetails) => { @@ -153,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, @@ -174,29 +176,43 @@ 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); } ); } - const userAuth = await userAuthRepository.findOne({ - where: { id: 1 }, - relations: { subscription: true }, + const result = await db.getMany([levelKeys.auth, levelKeys.user], { + valueEncoding: "json", }); + const userAuth = result.at(0) as Auth | undefined; + const user = result.at(1) as User | undefined; + this.userAuth = { - authToken: userAuth?.accessToken ?? "", - refreshToken: userAuth?.refreshToken ?? "", + authToken: userAuth?.accessToken + ? Crypto.decrypt(userAuth.accessToken) + : "", + refreshToken: userAuth?.refreshToken + ? Crypto.decrypt(userAuth.refreshToken) + : "", expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0, - subscription: userAuth?.subscription - ? { expiresAt: userAuth.subscription?.expiresAt } + subscription: user?.subscription + ? { expiresAt: user.subscription?.expiresAt } : null, }; @@ -216,11 +232,11 @@ export class HydraApi { } public static async refreshToken() { - const { accessToken, expiresIn } = await this.instance - .post<{ accessToken: string; expiresIn: number }>(`/auth/refresh`, { - refreshToken: this.userAuth.refreshToken, - }) - .then((response) => response.data); + const response = await this.instance.post(`/auth/refresh`, { + refreshToken: this.userAuth.refreshToken, + }); + + const { accessToken, expiresIn } = response.data; const tokenExpirationTimestamp = Date.now() + @@ -235,14 +251,19 @@ export class HydraApi { this.userAuth.expirationTimestamp ); - userAuthRepository.upsert( - { - id: 1, - accessToken, - tokenExpirationTimestamp, - }, - ["id"] - ); + await db + .get(levelKeys.auth, { valueEncoding: "json" }) + .then((auth) => { + return db.put( + levelKeys.auth, + { + ...auth, + accessToken: Crypto.encrypt(accessToken), + tokenExpirationTimestamp, + }, + { valueEncoding: "json" } + ); + }); return { accessToken, expiresIn }; } @@ -280,8 +301,16 @@ export class HydraApi { subscription: null, }; - userAuthRepository.delete({ id: 1 }); - userSubscriptionRepository.delete({ id: 1 }); + db.batch([ + { + type: "del", + key: levelKeys.auth, + }, + { + type: "del", + key: levelKeys.user, + }, + ]); this.sendSignOutEvent(); } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 5aaf5322..d2034f15 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -1,3 +1,4 @@ +export * from "./crypto"; export * from "./logger"; export * from "./steam"; export * from "./steam-250"; diff --git a/src/main/services/library-sync/clear-games-remote-id.ts b/src/main/services/library-sync/clear-games-remote-id.ts index f26d65f1..20989bc9 100644 --- a/src/main/services/library-sync/clear-games-remote-id.ts +++ b/src/main/services/library-sync/clear-games-remote-id.ts @@ -1,5 +1,16 @@ -import { gameRepository } from "@main/repository"; +import { gamesSublevel, levelKeys } from "@main/level"; -export const clearGamesRemoteIds = () => { - return gameRepository.update({}, { remoteId: null }); +export const clearGamesRemoteIds = async () => { + const games = await gamesSublevel.values().all(); + + await gamesSublevel.batch( + games.map((game) => ({ + type: "put", + key: levelKeys.game(game.shop, game.objectId), + value: { + ...game, + remoteId: null, + }, + })) + ); }; diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts index 6c701c9a..54718c1d 100644 --- a/src/main/services/library-sync/create-game.ts +++ b/src/main/services/library-sync/create-game.ts @@ -1,19 +1,21 @@ -import { Game } from "@main/entity"; +import type { Game } from "@types"; import { HydraApi } from "../hydra-api"; -import { gameRepository } from "@main/repository"; +import { gamesSublevel, levelKeys } from "@main/level"; export const createGame = async (game: Game) => { return HydraApi.post(`/profile/games`, { - objectId: game.objectID, + objectId: game.objectId, playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), shop: game.shop, lastTimePlayed: game.lastTimePlayed, }).then((response) => { const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response; - gameRepository.update( - { objectID: game.objectID }, - { remoteId, playTimeInMilliseconds, lastTimePlayed } - ); + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + ...game, + remoteId, + playTimeInMilliseconds, + lastTimePlayed, + }); }); }; diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index d286ad6c..2b6eebb0 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -1,17 +1,15 @@ -import { gameRepository } from "@main/repository"; import { HydraApi } from "../hydra-api"; import { steamGamesWorker } from "@main/workers"; import { steamUrlBuilder } from "@shared"; +import { gamesSublevel, levelKeys } from "@main/level"; export const mergeWithRemoteGames = async () => { return HydraApi.get("/profile/games") .then(async (response) => { for (const game of response) { - const localGame = await gameRepository.findOne({ - where: { - objectID: game.objectId, - }, - }); + const localGame = await gamesSublevel.get( + levelKeys.game(game.shop, game.objectId) + ); if (localGame) { const updatedLastTimePlayed = @@ -26,17 +24,12 @@ export const mergeWithRemoteGames = async () => { ? game.playTimeInMilliseconds : localGame.playTimeInMilliseconds; - gameRepository.update( - { - objectID: game.objectId, - shop: "steam", - }, - { - remoteId: game.id, - lastTimePlayed: updatedLastTimePlayed, - playTimeInMilliseconds: updatedPlayTime, - } - ); + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + ...localGame, + remoteId: game.id, + lastTimePlayed: updatedLastTimePlayed, + playTimeInMilliseconds: updatedPlayTime, + }); } else { const steamGame = await steamGamesWorker.run(Number(game.objectId), { name: "getById", @@ -47,14 +40,15 @@ export const mergeWithRemoteGames = async () => { ? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon) : null; - gameRepository.insert({ - objectID: game.objectId, + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + objectId: game.objectId, title: steamGame?.name, remoteId: game.id, shop: game.shop, iconUrl, lastTimePlayed: game.lastTimePlayed, playTimeInMilliseconds: game.playTimeInMilliseconds, + isDeleted: false, }); } } diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts index 28c3bed3..3689b302 100644 --- a/src/main/services/library-sync/update-game-playtime.ts +++ b/src/main/services/library-sync/update-game-playtime.ts @@ -1,4 +1,4 @@ -import { Game } from "@main/entity"; +import type { Game } from "@types"; import { HydraApi } from "../hydra-api"; export const updateGamePlaytime = async ( diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index 79559a35..90c97e8b 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -1,15 +1,19 @@ -import { gameRepository } from "@main/repository"; import { chunk } from "lodash-es"; -import { IsNull } from "typeorm"; import { HydraApi } from "../hydra-api"; import { mergeWithRemoteGames } from "./merge-with-remote-games"; import { WindowManager } from "../window-manager"; import { AchievementWatcherManager } from "../achievements/achievement-watcher-manager"; +import { gamesSublevel } from "@main/level"; export const uploadGamesBatch = async () => { - const games = await gameRepository.find({ - where: { remoteId: IsNull(), isDeleted: false }, - }); + const games = await gamesSublevel + .values() + .all() + .then((results) => { + return results.filter( + (game) => !game.isDeleted && game.remoteId === null + ); + }); const gamesChunks = chunk(games, 200); @@ -18,7 +22,7 @@ export const uploadGamesBatch = async () => { "/profile/games/batch", chunk.map((game) => { return { - objectId: game.objectID, + objectId: game.objectId, playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), shop: game.shop, lastTimePlayed: game.lastTimePlayed, diff --git a/src/main/services/logger.ts b/src/main/services/logger.ts index 95a399ea..03bf6ad7 100644 --- a/src/main/services/logger.ts +++ b/src/main/services/logger.ts @@ -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"); diff --git a/src/main/services/main-loop.ts b/src/main/services/main-loop.ts index a1c2b449..12b6e3a7 100644 --- a/src/main/services/main-loop.ts +++ b/src/main/services/main-loop.ts @@ -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); diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index f3e2541b..63c666dc 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -1,8 +1,6 @@ import { Notification, app } from "electron"; import { t } from "i18next"; import trayIcon from "@resources/tray-icon.png?asset"; -import { Game } from "@main/entity"; -import { userPreferencesRepository } from "@main/repository"; import fs from "node:fs"; import axios from "axios"; import path from "node:path"; @@ -11,6 +9,9 @@ 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"; async function downloadImage(url: string | null) { if (!url) return undefined; @@ -38,9 +39,12 @@ async function downloadImage(url: string | null) { } export const publishDownloadCompleteNotification = async (game: Game) => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); if (userPreferences?.downloadNotificationsEnabled) { new Notification({ @@ -93,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); } }; @@ -140,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); } }; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index c6cb7e10..0b04defe 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -1,12 +1,11 @@ -import { gameRepository } from "@main/repository"; import { WindowManager } from "./window-manager"; import { createGame, updateGamePlaytime } from "./library-sync"; -import type { GameRunning } from "@types"; +import type { Game, GameRunning } from "@types"; import { PythonRPC } from "./python-rpc"; -import { Game } from "@main/entity"; import axios from "axios"; import { exec } from "child_process"; import { ProcessPayload } from "./download/types"; +import { gamesSublevel, levelKeys } from "@main/level"; const commands = { findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`, @@ -14,7 +13,7 @@ const commands = { }; export const gamesPlaytime = new Map< - number, + string, { lastTick: number; firstTick: number; lastSyncTick: number } >(); @@ -82,23 +81,28 @@ const findGamePathByProcess = ( const pathSet = processMap.get(executable.exe); if (pathSet) { - pathSet.forEach((path) => { + pathSet.forEach(async (path) => { if (path.toLowerCase().endsWith(executable.name)) { - gameRepository.update( - { objectID: gameId, shop: "steam" }, - { executablePath: path } - ); + const gameKey = levelKeys.game("steam", gameId); + const game = await gamesSublevel.get(gameKey); + + if (game) { + gamesSublevel.put(gameKey, { + ...game, + executablePath: path, + }); + } if (isLinuxPlatform) { exec(commands.findWineDir, (err, out) => { if (err) return; - gameRepository.update( - { objectID: gameId, shop: "steam" }, - { + if (game) { + gamesSublevel.put(gameKey, { + ...game, winePrefixPath: out.trim().replace("/drive_c/windows", ""), - } - ); + }); + } }); } } @@ -159,11 +163,12 @@ const getSystemProcessMap = async () => { }; export const watchProcesses = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((results) => { + return results.filter((game) => game.isDeleted === false); + }); if (!games.length) return; @@ -172,8 +177,8 @@ export const watchProcesses = async () => { for (const game of games) { const executablePath = game.executablePath; if (!executablePath) { - if (gameExecutables[game.objectID]) { - findGamePathByProcess(processMap, game.objectID); + if (gameExecutables[game.objectId]) { + findGamePathByProcess(processMap, game.objectId); } continue; } @@ -185,12 +190,12 @@ export const watchProcesses = async () => { const hasProcess = processMap.get(executable)?.has(executablePath); if (hasProcess) { - if (gamesPlaytime.has(game.id)) { + if (gamesPlaytime.has(levelKeys.game(game.shop, game.objectId))) { onTickGame(game); } else { onOpenGame(game); } - } else if (gamesPlaytime.has(game.id)) { + } else if (gamesPlaytime.has(levelKeys.game(game.shop, game.objectId))) { onCloseGame(game); } } @@ -202,20 +207,17 @@ export const watchProcesses = async () => { return { id: entry[0], sessionDurationInMillis: performance.now() - entry[1].firstTick, - }; + } as Pick; }); - WindowManager.mainWindow.webContents.send( - "on-games-running", - gamesRunning as Pick[] - ); + WindowManager.mainWindow.webContents.send("on-games-running", gamesRunning); } }; function onOpenGame(game: Game) { const now = performance.now(); - gamesPlaytime.set(game.id, { + gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { lastTick: now, firstTick: now, lastSyncTick: now, @@ -230,16 +232,25 @@ function onOpenGame(game: Game) { function onTickGame(game: Game) { const now = performance.now(); - const gamePlaytime = gamesPlaytime.get(game.id)!; + const gamePlaytime = gamesPlaytime.get( + levelKeys.game(game.shop, game.objectId) + )!; const delta = now - gamePlaytime.lastTick; - gameRepository.update(game.id, { + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + ...game, playTimeInMilliseconds: game.playTimeInMilliseconds + delta, lastTimePlayed: new Date(), }); - gamesPlaytime.set(game.id, { + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + ...game, + playTimeInMilliseconds: game.playTimeInMilliseconds + delta, + lastTimePlayed: new Date(), + }); + + gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { ...gamePlaytime, lastTick: now, }); @@ -255,7 +266,7 @@ function onTickGame(game: Game) { gamePromise .then(() => { - gamesPlaytime.set(game.id, { + gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { ...gamePlaytime, lastSyncTick: now, }); @@ -265,8 +276,10 @@ function onTickGame(game: Game) { } const onCloseGame = (game: Game) => { - const gamePlaytime = gamesPlaytime.get(game.id)!; - gamesPlaytime.delete(game.id); + const gamePlaytime = gamesPlaytime.get( + levelKeys.game(game.shop, game.objectId) + )!; + gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId)); if (game.remoteId) { updateGamePlaytime( diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index 1384a1be..22e60461 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -10,7 +10,7 @@ import { Readable } from "node:stream"; import { app, dialog } from "electron"; interface GamePayload { - game_id: number; + game_id: string; url: string; save_path: string; } diff --git a/src/main/services/update-manager.ts b/src/main/services/update-manager.ts new file mode 100644 index 00000000..9a277dd7 --- /dev/null +++ b/src/main/services/update-manager.ts @@ -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++; + } +} diff --git a/src/main/services/user/get-user-data.ts b/src/main/services/user/get-user-data.ts index 7e924454..d26c995d 100644 --- a/src/main/services/user/get-user-data.ts +++ b/src/main/services/user/get-user-data.ts @@ -1,60 +1,45 @@ -import type { ProfileVisibility, UserDetails } from "@types"; +import { User, type ProfileVisibility, type UserDetails } from "@types"; import { HydraApi } from "../hydra-api"; -import { - userAuthRepository, - userSubscriptionRepository, -} from "@main/repository"; import { UserNotLoggedInError } from "@shared"; import { logger } from "../logger"; +import { db } from "@main/level"; +import { levelKeys } from "@main/level/sublevels"; -export const getUserData = () => { +export const getUserData = async () => { return HydraApi.get(`/profile/me`) .then(async (me) => { - userAuthRepository.upsert( - { - id: 1, - displayName: me.displayName, - profileImageUrl: me.profileImageUrl, - backgroundImageUrl: me.backgroundImageUrl, - userId: me.id, - }, - ["id"] + db.get(levelKeys.user, { valueEncoding: "json" }).then( + (user) => { + return db.put( + levelKeys.user, + { + ...user, + id: me.id, + displayName: me.displayName, + profileImageUrl: me.profileImageUrl, + backgroundImageUrl: me.backgroundImageUrl, + subscription: me.subscription, + }, + { valueEncoding: "json" } + ); + } ); - if (me.subscription) { - await userSubscriptionRepository.upsert( - { - id: 1, - subscriptionId: me.subscription?.id || "", - status: me.subscription?.status || "", - planId: me.subscription?.plan.id || "", - planName: me.subscription?.plan.name || "", - expiresAt: me.subscription?.expiresAt || null, - user: { id: 1 }, - }, - ["id"] - ); - } else { - await userSubscriptionRepository.delete({ id: 1 }); - } - return me; }) .catch(async (err) => { if (err instanceof UserNotLoggedInError) { - logger.info("User is not logged in", err); return null; } logger.error("Failed to get logged user"); - const loggedUser = await userAuthRepository.findOne({ - where: { id: 1 }, - relations: { subscription: true }, + + const loggedUser = await db.get(levelKeys.user, { + valueEncoding: "json", }); if (loggedUser) { return { ...loggedUser, - id: loggedUser.userId, username: "", bio: "", email: null, @@ -64,15 +49,16 @@ export const getUserData = () => { }, subscription: loggedUser.subscription ? { - id: loggedUser.subscription.subscriptionId, + id: loggedUser.subscription.id, status: loggedUser.subscription.status, plan: { - id: loggedUser.subscription.planId, - name: loggedUser.subscription.planName, + id: loggedUser.subscription.plan.id, + name: loggedUser.subscription.plan.name, }, expiresAt: loggedUser.subscription.expiresAt, } : null, + featurebaseJwt: "", } as UserDetails; } diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index f7e82f07..2d0bf24d 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -13,11 +13,13 @@ import { t } from "i18next"; import path from "node:path"; import icon from "@resources/icon.png?asset"; import trayIcon from "@resources/tray-icon.png?asset"; -import { gameRepository, userPreferencesRepository } from "@main/repository"; -import { IsNull, Not } from "typeorm"; import { HydraApi } from "./hydra-api"; import UserAgent from "user-agents"; +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; @@ -49,7 +51,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", @@ -126,14 +128,18 @@ 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(); }); this.mainWindow.on("close", async () => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); if (userPreferences?.preferQuitInsteadOfHiding) { app.quit(); @@ -141,6 +147,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) { @@ -211,17 +222,19 @@ export class WindowManager { } const updateSystemTray = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - executablePath: Not(IsNull()), - lastTimePlayed: Not(IsNull()), - }, - take: 5, - order: { - lastTimePlayed: "DESC", - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((games) => { + const filteredGames = games.filter( + (game) => + !game.isDeleted && game.executablePath && game.lastTimePlayed + ); + + const sortedGames = sortBy(filteredGames, "lastTimePlayed", "DESC"); + + return slice(sortedGames, 5); + }); const recentlyPlayedGames: Array = games.map(({ title, executablePath }) => ({ diff --git a/src/preload/index.ts b/src/preload/index.ts index 5f9bc02c..439327cd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,16 +22,16 @@ contextBridge.exposeInMainWorld("electron", { /* Torrenting */ startGameDownload: (payload: StartGameDownloadPayload) => ipcRenderer.invoke("startGameDownload", payload), - cancelGameDownload: (gameId: number) => - ipcRenderer.invoke("cancelGameDownload", gameId), - pauseGameDownload: (gameId: number) => - ipcRenderer.invoke("pauseGameDownload", gameId), - resumeGameDownload: (gameId: number) => - ipcRenderer.invoke("resumeGameDownload", gameId), - pauseGameSeed: (gameId: number) => - ipcRenderer.invoke("pauseGameSeed", gameId), - resumeGameSeed: (gameId: number) => - ipcRenderer.invoke("resumeGameSeed", gameId), + cancelGameDownload: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("cancelGameDownload", shop, objectId), + pauseGameDownload: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("pauseGameDownload", shop, objectId), + resumeGameDownload: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("resumeGameDownload", shop, objectId), + pauseGameSeed: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("pauseGameSeed", shop, objectId), + resumeGameSeed: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("resumeGameSeed", shop, objectId), onDownloadProgress: (cb: (value: DownloadProgress) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -92,46 +92,69 @@ 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[]) => ipcRenderer.invoke("putDownloadSource", objectIds), /* Library */ - addGameToLibrary: (objectId: string, title: string, shop: GameShop) => - ipcRenderer.invoke("addGameToLibrary", objectId, title, shop), - createGameShortcut: (id: number) => - ipcRenderer.invoke("createGameShortcut", id), - updateExecutablePath: (id: number, executablePath: string | null) => - ipcRenderer.invoke("updateExecutablePath", id, executablePath), - updateLaunchOptions: (id: number, launchOptions: string | null) => - ipcRenderer.invoke("updateLaunchOptions", id, launchOptions), - selectGameWinePrefix: (id: number, winePrefixPath: string | null) => - ipcRenderer.invoke("selectGameWinePrefix", id, winePrefixPath), + addGameToLibrary: (shop: GameShop, objectId: string, title: string) => + ipcRenderer.invoke("addGameToLibrary", shop, objectId, title), + createGameShortcut: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("createGameShortcut", shop, objectId), + updateExecutablePath: ( + shop: GameShop, + objectId: string, + executablePath: string | null + ) => + ipcRenderer.invoke("updateExecutablePath", shop, objectId, executablePath), + updateLaunchOptions: ( + shop: GameShop, + objectId: string, + launchOptions: string | null + ) => ipcRenderer.invoke("updateLaunchOptions", shop, objectId, launchOptions), + selectGameWinePrefix: ( + shop: GameShop, + objectId: string, + winePrefixPath: string | null + ) => + ipcRenderer.invoke("selectGameWinePrefix", shop, objectId, winePrefixPath), verifyExecutablePathInUse: (executablePath: string) => ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), getLibrary: () => ipcRenderer.invoke("getLibrary"), - openGameInstaller: (gameId: number) => - ipcRenderer.invoke("openGameInstaller", gameId), - openGameInstallerPath: (gameId: number) => - ipcRenderer.invoke("openGameInstallerPath", gameId), - openGameExecutablePath: (gameId: number) => - ipcRenderer.invoke("openGameExecutablePath", gameId), + openGameInstaller: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("openGameInstaller", shop, objectId), + openGameInstallerPath: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("openGameInstallerPath", shop, objectId), + openGameExecutablePath: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("openGameExecutablePath", shop, objectId), openGame: ( - gameId: number, + shop: GameShop, + objectId: string, executablePath: string, - launchOptions: string | null - ) => ipcRenderer.invoke("openGame", gameId, executablePath, launchOptions), - closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId), - removeGameFromLibrary: (gameId: number) => - ipcRenderer.invoke("removeGameFromLibrary", gameId), - removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId), - deleteGameFolder: (gameId: number) => - ipcRenderer.invoke("deleteGameFolder", gameId), - getGameByObjectId: (objectId: string) => - ipcRenderer.invoke("getGameByObjectId", objectId), - resetGameAchievements: (gameId: number) => - ipcRenderer.invoke("resetGameAchievements", gameId), + launchOptions?: string | null + ) => + ipcRenderer.invoke( + "openGame", + shop, + objectId, + executablePath, + launchOptions + ), + closeGame: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("closeGame", shop, objectId), + removeGameFromLibrary: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("removeGameFromLibrary", shop, objectId), + removeGame: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("removeGame", shop, objectId), + deleteGameFolder: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("deleteGameFolder", shop, objectId), + getGameByObjectId: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("getGameByObjectId", shop, objectId), + resetGameAchievements: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("resetGameAchievements", shop, objectId), onGamesRunning: ( cb: ( gamesRunning: Pick[] @@ -148,6 +171,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) => diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 31309256..2ec917fb 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -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 { @@ -84,7 +84,7 @@ export function App() { useEffect(() => { const unsubscribe = window.electron.onDownloadProgress( (downloadProgress) => { - if (downloadProgress.game.progress === 1) { + if (downloadProgress.progress === 1) { clearDownload(); updateLibrary(); return; @@ -212,27 +212,43 @@ export function App() { const id = crypto.randomUUID(); const channel = new BroadcastChannel(`download_sources:sync:${id}`); - channel.onmessage = (event: MessageEvent) => { + channel.onmessage = async (event: MessageEvent) => { const newRepacksCount = event.data; window.electron.publishNewRepacksNotification(newRepacksCount); updateRepacks(); - downloadSourcesTable.toArray().then((downloadSources) => { - downloadSources - .filter((source) => !source.fingerprint) - .forEach((downloadSource) => { - window.electron - .putDownloadSource(downloadSource.objectIds) - .then(({ fingerprint }) => { - downloadSourcesTable.update(downloadSource.id, { fingerprint }); - }); - }); - }); + const downloadSources = await downloadSourcesTable.toArray(); + + downloadSources + .filter((source) => !source.fingerprint) + .forEach(async (downloadSource) => { + const { fingerprint } = await window.electron.putDownloadSource( + downloadSource.objectIds + ); + + downloadSourcesTable.update(downloadSource.id, { fingerprint }); + }); }; downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); }, [updateRepacks]); + const playAudio = useCallback(() => { + const audio = new Audio(achievementSound); + audio.volume = 0.2; + audio.play(); + }, []); + + useEffect(() => { + const unsubscribe = window.electron.onAchievementUnlocked(() => { + playAudio(); + }); + + return () => { + unsubscribe(); + }; + }, [playAudio]); + const handleToastClose = useCallback(() => { dispatch(closeToast()); }, [dispatch]); @@ -252,9 +268,11 @@ export function App() { (""); @@ -32,27 +34,29 @@ export function BottomPanel() { const status = useMemo(() => { if (isGameDownloading) { + const game = library.find((game) => game.id === lastPacket?.gameId)!; + if (lastPacket?.isCheckingFiles) return t("checking_files", { - title: lastPacket?.game.title, + title: game.title, percentage: progress, }); if (lastPacket?.isDownloadingMetadata) return t("downloading_metadata", { - title: lastPacket?.game.title, + title: game.title, percentage: progress, }); if (!eta) { return t("calculating_eta", { - title: lastPacket?.game.title, + title: game.title, percentage: progress, }); } return t("downloading", { - title: lastPacket?.game.title, + title: game.title, percentage: progress, eta, speed: downloadSpeed, @@ -60,16 +64,7 @@ export function BottomPanel() { } return t("no_downloads_in_progress"); - }, [ - t, - isGameDownloading, - lastPacket?.game, - lastPacket?.isDownloadingMetadata, - lastPacket?.isCheckingFiles, - progress, - eta, - downloadSpeed, - ]); + }, [t, isGameDownloading, library, lastPacket, progress, eta, downloadSpeed]); return (
@@ -81,10 +76,15 @@ export function BottomPanel() { {status} - - {sessionHash ? `${sessionHash} -` : ""} v{version} " - {VERSION_CODENAME}" - +
); } diff --git a/src/renderer/src/components/checkbox-field/checkbox-field.scss b/src/renderer/src/components/checkbox-field/checkbox-field.scss index fb8b4131..85ab4149 100644 --- a/src/renderer/src/components/checkbox-field/checkbox-field.scss +++ b/src/renderer/src/components/checkbox-field/checkbox-field.scss @@ -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; diff --git a/src/renderer/src/components/dropdown-menu/dropdown-menu.tsx b/src/renderer/src/components/dropdown-menu/dropdown-menu.tsx index 14531868..9e3a1dec 100644 --- a/src/renderer/src/components/dropdown-menu/dropdown-menu.tsx +++ b/src/renderer/src/components/dropdown-menu/dropdown-menu.tsx @@ -29,7 +29,7 @@ export function DropdownMenu({ loop = true, align = "center", alignOffset = 0, -}: DropdownMenuProps) { +}: Readonly) { return ( diff --git a/src/renderer/src/components/modal/modal.tsx b/src/renderer/src/components/modal/modal.tsx index 4d5a0a54..09a61438 100644 --- a/src/renderer/src/components/modal/modal.tsx +++ b/src/renderer/src/components/modal/modal.tsx @@ -53,6 +53,7 @@ export function Modal({ ) ) return false; + const openModals = document.querySelectorAll("[role=dialog]"); return ( diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 42ae1f33..5801de37 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -58,7 +58,7 @@ export function Sidebar() { useEffect(() => { updateLibrary(); - }, [lastPacket?.game.id, updateLibrary]); + }, [lastPacket?.gameId, updateLibrary]); const sidebarRef = useRef(null); @@ -120,18 +120,17 @@ export function Sidebar() { }, [isResizing]); const getGameTitle = (game: LibraryGame) => { - if (lastPacket?.game.id === game.id) { + if (lastPacket?.gameId === game.id) { return t("downloading", { title: game.title, percentage: progress, }); } - if (game.downloadQueue !== null) { - return t("queued", { title: game.title }); - } + if (game.download?.queued) return t("queued", { title: game.title }); - if (game.status === "paused") return t("paused", { title: game.title }); + if (game.download?.status === "paused") + return t("paused", { title: game.title }); return game.title; }; @@ -148,7 +147,7 @@ export function Sidebar() { ) => { const path = buildGameDetailsPath({ ...game, - objectId: game.objectID, + objectId: game.objectId, }); if (path !== location.pathname) { navigate(path); @@ -157,7 +156,8 @@ export function Sidebar() { if (event.detail === 2) { if (game.executablePath) { window.electron.openGame( - game.id, + game.shop, + game.objectId, game.executablePath, game.launchOptions ); @@ -223,8 +223,9 @@ export function Sidebar() { className={cn("sidebar__menu-item", { "sidebar__menu-item--active": location.pathname === - `/game/${game.shop}/${game.objectID}`, - "sidebar__menu-item--muted": game.status === "removed", + `/game/${game.shop}/${game.objectId}`, + "sidebar__menu-item--muted": + game.download?.status === "removed", })} > + + + {message &&

{message}

} - - diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index d0797caf..1d7aa1b1 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -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; diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 9242d9a6..da7fd1e8 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -18,9 +18,9 @@ import { } from "@renderer/hooks"; import type { - Game, GameShop, GameStats, + LibraryGame, ShopDetails, UserAchievement, } from "@types"; @@ -68,12 +68,12 @@ export function GameDetailsContextProvider({ objectId, gameTitle, shop, -}: GameDetailsContextProps) { +}: Readonly) { const [shopDetails, setShopDetails] = useState(null); const [achievements, setAchievements] = useState( null ); - const [game, setGame] = useState(null); + const [game, setGame] = useState(null); const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false); const abortControllerRef = useRef(null); @@ -81,7 +81,7 @@ export function GameDetailsContextProvider({ const [isLoading, setIsLoading] = useState(false); const [gameColor, setGameColor] = useState(""); - const [isGameRunning, setisGameRunning] = useState(false); + const [isGameRunning, setIsGameRunning] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false); const [showGameOptionsModal, setShowGameOptionsModal] = useState(false); @@ -101,15 +101,16 @@ export function GameDetailsContextProvider({ const updateGame = useCallback(async () => { return window.electron - .getGameByObjectId(objectId!) + .getGameByObjectId(shop, objectId) .then((result) => setGame(result)); - }, [setGame, objectId]); + }, [setGame, shop, objectId]); - const isGameDownloading = lastPacket?.game.id === game?.id; + const isGameDownloading = + lastPacket?.gameId === game?.id && game?.download?.status === "active"; useEffect(() => { updateGame(); - }, [updateGame, isGameDownloading, lastPacket?.game.status]); + }, [updateGame, isGameDownloading, lastPacket?.gameId]); useEffect(() => { if (abortControllerRef.current) abortControllerRef.current.abort(); @@ -167,7 +168,7 @@ export function GameDetailsContextProvider({ setShopDetails(null); setGame(null); setIsLoading(true); - setisGameRunning(false); + setIsGameRunning(false); setAchievements(null); dispatch(setHeaderTitle(gameTitle)); }, [objectId, gameTitle, dispatch]); @@ -182,17 +183,18 @@ export function GameDetailsContextProvider({ updateGame(); } - setisGameRunning(updatedIsGameRunning); + setIsGameRunning(updatedIsGameRunning); }); + return () => { unsubscribe(); }; }, [game?.id, isGameRunning, updateGame]); const lastDownloadedOption = useMemo(() => { - if (game?.uri) { + if (game?.download) { const repack = repacks.find((repack) => - repack.uris.some((uri) => uri.includes(game.uri!)) + repack.uris.some((uri) => uri.includes(game.download!.uri)) ); if (!repack) return null; @@ -200,7 +202,7 @@ export function GameDetailsContextProvider({ } return null; - }, [game?.uri, repacks]); + }, [game?.download, repacks]); useEffect(() => { const unsubscribe = window.electron.onUpdateAchievements( @@ -250,7 +252,7 @@ export function GameDetailsContextProvider({ value={{ game, shopDetails, - shop: shop as GameShop, + shop, repacks, gameTitle, isGameRunning, diff --git a/src/renderer/src/context/game-details/game-details.context.types.ts b/src/renderer/src/context/game-details/game-details.context.types.ts index 49718430..5cecd7b3 100644 --- a/src/renderer/src/context/game-details/game-details.context.types.ts +++ b/src/renderer/src/context/game-details/game-details.context.types.ts @@ -1,14 +1,14 @@ import type { - Game, GameRepack, GameShop, GameStats, + LibraryGame, ShopDetails, UserAchievement, } from "@types"; export interface GameDetailsContext { - game: Game | null; + game: LibraryGame | null; shopDetails: ShopDetails | null; repacks: GameRepack[]; shop: GameShop; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 31c99df5..c1923562 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -1,8 +1,6 @@ import type { AuthPage, CatalogueCategory } from "@shared"; import type { AppUpdaterEvent, - Game, - LibraryGame, GameShop, HowLongToBeatCategory, ShopDetails, @@ -23,12 +21,14 @@ import type { UserStats, UserDetails, FriendRequestSync, - GameAchievement, GameArtifact, LudusaviBackup, UserAchievement, ComparedAchievements, CatalogueSearchPayload, + LibraryGame, + GameRunning, + TorBoxUser, } from "@types"; import type { AxiosProgressEvent } from "axios"; import type disk from "diskusage"; @@ -41,12 +41,14 @@ declare global { interface Electron { /* Torrenting */ - startGameDownload: (payload: StartGameDownloadPayload) => Promise; - cancelGameDownload: (gameId: number) => Promise; - pauseGameDownload: (gameId: number) => Promise; - resumeGameDownload: (gameId: number) => Promise; - pauseGameSeed: (gameId: number) => Promise; - resumeGameSeed: (gameId: number) => Promise; + startGameDownload: ( + payload: StartGameDownloadPayload + ) => Promise<{ ok: boolean; error?: string }>; + cancelGameDownload: (shop: GameShop, objectId: string) => Promise; + pauseGameDownload: (shop: GameShop, objectId: string) => Promise; + resumeGameDownload: (shop: GameShop, objectId: string) => Promise; + pauseGameSeed: (shop: GameShop, objectId: string) => Promise; + resumeGameSeed: (shop: GameShop, objectId: string) => Promise; onDownloadProgress: ( cb: (value: DownloadProgress) => void ) => () => Electron.IpcRenderer; @@ -77,52 +79,62 @@ declare global { onUpdateAchievements: ( objectId: string, shop: GameShop, - cb: (achievements: GameAchievement[]) => void + cb: (achievements: UserAchievement[]) => void ) => () => Electron.IpcRenderer; getPublishers: () => Promise; getDevelopers: () => Promise; /* Library */ addGameToLibrary: ( + shop: GameShop, objectId: string, - title: string, - shop: GameShop + title: string ) => Promise; - createGameShortcut: (id: number) => Promise; + createGameShortcut: (shop: GameShop, objectId: string) => Promise; updateExecutablePath: ( - id: number, + shop: GameShop, + objectId: string, executablePath: string | null ) => Promise; updateLaunchOptions: ( - id: number, + shop: GameShop, + objectId: string, launchOptions: string | null ) => Promise; selectGameWinePrefix: ( - id: number, + shop: GameShop, + objectId: string, winePrefixPath: string | null ) => Promise; verifyExecutablePathInUse: (executablePath: string) => Promise; getLibrary: () => Promise; - openGameInstaller: (gameId: number) => Promise; - openGameInstallerPath: (gameId: number) => Promise; - openGameExecutablePath: (gameId: number) => Promise; + openGameInstaller: (shop: GameShop, objectId: string) => Promise; + openGameInstallerPath: ( + shop: GameShop, + objectId: string + ) => Promise; + openGameExecutablePath: (shop: GameShop, objectId: string) => Promise; openGame: ( - gameId: number, + shop: GameShop, + objectId: string, executablePath: string, - launchOptions: string | null + launchOptions?: string | null ) => Promise; - closeGame: (gameId: number) => Promise; - removeGameFromLibrary: (gameId: number) => Promise; - removeGame: (gameId: number) => Promise; - deleteGameFolder: (gameId: number) => Promise; - getGameByObjectId: (objectId: string) => Promise; + closeGame: (shop: GameShop, objectId: string) => Promise; + removeGameFromLibrary: (shop: GameShop, objectId: string) => Promise; + removeGame: (shop: GameShop, objectId: string) => Promise; + deleteGameFolder: (shop: GameShop, objectId: string) => Promise; + getGameByObjectId: ( + shop: GameShop, + objectId: string + ) => Promise; onGamesRunning: ( cb: ( gamesRunning: Pick[] ) => void ) => () => Electron.IpcRenderer; onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer; - resetGameAchievements: (gameId: number) => Promise; + resetGameAchievements: (shop: GameShop, objectId: string) => Promise; /* User preferences */ getUserPreferences: () => Promise; updateUserPreferences: ( @@ -133,6 +145,8 @@ declare global { minimized: boolean; }) => Promise; authenticateRealDebrid: (apiToken: string) => Promise; + authenticateTorBox: (apiToken: string) => Promise; + onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer; /* Download sources */ putDownloadSource: ( diff --git a/src/renderer/src/features/download-slice.ts b/src/renderer/src/features/download-slice.ts index 0a419370..cc7c40ea 100644 --- a/src/renderer/src/features/download-slice.ts +++ b/src/renderer/src/features/download-slice.ts @@ -4,8 +4,8 @@ import type { DownloadProgress } from "@types"; export interface DownloadState { lastPacket: DownloadProgress | null; - gameId: number | null; - gamesWithDeletionInProgress: number[]; + gameId: string | null; + gamesWithDeletionInProgress: string[]; } const initialState: DownloadState = { @@ -20,13 +20,13 @@ export const downloadSlice = createSlice({ reducers: { setLastPacket: (state, action: PayloadAction) => { state.lastPacket = action.payload; - if (!state.gameId) state.gameId = action.payload.game.id; + if (!state.gameId) state.gameId = action.payload.gameId; }, clearDownload: (state) => { state.lastPacket = null; state.gameId = null; }, - setGameDeleting: (state, action: PayloadAction) => { + setGameDeleting: (state, action: PayloadAction) => { if ( !state.gamesWithDeletionInProgress.includes(action.payload) && action.payload @@ -34,7 +34,7 @@ export const downloadSlice = createSlice({ state.gamesWithDeletionInProgress.push(action.payload); } }, - removeGameFromDeleting: (state, action: PayloadAction) => { + removeGameFromDeleting: (state, action: PayloadAction) => { const index = state.gamesWithDeletionInProgress.indexOf(action.payload); if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1); }, diff --git a/src/renderer/src/features/running-game-slice.ts b/src/renderer/src/features/game-running.slice.ts similarity index 95% rename from src/renderer/src/features/running-game-slice.ts rename to src/renderer/src/features/game-running.slice.ts index e1dd609f..18718785 100644 --- a/src/renderer/src/features/running-game-slice.ts +++ b/src/renderer/src/features/game-running.slice.ts @@ -10,7 +10,7 @@ const initialState: GameRunningState = { }; export const gameRunningSlice = createSlice({ - name: "running-game", + name: "game-running", initialState, reducers: { setGameRunning: (state, action: PayloadAction) => { diff --git a/src/renderer/src/features/index.ts b/src/renderer/src/features/index.ts index c0b8753d..9d48c0df 100644 --- a/src/renderer/src/features/index.ts +++ b/src/renderer/src/features/index.ts @@ -4,7 +4,7 @@ export * from "./download-slice"; export * from "./window-slice"; export * from "./toast-slice"; export * from "./user-details-slice"; -export * from "./running-game-slice"; +export * from "./game-running.slice"; export * from "./subscription-slice"; export * from "./repacks-slice"; export * from "./catalogue-search"; diff --git a/src/renderer/src/features/toast-slice.ts b/src/renderer/src/features/toast-slice.ts index e27480fa..f5df1d1c 100644 --- a/src/renderer/src/features/toast-slice.ts +++ b/src/renderer/src/features/toast-slice.ts @@ -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>) => { + state.title = action.payload.title; state.message = action.payload.message; state.type = action.payload.type; + state.duration = action.payload.duration ?? 5000; state.visible = true; }, closeToast: (state) => { diff --git a/src/renderer/src/hooks/use-download.ts b/src/renderer/src/hooks/use-download.ts index 4ea79b93..b84ac515 100644 --- a/src/renderer/src/hooks/use-download.ts +++ b/src/renderer/src/hooks/use-download.ts @@ -9,7 +9,11 @@ import { setGameDeleting, removeGameFromDeleting, } from "@renderer/features"; -import type { DownloadProgress, StartGameDownloadPayload } from "@types"; +import type { + DownloadProgress, + GameShop, + StartGameDownloadPayload, +} from "@types"; import { useDate } from "./use-date"; import { formatBytes } from "@shared"; @@ -25,54 +29,55 @@ 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 (gameId: number) => { - await window.electron.pauseGameDownload(gameId); + const pauseDownload = async (shop: GameShop, objectId: string) => { + await window.electron.pauseGameDownload(shop, objectId); await updateLibrary(); dispatch(clearDownload()); }; - const resumeDownload = async (gameId: number) => { - await window.electron.resumeGameDownload(gameId); + const resumeDownload = async (shop: GameShop, objectId: string) => { + await window.electron.resumeGameDownload(shop, objectId); return updateLibrary(); }; - const removeGameInstaller = async (gameId: number) => { - dispatch(setGameDeleting(gameId)); + const removeGameInstaller = async (shop: GameShop, objectId: string) => { + dispatch(setGameDeleting(objectId)); try { - await window.electron.deleteGameFolder(gameId); + await window.electron.deleteGameFolder(shop, objectId); updateLibrary(); } finally { - dispatch(removeGameFromDeleting(gameId)); + dispatch(removeGameFromDeleting(objectId)); } }; - const cancelDownload = async (gameId: number) => { - await window.electron.cancelGameDownload(gameId); + const cancelDownload = async (shop: GameShop, objectId: string) => { + await window.electron.cancelGameDownload(shop, objectId); dispatch(clearDownload()); updateLibrary(); - removeGameInstaller(gameId); + removeGameInstaller(shop, objectId); }; - const removeGameFromLibrary = (gameId: number) => - window.electron.removeGameFromLibrary(gameId).then(() => { + const removeGameFromLibrary = (shop: GameShop, objectId: string) => + window.electron.removeGameFromLibrary(shop, objectId).then(() => { updateLibrary(); }); - const pauseSeeding = async (gameId: number) => { - await window.electron.pauseGameSeed(gameId); + const pauseSeeding = async (shop: GameShop, objectId: string) => { + await window.electron.pauseGameSeed(shop, objectId); await updateLibrary(); }; - const resumeSeeding = async (gameId: number) => { - await window.electron.resumeGameSeed(gameId); + const resumeSeeding = async (shop: GameShop, objectId: string) => { + await window.electron.resumeGameSeed(shop, objectId); await updateLibrary(); }; @@ -90,8 +95,8 @@ export function useDownload() { } }; - const isGameDeleting = (gameId: number) => { - return gamesWithDeletionInProgress.includes(gameId); + const isGameDeleting = (objectId: string) => { + return gamesWithDeletionInProgress.includes(objectId); }; return { diff --git a/src/renderer/src/hooks/use-toast.ts b/src/renderer/src/hooks/use-toast.ts index 485470f0..8b4c3e0f 100644 --- a/src/renderer/src/hooks/use-toast.ts +++ b/src/renderer/src/hooks/use-toast.ts @@ -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, }) ); }, diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 0679cde8..2fdac382 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -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 () => { diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 61c561f1..cb6ba45f 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -45,6 +45,7 @@ Sentry.init({ tracesSampleRate: 1.0, replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, + release: await window.electron.getVersion(), }); console.log = logger.log; diff --git a/src/renderer/src/pages/achievements/achievement-list.tsx b/src/renderer/src/pages/achievements/achievement-list.tsx index 36ef97d4..8862ce48 100644 --- a/src/renderer/src/pages/achievements/achievement-list.tsx +++ b/src/renderer/src/pages/achievements/achievement-list.tsx @@ -10,7 +10,9 @@ interface AchievementListProps { achievements: UserAchievement[]; } -export function AchievementList({ achievements }: AchievementListProps) { +export function AchievementList({ + achievements, +}: Readonly) { const { t } = useTranslation("achievement"); const { showHydraCloudModal } = useSubscription(); const { formatDateTime } = useDate(); diff --git a/src/renderer/src/pages/achievements/achievements.tsx b/src/renderer/src/pages/achievements/achievements.tsx index 18569177..d5a1e1ef 100644 --- a/src/renderer/src/pages/achievements/achievements.tsx +++ b/src/renderer/src/pages/achievements/achievements.tsx @@ -43,7 +43,7 @@ export default function Achievements() { .getComparedUnlockedAchievements(objectId, shop as GameShop, userId) .then(setComparedAchievements); } - }, [objectId, shop, userId]); + }, [objectId, shop, userDetails?.id, userId]); const otherUserId = userDetails?.id === userId ? null : userId; diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 192a4428..d5e568fb 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router-dom"; -import type { LibraryGame, SeedingStatus } from "@types"; +import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import { Badge, Button } from "@renderer/components"; import { @@ -30,11 +30,12 @@ import { XCircleIcon, } from "@primer/octicons-react"; +import torBoxLogo from "@renderer/assets/icons/torbox.webp"; export interface DownloadGroupProps { library: LibraryGame[]; title: string; - openDeleteGameModal: (gameId: number) => void; - openGameInstaller: (gameId: number) => void; + openDeleteGameModal: (shop: GameShop, objectId: string) => void; + openGameInstaller: (shop: GameShop, objectId: string) => void; seedingStatus: SeedingStatus[]; } @@ -44,7 +45,7 @@ export function DownloadGroup({ openDeleteGameModal, openGameInstaller, seedingStatus, -}: DownloadGroupProps) { +}: Readonly) { const navigate = useNavigate(); const { t } = useTranslation("downloads"); @@ -65,18 +66,19 @@ export function DownloadGroup({ } = useDownload(); const getFinalDownloadSize = (game: LibraryGame) => { - const isGameDownloading = lastPacket?.game.id === game.id; + const download = game.download!; + const isGameDownloading = lastPacket?.gameId === game.id; - if (game.fileSize) return formatBytes(game.fileSize); + if (download.fileSize) return formatBytes(download.fileSize); - if (lastPacket?.game.fileSize && isGameDownloading) - return formatBytes(lastPacket?.game.fileSize); + if (lastPacket?.download.fileSize && isGameDownloading) + return formatBytes(lastPacket.download.fileSize); return "N/A"; }; const seedingMap = useMemo(() => { - const map = new Map(); + const map = new Map(); seedingStatus.forEach((seed) => { map.set(seed.gameId, seed); @@ -86,7 +88,9 @@ export function DownloadGroup({ }, [seedingStatus]); const getGameInfo = (game: LibraryGame) => { - const isGameDownloading = lastPacket?.game.id === game.id; + const download = game.download!; + + const isGameDownloading = lastPacket?.gameId === game.id; const finalDownloadSize = getFinalDownloadSize(game); const seedingStatus = seedingMap.get(game.id); @@ -113,11 +117,11 @@ export function DownloadGroup({

{progress}

- {formatBytes(lastPacket?.game.bytesDownloaded)} /{" "} + {formatBytes(lastPacket.download.bytesDownloaded)} /{" "} {finalDownloadSize}

- {game.downloader === Downloader.Torrent && ( + {download.downloader === Downloader.Torrent && ( {lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds @@ -126,11 +130,11 @@ export function DownloadGroup({ ); } - if (game.progress === 1) { + if (download.progress === 1) { const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0); - return game.status === "seeding" && - game.downloader === Downloader.Torrent ? ( + return download.status === "seeding" && + download.downloader === Downloader.Torrent ? ( <>

{t("seeding")}

{uploadSpeed &&

{uploadSpeed}/s

} @@ -140,41 +144,44 @@ export function DownloadGroup({ ); } - if (game.status === "paused") { + if (download.status === "paused") { return ( <> -

{formatDownloadProgress(game.progress)}

-

{t(game.downloadQueue && lastPacket ? "queued" : "paused")}

+

{formatDownloadProgress(download.progress)}

+

{t(download.queued ? "queued" : "paused")}

); } - if (game.status === "active") { + if (download.status === "active") { return ( <> -

{formatDownloadProgress(game.progress)}

+

{formatDownloadProgress(download.progress)}

- {formatBytes(game.bytesDownloaded)} / {finalDownloadSize} + {formatBytes(download.bytesDownloaded)} / {finalDownloadSize}

); } - return

{t(game.status as string)}

; + return

{t(download.status as string)}

; }; const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { - const isGameDownloading = lastPacket?.game.id === game.id; + const download = lastPacket?.download; + const isGameDownloading = lastPacket?.gameId === game.id; const deleting = isGameDeleting(game.id); - if (game.progress === 1) { + if (download?.progress === 1) { return [ { label: t("install"), disabled: deleting, - onClick: () => openGameInstaller(game.id), + onClick: () => { + openGameInstaller(game.shop, game.objectId); + }, icon: , }, { @@ -182,53 +189,73 @@ export function DownloadGroup({ disabled: deleting, icon: , show: - game.status === "seeding" && game.downloader === Downloader.Torrent, - onClick: () => pauseSeeding(game.id), + download.status === "seeding" && + download.downloader === Downloader.Torrent, + onClick: () => { + pauseSeeding(game.shop, game.objectId); + }, }, { label: t("resume_seeding"), disabled: deleting, icon: , show: - game.status !== "seeding" && game.downloader === Downloader.Torrent, - onClick: () => resumeSeeding(game.id), + download.status !== "seeding" && + download.downloader === Downloader.Torrent, + onClick: () => { + resumeSeeding(game.shop, game.objectId); + }, }, { label: t("delete"), disabled: deleting, icon: , - onClick: () => openDeleteGameModal(game.id), + onClick: () => { + openDeleteGameModal(game.shop, game.objectId); + }, }, ]; } - if (isGameDownloading || game.status === "active") { + if (isGameDownloading || download?.status === "active") { return [ { label: t("pause"), - onClick: () => pauseDownload(game.id), + onClick: () => { + pauseDownload(game.shop, game.objectId); + }, icon: , }, { label: t("cancel"), - onClick: () => cancelDownload(game.id), + onClick: () => { + cancelDownload(game.shop, game.objectId); + }, icon: , }, ]; } + const isResumeDisabled = + (download?.downloader === Downloader.RealDebrid && + !userPreferences?.realDebridApiToken) || + (download?.downloader === Downloader.TorBox && + !userPreferences?.torBoxApiToken); + return [ { label: t("resume"), - disabled: - game.downloader === Downloader.RealDebrid && - !userPreferences?.realDebridApiToken, - onClick: () => resumeDownload(game.id), + disabled: isResumeDisabled, + onClick: () => { + resumeDownload(game.shop, game.objectId); + }, icon: , }, { label: t("cancel"), - onClick: () => cancelDownload(game.id), + onClick: () => { + cancelDownload(game.shop, game.objectId); + }, icon: , }, ]; @@ -251,13 +278,26 @@ export function DownloadGroup({
{game.title}
- {DOWNLOADER_NAME[game.downloader]} + {game.download?.downloader === Downloader.TorBox ? ( + + TorBox + TorBox + + ) : ( + + {DOWNLOADER_NAME[game.download!.downloader]} + + )}
@@ -271,7 +311,7 @@ export function DownloadGroup({ navigate( buildGameDetailsPath({ ...game, - objectId: game.objectID, + objectId: game.objectId, }) ) } diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index 5b5389f8..eb38c6f5 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -7,8 +7,8 @@ import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal"; import "./downloads.scss"; import { DeleteGameModal } from "./delete-game-modal"; import { DownloadGroup } from "./download-group"; -import type { LibraryGame, SeedingStatus } from "@types"; -import { orderBy } from "lodash-es"; +import type { GameShop, LibraryGame, SeedingStatus } from "@types"; +import { orderBy, sortBy } from "lodash-es"; import { ArrowDownIcon } from "@primer/octicons-react"; export default function Downloads() { @@ -16,7 +16,7 @@ export default function Downloads() { const { t } = useTranslation("downloads"); - const gameToBeDeleted = useRef(null); + const gameToBeDeleted = useRef<[GameShop, string] | null>(null); const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); @@ -25,8 +25,10 @@ export default function Downloads() { const handleDeleteGame = async () => { if (gameToBeDeleted.current) { - await pauseSeeding(gameToBeDeleted.current); - await removeGameInstaller(gameToBeDeleted.current); + const [shop, objectId] = gameToBeDeleted.current; + + await pauseSeeding(shop, objectId); + await removeGameInstaller(shop, objectId); } }; @@ -38,14 +40,14 @@ export default function Downloads() { window.electron.onSeedingStatus((value) => setSeedingStatus(value)); }, []); - const handleOpenGameInstaller = (gameId: number) => - window.electron.openGameInstaller(gameId).then((isBinaryInPath) => { + const handleOpenGameInstaller = (shop: GameShop, objectId: string) => + window.electron.openGameInstaller(shop, objectId).then((isBinaryInPath) => { if (!isBinaryInPath) setShowBinaryNotFoundModal(true); updateLibrary(); }); - const handleOpenDeleteGameModal = (gameId: number) => { - gameToBeDeleted.current = gameId; + const handleOpenDeleteGameModal = (shop: GameShop, objectId: string) => { + gameToBeDeleted.current = [shop, objectId]; setShowDeleteModal(true); }; @@ -56,29 +58,31 @@ export default function Downloads() { complete: [], }; - const result = library.reduce((prev, next) => { - /* Game has been manually added to the library or has been canceled */ - if (!next.status || next.status === "removed") return prev; + const result = sortBy(library, (game) => game.download?.timestamp).reduce( + (prev, next) => { + /* Game has been manually added to the library or has been canceled */ + if (!next.download?.status || next.download?.status === "removed") + return prev; - /* Is downloading */ - if (lastPacket?.game.id === next.id) - return { ...prev, downloading: [...prev.downloading, next] }; + /* Is downloading */ + if (lastPacket?.gameId === next.id) + return { ...prev, downloading: [...prev.downloading, next] }; - /* Is either queued or paused */ - if (next.downloadQueue || next.status === "paused") - return { ...prev, queued: [...prev.queued, next] }; + /* Is either queued or paused */ + if (next.download.queued || next.download?.status === "paused") + return { ...prev, queued: [...prev.queued, next] }; - return { ...prev, complete: [...prev.complete, next] }; - }, initialValue); - - const queued = orderBy( - result.queued, - (game) => game.downloadQueue?.id ?? -1, - ["desc"] + return { ...prev, complete: [...prev.complete, next] }; + }, + initialValue ); + const queued = orderBy(result.queued, (game) => game.download?.timestamp, [ + "desc", + ]); + const complete = orderBy(result.complete, (game) => - game.progress === 1 ? 0 : 1 + game.download?.progress === 1 ? 0 : 1 ); return { @@ -86,7 +90,7 @@ export default function Downloads() { queued, complete, }; - }, [library, lastPacket?.game.id]); + }, [library, lastPacket?.gameId]); const downloadGroups = [ { diff --git a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx index 071dc2a2..64d7a0cf 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx +++ b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx @@ -189,14 +189,6 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { - {uploadingBackup && ( - - )} -

{t("backups")}

diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index ce00216d..b2d1334a 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -139,6 +139,7 @@ export function GameDetailsContent() { className="game-details__hero-backdrop" style={{ backgroundColor: gameColor, + flex: 1, opacity: Math.min(1, 1 - backdropOpactiy), }} /> diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index fdccc728..81965ff0 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -100,19 +100,23 @@ export default function GameDetails() { downloader: Downloader, downloadPath: string ) => { - await startDownload({ + const response = await startDownload({ repackId: repack.id, objectId: objectId!, title: gameTitle, downloader, - shop: shop as GameShop, + shop, downloadPath, uri: selectRepackUri(repack, downloader), }); - await updateGame(); - setShowRepacksModal(false); - setShowGameOptionsModal(false); + if (response.ok) { + await updateGame(); + setShowRepacksModal(false); + setShowGameOptionsModal(false); + } + + return response; }; const handleNSFWContentRefuse = () => { @@ -121,10 +125,7 @@ export default function GameDetails() { }; return ( - + {({ showCloudSyncModal, diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index f890701d..68290313 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -21,6 +21,7 @@ export function HeroPanelActions() { game, repacks, isGameRunning, + shop, objectId, gameTitle, setShowGameOptionsModal, @@ -32,7 +33,7 @@ export function HeroPanelActions() { const { lastPacket } = useDownload(); const isGameDownloading = - game?.status === "active" && lastPacket?.game.id === game?.id; + game?.download?.status === "active" && lastPacket?.gameId === game?.id; const { updateLibrary } = useLibrary(); @@ -42,7 +43,7 @@ export function HeroPanelActions() { setToggleLibraryGameDisabled(true); try { - await window.electron.addGameToLibrary(objectId!, gameTitle, "steam"); + await window.electron.addGameToLibrary(shop, objectId!, gameTitle); updateLibrary(); updateGame(); @@ -55,7 +56,8 @@ export function HeroPanelActions() { if (game) { if (game.executablePath) { window.electron.openGame( - game.id, + game.shop, + game.objectId, game.executablePath, game.launchOptions ); @@ -65,7 +67,8 @@ export function HeroPanelActions() { const gameExecutablePath = await selectGameExecutable(); if (gameExecutablePath) window.electron.openGame( - game.id, + game.shop, + game.objectId, gameExecutablePath, game.launchOptions ); @@ -73,7 +76,7 @@ export function HeroPanelActions() { }; const closeGame = () => { - if (game) window.electron.closeGame(game.id); + if (game) window.electron.closeGame(game.shop, game.objectId); }; const deleting = game ? isGameDeleting(game?.id) : false; diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx index 567858c3..ba30879c 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx @@ -43,21 +43,24 @@ export function HeroPanelPlaytime() { if (!game) return null; const hasDownload = - ["active", "paused"].includes(game.status as string) && game.progress !== 1; + ["active", "paused"].includes(game.download?.status as string) && + game.download?.progress !== 1; const isGameDownloading = - game.status === "active" && lastPacket?.game.id === game.id; + game.download?.status === "active" && lastPacket?.gameId === game.id; const downloadInProgressInfo = (
- {game.status === "active" + {game.download?.status === "active" ? t("download_in_progress") : t("download_paused")} - {isGameDownloading ? progress : formatDownloadProgress(game.progress)} + {isGameDownloading + ? progress + : formatDownloadProgress(game.download?.progress)}
); diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.tsx b/src/renderer/src/pages/game-details/hero/hero-panel.tsx index 19e94d44..e9d110f0 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel.tsx @@ -23,7 +23,7 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) { const { lastPacket } = useDownload(); const isGameDownloading = - game?.status === "active" && lastPacket?.game.id === game?.id; + game?.download?.status === "active" && lastPacket?.gameId === game?.id; const getInfo = () => { if (!game) { @@ -50,8 +50,8 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) { }; const showProgressBar = - (game?.status === "active" && game?.progress < 1) || - game?.status === "paused"; + (game?.download?.status === "active" && game?.download?.progress < 1) || + game?.download?.status === "paused"; return (
Promise; + ) => Promise<{ ok: boolean; error?: string }>; repack: GameRepack | null; } @@ -24,7 +24,7 @@ export function DownloadSettingsModal({ onClose, startDownload, repack, -}: DownloadSettingsModalProps) { +}: Readonly) { const { t } = useTranslation("game_details"); const { showErrorToast } = useToast(); @@ -85,19 +85,17 @@ export function DownloadSettingsModal({ const filteredDownloaders = downloaders.filter((downloader) => { if (downloader === Downloader.RealDebrid) return userPreferences?.realDebridApiToken; + if (downloader === Downloader.TorBox) + return userPreferences?.torBoxApiToken; return true; }); - /* Gives preference to Real Debrid */ - const selectedDownloader = filteredDownloaders.includes( - Downloader.RealDebrid - ) - ? Downloader.RealDebrid + /* Gives preference to TorBox */ + const selectedDownloader = filteredDownloaders.includes(Downloader.TorBox) + ? Downloader.TorBox : filteredDownloaders[0]; - setSelectedDownloader( - selectedDownloader === undefined ? null : selectedDownloader - ); + setSelectedDownloader(selectedDownloader ?? null); }, [ userPreferences?.downloadsPath, downloaders, @@ -116,20 +114,30 @@ export function DownloadSettingsModal({ } }; - const handleStartClick = () => { + const handleStartClick = async () => { if (repack) { setDownloadStarting(true); - startDownload(repack, selectedDownloader!, selectedPath) - .then(() => { + try { + const response = await startDownload( + repack, + selectedDownloader!, + selectedPath + ); + + if (response.ok) { onClose(); - }) - .catch(() => { - showErrorToast(t("download_error")); - }) - .finally(() => { - setDownloadStarting(false); - }); + return; + } else if (response.error) { + showErrorToast(t("download_error"), t(response.error), 4_000); + } + } catch (error) { + if (error instanceof Error) { + showErrorToast(t("download_error"), error.message, 4_000); + } + } finally { + setDownloadStarting(false); + } } }; diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index ec6a8b07..d2d188db 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -1,7 +1,7 @@ import { useContext, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Modal, TextField } from "@renderer/components"; -import type { Game } from "@types"; +import type { LibraryGame } from "@types"; import { gameDetailsContext } from "@renderer/context"; import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; import { useDownload, useToast, useUserDetails } from "@renderer/hooks"; @@ -13,7 +13,7 @@ import "./game-options-modal.scss"; export interface GameOptionsModalProps { visible: boolean; - game: Game; + game: LibraryGame; onClose: () => void; } @@ -21,7 +21,7 @@ export function GameOptionsModal({ visible, game, onClose, -}: GameOptionsModalProps) { +}: Readonly) { const { t } = useTranslation("game_details"); const { showSuccessToast, showErrorToast } = useToast(); @@ -59,21 +59,25 @@ export function GameOptionsModal({ const { lastPacket } = useDownload(); const isGameDownloading = - game.status === "active" && lastPacket?.game.id === game.id; + game.download?.status === "active" && lastPacket?.gameId === game.id; const debounceUpdateLaunchOptions = useRef( debounce(async (value: string) => { - await window.electron.updateLaunchOptions(game.id, value); + await window.electron.updateLaunchOptions( + game.shop, + game.objectId, + value + ); updateGame(); }, 1000) ).current; const handleRemoveGameFromLibrary = async () => { if (isGameDownloading) { - await cancelDownload(game.id); + await cancelDownload(game.shop, game.objectId); } - await removeGameFromLibrary(game.id); + await removeGameFromLibrary(game.shop, game.objectId); updateGame(); onClose(); }; @@ -92,35 +96,39 @@ export function GameOptionsModal({ return; } - window.electron.updateExecutablePath(game.id, path).then(updateGame); + window.electron + .updateExecutablePath(game.shop, game.objectId, path) + .then(updateGame); } }; const handleCreateShortcut = async () => { - window.electron.createGameShortcut(game.id).then((success) => { - if (success) { - showSuccessToast(t("create_shortcut_success")); - } else { - showErrorToast(t("create_shortcut_error")); - } - }); + window.electron + .createGameShortcut(game.shop, game.objectId) + .then((success) => { + if (success) { + showSuccessToast(t("create_shortcut_success")); + } else { + showErrorToast(t("create_shortcut_error")); + } + }); }; const handleOpenDownloadFolder = async () => { - await window.electron.openGameInstallerPath(game.id); + await window.electron.openGameInstallerPath(game.shop, game.objectId); }; const handleDeleteGame = async () => { - await removeGameInstaller(game.id); + await removeGameInstaller(game.shop, game.objectId); updateGame(); }; const handleOpenGameExecutablePath = async () => { - await window.electron.openGameExecutablePath(game.id); + await window.electron.openGameExecutablePath(game.shop, game.objectId); }; const handleClearExecutablePath = async () => { - await window.electron.updateExecutablePath(game.id, null); + await window.electron.updateExecutablePath(game.shop, game.objectId, null); updateGame(); }; @@ -130,13 +138,17 @@ export function GameOptionsModal({ }); if (filePaths && filePaths.length > 0) { - await window.electron.selectGameWinePrefix(game.id, filePaths[0]); + await window.electron.selectGameWinePrefix( + game.shop, + game.objectId, + filePaths[0] + ); await updateGame(); } }; const handleClearWinePrefixPath = async () => { - await window.electron.selectGameWinePrefix(game.id, null); + await window.electron.selectGameWinePrefix(game.shop, game.objectId, null); updateGame(); }; @@ -150,7 +162,9 @@ export function GameOptionsModal({ const handleClearLaunchOptions = async () => { setLaunchOptions(""); - window.electron.updateLaunchOptions(game.id, null).then(updateGame); + window.electron + .updateLaunchOptions(game.shop, game.objectId, null) + .then(updateGame); }; const shouldShowWinePrefixConfiguration = @@ -159,7 +173,7 @@ export function GameOptionsModal({ const handleResetAchievements = async () => { setIsDeletingAchievements(true); try { - await window.electron.resetGameAchievements(game.id); + await window.electron.resetGameAchievements(game.shop, game.objectId); await updateGame(); showSuccessToast(t("reset_achievements_success")); } catch (error) { @@ -169,8 +183,6 @@ export function GameOptionsModal({ } }; - const shouldShowLaunchOptionsConfiguration = false; - return ( <> setShowDeleteModal(false)} deleteGame={handleDeleteGame} /> + setShowRemoveGameModal(false)} removeGameFromLibrary={handleRemoveGameFromLibrary} game={game} /> + setShowResetAchievementsModal(false)} @@ -290,29 +304,27 @@ export function GameOptionsModal({
)} - {shouldShowLaunchOptionsConfiguration && ( -
-
-

{t("launch_options")}

-

- {t("launch_options_description")} -

-
- - {t("clear")} - - ) - } - /> +
+
+

{t("launch_options")}

+

+ {t("launch_options_description")} +

- )} + + {t("clear")} + + ) + } + /> +
@@ -330,7 +342,7 @@ export function GameOptionsModal({ > {t("open_download_options")} - {game.downloadPath && ( + {game.download?.downloadPath && ( diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 60d1d95b..12c0090a 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -17,7 +17,7 @@ export interface RepacksModalProps { repack: GameRepack, downloader: Downloader, downloadPath: string - ) => Promise; + ) => Promise<{ ok: boolean; error?: string }>; onClose: () => void; } @@ -25,7 +25,7 @@ export function RepacksModal({ visible, startDownload, onClose, -}: RepacksModalProps) { +}: Readonly) { const [filteredRepacks, setFilteredRepacks] = useState([]); const [repack, setRepack] = useState(null); const [showSelectFolderModal, setShowSelectFolderModal] = useState(false); @@ -65,8 +65,8 @@ export function RepacksModal({ }; const checkIfLastDownloadedOption = (repack: GameRepack) => { - if (!game) return false; - return repack.uris.some((uri) => uri.includes(game.uri!)); + if (!game?.download) return false; + return repack.uris.some((uri) => uri.includes(game.download!.uri)); }; return ( @@ -107,7 +107,7 @@ export function RepacksModal({

{repack.fileSize} - {repack.repacker} -{" "} - {repack.uploadDate ? formatDate(repack.uploadDate!) : ""} + {repack.uploadDate ? formatDate(repack.uploadDate) : ""}

); diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index 542f9936..5073a2e4 100644 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -164,8 +164,8 @@ export function Sidebar() { )} - {achievements.slice(0, 4).map((achievement, index) => ( -
  • + {achievements.slice(0, 4).map((achievement) => ( +
  • { return userProfile?.relation?.status === "ACCEPTED"; }, [userProfile]); @@ -83,6 +80,7 @@ export function ProfileContent() { } const hasGames = userProfile?.libraryGames.length > 0; + const shouldShowRightContent = hasGames || userProfile.friends.length > 0; return ( @@ -102,6 +100,7 @@ export function ProfileContent() { <>

    {t("library")}

    + {userStats && ( {numberFormatter.format(userStats.libraryCount)} )} @@ -139,13 +138,13 @@ export function ProfileContent() { userStats, numberFormatter, t, - navigate, statsIndex, ]); return (
    + {content}
    ); diff --git a/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx b/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx index f5678909..5e13b0a9 100644 --- a/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx +++ b/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx @@ -11,7 +11,9 @@ import "./recent-games-box.scss"; export function RecentGamesBox() { const { userProfile } = useContext(userProfileContext); + const { t } = useTranslation("user_profile"); + const { numberFormatter } = useFormat(); const formatPlayTime = useCallback( diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx index 0b235acb..937df7f0 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx @@ -61,7 +61,9 @@ export function UserLibraryGameCard({ const formatAchievementPoints = (number: number) => { if (number < 100_000) return numberFormatter.format(number); + if (number < 1_000_000) return `${(number / 1000).toFixed(1)}K`; + return `${(number / 1_000_000).toFixed(1)}M`; }; diff --git a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx index c8225bfb..e198fbf2 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx @@ -196,7 +196,7 @@ export function ProfileHero() { + } + hint={ + + + + } + /> + )} + + ); +} diff --git a/src/renderer/src/pages/settings/settings.tsx b/src/renderer/src/pages/settings/settings.tsx index 75989aed..4c94343c 100644 --- a/src/renderer/src/pages/settings/settings.tsx +++ b/src/renderer/src/pages/settings/settings.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { SettingsRealDebrid } from "./settings-real-debrid"; import { SettingsGeneral } from "./settings-general"; import { SettingsBehavior } from "./settings-behavior"; +import torBoxLogo from "@renderer/assets/icons/torbox.webp"; import { SettingsDownloadSources } from "./settings-download-sources"; import { SettingsContextConsumer, @@ -12,20 +13,35 @@ import { SettingsAccount } from "./settings-account"; import { useUserDetails } from "@renderer/hooks"; import { useMemo } from "react"; import "./settings.scss"; +import { SettingsTorbox } from "./settings-torbox"; export default function Settings() { const { t } = useTranslation("settings"); + const { userDetails } = useUserDetails(); const categories = useMemo(() => { const categories = [ - t("general"), - t("behavior"), - t("download_sources"), - "Real-Debrid", + { tabLabel: t("general"), contentTitle: t("general") }, + { tabLabel: t("behavior"), contentTitle: t("behavior") }, + { tabLabel: t("download_sources"), contentTitle: t("download_sources") }, + { + tabLabel: ( + <> + TorBox + Torbox + + ), + contentTitle: "TorBox", + }, + { tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" }, ]; - if (userDetails) return [...categories, t("account")]; + if (userDetails) + return [ + ...categories, + { tabLabel: t("account"), contentTitle: t("account") }, + ]; return categories; }, [userDetails, t]); @@ -47,6 +63,10 @@ export default function Settings() { } if (currentCategoryIndex === 3) { + return ; + } + + if (currentCategoryIndex === 4) { return ; } @@ -59,18 +79,18 @@ export default function Settings() {
    {categories.map((category, index) => ( ))}
    -

    {categories[currentCategoryIndex]}

    +

    {categories[currentCategoryIndex].contentTitle}

    {renderCategory()}
    diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx index 893d339c..6b273a9e 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx @@ -30,9 +30,10 @@ export const UserFriendItem = (props: UserFriendItemProps) => { const getRequestDescription = () => { if (type === "ACCEPTED" || type === null) return null; + return ( - {type === "SENT" ? t("request_sent") : t("request_received")} + {type == "SENT" ? t("request_sent") : t("request_received")} ); }; @@ -105,10 +106,12 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
    +

    {displayName}

    +
    {getRequestActions()}
    @@ -130,6 +133,7 @@ export const UserFriendItem = (props: UserFriendItemProps) => { {getRequestDescription()}
    +
    {getRequestActions()}
    diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx index 93997777..7c045394 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx @@ -26,11 +26,15 @@ export const UserFriendModal = ({ userId, }: UserFriendsModalProps) => { const { t } = useTranslation("user_profile"); + const tabs = [t("friends_list"), t("add_friends")]; + const [currentTab, setCurrentTab] = useState( initialTab || UserFriendModalTab.FriendsList ); + const { showSuccessToast } = useToast(); + const { userDetails } = useUserDetails(); const isMe = userDetails?.id == userId; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f2bcc793..550c1097 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -5,6 +5,8 @@ export enum Downloader { PixelDrain, Qiwi, Datanodes, + Mediafire, + TorBox, } export enum DownloadSourceStatus { @@ -48,3 +50,10 @@ export enum AuthPage { UpdateEmail = "/update-email", UpdatePassword = "/update-password", } + +export enum DownloadError { + NotCachedInRealDebrid = "download_error_not_cached_in_real_debrid", + NotCachedInTorbox = "download_error_not_cached_in_torbox", + GofileQuotaExceeded = "download_error_gofile_quota_exceeded", + RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized", +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 7d612a17..01d7cb06 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -39,7 +39,7 @@ export const pipe = fns.reduce((prev, fn) => fn(prev), arg); export const removeReleaseYearFromName = (name: string) => - name.replace(/\([0-9]{4}\)/g, ""); + name.replace(/\(\d{4}\)/g, ""); export const removeSymbolsFromName = (name: string) => name.replace(/[^A-Za-z 0-9]/g, ""); @@ -88,12 +88,14 @@ export const getDownloadersForUri = (uri: string) => { if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain]; if (uri.startsWith("https://qiwi.gg")) return [Downloader.Qiwi]; if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes]; + if (uri.startsWith("https://www.mediafire.com")) + return [Downloader.Mediafire]; if (realDebridHosts.some((host) => uri.startsWith(host))) return [Downloader.RealDebrid]; if (uri.startsWith("magnet:")) { - return [Downloader.Torrent, Downloader.RealDebrid]; + return [Downloader.Torrent, Downloader.TorBox, Downloader.RealDebrid]; } return []; diff --git a/src/types/download.types.ts b/src/types/download.types.ts new file mode 100644 index 00000000..8b7f2091 --- /dev/null +++ b/src/types/download.types.ts @@ -0,0 +1,176 @@ +import type { Download } from "./level.types"; + +export type DownloadStatus = + | "active" + | "waiting" + | "paused" + | "error" + | "complete" + | "seeding" + | "removed"; + +export interface DownloadProgress { + downloadSpeed: number; + timeRemaining: number; + numPeers: number; + numSeeds: number; + isDownloadingMetadata: boolean; + isCheckingFiles: boolean; + progress: number; + gameId: string; + download: Download; +} + +/* Torbox */ +export interface TorBoxUser { + id: number; + email: string; + plan: string; + expiration: string; +} + +export interface TorBoxUserRequest { + success: boolean; + detail: string; + error: string; + data: TorBoxUser; +} + +export interface TorBoxFile { + id: number; + md5: string; + s3_path: string; + name: string; + size: number; + mimetype: string; + short_name: string; +} + +export interface TorBoxTorrentInfo { + id: number; + hash: string; + created_at: string; + updated_at: string; + magnet: string; + size: number; + active: boolean; + cached: boolean; + auth_id: string; + download_state: + | "downloading" + | "uploading" + | "stalled (no seeds)" + | "paused" + | "completed" + | "cached" + | "metaDL" + | "checkingResumeData"; + seeds: number; + ratio: number; + progress: number; + download_speed: number; + upload_speed: number; + name: string; + eta: number; + files: TorBoxFile[]; +} + +export interface TorBoxTorrentInfoRequest { + success: boolean; + detail: string; + error: string; + data: TorBoxTorrentInfo[]; +} + +export interface TorBoxAddTorrentRequest { + success: boolean; + detail: string; + error: string; + data: { + torrent_id: number; + name: string; + hash: string; + }; +} + +export interface TorBoxRequestLinkRequest { + success: boolean; + detail: string; + error: string; + data: string; +} + +/* Real-Debrid */ +export interface RealDebridUnrestrictLink { + id: string; + filename: string; + mimeType: string; + filesize: number; + link: string; + host: string; + host_icon: string; + chunks: number; + crc: number; + download: string; + streamable: number; +} + +export interface RealDebridAddMagnet { + id: string; + // URL of the created resource + uri: string; +} + +export interface RealDebridTorrentInfo { + id: string; + filename: string; + original_filename: string; + hash: string; + bytes: number; + original_bytes: number; + host: string; + split: number; + progress: number; + status: + | "magnet_error" + | "magnet_conversion" + | "waiting_files_selection" + | "queued" + | "downloading" + | "downloaded" + | "error" + | "virus" + | "compressing" + | "uploading" + | "dead"; + added: string; + files: { + id: number; + path: string; + bytes: number; + selected: number; + }[]; + links: string[]; + ended: string; + speed: number; + seeders: number; +} + +export interface RealDebridUser { + id: number; + username: string; + email: string; + points: number; + locale: string; + avatar: string; + type: string; + premium: number; + expiration: string; +} + +/* Torrent */ +export interface SeedingStatus { + gameId: string; + status: DownloadStatus; + uploadSpeed: number; +} diff --git a/src/types/game.types.ts b/src/types/game.types.ts new file mode 100644 index 00000000..e1ba779b --- /dev/null +++ b/src/types/game.types.ts @@ -0,0 +1,21 @@ +export type GameShop = "steam" | "epic"; + +export interface UnlockedAchievement { + name: string; + unlockTime: number; +} + +export interface SteamAchievement { + name: string; + displayName: string; + description?: string; + icon: string; + icongray: string; + hidden: boolean; + points?: number; +} + +export interface UserAchievement extends SteamAchievement { + unlocked: boolean; + unlockTime: number | null; +} diff --git a/src/types/index.ts b/src/types/index.ts index 345893a5..1e089d08 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,16 +1,7 @@ import type { Cracker, DownloadSourceStatus, Downloader } from "@shared"; import type { SteamAppDetails } from "./steam.types"; - -export type GameStatus = - | "active" - | "waiting" - | "paused" - | "error" - | "complete" - | "seeding" - | "removed"; - -export type GameShop = "steam" | "epic"; +import type { Download, Game, Subscription } from "./level.types"; +import type { GameShop } from "./game.types"; export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL"; @@ -31,48 +22,6 @@ export interface GameRepack { updatedAt: Date; } -export interface AchievementData { - name: string; - displayName: string; - description?: string; - icon: string; - icongray: string; - hidden: boolean; - points?: number; -} - -export interface UserAchievement { - name: string; - hidden: boolean; - displayName: string; - points?: number; - description?: string; - unlocked: boolean; - unlockTime: number | null; - icon: string; - icongray: string; -} - -export interface RemoteUnlockedAchievement { - name: string; - hidden: boolean; - icon: string; - displayName: string; - description?: string; - unlockTime: number; -} - -export interface GameAchievement { - name: string; - hidden: boolean; - displayName: string; - description?: string; - unlocked: boolean; - unlockTime: number | null; - icon: string; - icongray: string; -} - export type ShopDetails = SteamAppDetails & { objectId: string; }; @@ -95,82 +44,15 @@ export interface UserGame { achievementsPointsEarnedSum: number; } -export interface DownloadQueue { - id: number; - createdAt: Date; - updatedAt: Date; -} - -/* Used by the library */ -export interface Game { - id: number; - title: string; - iconUrl: string; - status: GameStatus | null; - folderName: string; - downloadPath: string | null; - progress: number; - bytesDownloaded: number; - playTimeInMilliseconds: number; - downloader: Downloader; - winePrefixPath: string | null; - executablePath: string | null; - launchOptions: string | null; - lastTimePlayed: Date | null; - uri: string | null; - fileSize: number; - objectID: string; - shop: GameShop; - downloadQueue: DownloadQueue | null; - shouldSeed: boolean; - createdAt: Date; - updatedAt: Date; -} - -export type LibraryGame = Omit; - export interface GameRunning { - id?: number; + id: string; title: string; iconUrl: string | null; - objectID: string; + objectId: string; shop: GameShop; sessionDurationInMillis: number; } -export interface DownloadProgress { - downloadSpeed: number; - timeRemaining: number; - numPeers: number; - numSeeds: number; - isDownloadingMetadata: boolean; - isCheckingFiles: boolean; - progress: number; - gameId: number; - game: LibraryGame; -} - -export interface SeedingStatus { - gameId: number; - status: GameStatus; - uploadSpeed: number; -} - -export interface UserPreferences { - downloadsPath: string | null; - language: string; - downloadNotificationsEnabled: boolean; - repackUpdatesNotificationsEnabled: boolean; - achievementNotificationsEnabled: boolean; - realDebridApiToken: string | null; - preferQuitInsteadOfHiding: boolean; - runAtStartup: boolean; - startMinimized: boolean; - disableNsfwAlert: boolean; - seedAfterDownloadComplete: boolean; - showHiddenAchievementsDescription: boolean; -} - export interface Steam250Game { title: string; objectId: string; @@ -248,16 +130,6 @@ export interface UserProfileCurrentGame extends Omit { export type ProfileVisibility = "PUBLIC" | "PRIVATE" | "FRIENDS"; -export type SubscriptionStatus = "active" | "pending" | "cancelled"; - -export interface Subscription { - id: string; - status: SubscriptionStatus; - plan: { id: string; name: string }; - expiresAt: string | null; - paymentMethod: "pix" | "paypal"; -} - export interface UserDetails { id: string; username: string; @@ -267,6 +139,7 @@ export interface UserDetails { backgroundImageUrl: string | null; profileVisibility: ProfileVisibility; bio: string; + featurebaseJwt: string; subscription: Subscription | null; quirks?: { backupsPerGameLimit: number; @@ -299,6 +172,7 @@ export interface UpdateProfileRequest { profileImageUrl?: string | null; backgroundImageUrl?: string | null; bio?: string; + language?: string; } export interface DownloadSourceDownload { @@ -353,11 +227,6 @@ export interface UserStats { unlockedAchievementSum?: number; } -export interface UnlockedAchievement { - name: string; - unlockTime: number; -} - export interface AchievementFile { type: Cracker; filePath: string; @@ -416,8 +285,14 @@ export interface CatalogueSearchPayload { developers: string[]; } +export interface LibraryGame extends Game { + id: string; + download: Download | null; +} + +export * from "./game.types"; export * from "./steam.types"; -export * from "./real-debrid.types"; +export * from "./download.types"; export * from "./ludusavi.types"; export * from "./how-long-to-beat.types"; -export * from "./torbox.types"; +export * from "./level.types"; diff --git a/src/types/level.types.ts b/src/types/level.types.ts new file mode 100644 index 00000000..aa28d9dd --- /dev/null +++ b/src/types/level.types.ts @@ -0,0 +1,82 @@ +import type { Downloader } from "@shared"; +import type { + GameShop, + SteamAchievement, + UnlockedAchievement, +} from "./game.types"; +import type { DownloadStatus } from "./download.types"; + +export type SubscriptionStatus = "active" | "pending" | "cancelled"; + +export interface Subscription { + id: string; + status: SubscriptionStatus; + plan: { id: string; name: string }; + expiresAt: string | null; + paymentMethod: "pix" | "paypal"; +} + +export interface Auth { + accessToken: string; + refreshToken: string; + tokenExpirationTimestamp: number; +} + +export interface User { + id: string; + displayName: string; + profileImageUrl: string | null; + backgroundImageUrl: string | null; + subscription: Subscription | null; +} + +export interface Game { + title: string; + iconUrl: string | null; + playTimeInMilliseconds: number; + lastTimePlayed: Date | null; + objectId: string; + shop: GameShop; + remoteId: string | null; + isDeleted: boolean; + winePrefixPath?: string | null; + executablePath?: string | null; + launchOptions?: string | null; +} + +export interface Download { + shop: GameShop; + objectId: string; + uri: string; + folderName: string | null; + downloadPath: string; + progress: number; + downloader: Downloader; + bytesDownloaded: number; + fileSize: number | null; + shouldSeed: boolean; + status: DownloadStatus | null; + queued: boolean; + timestamp: number; +} + +export interface GameAchievement { + achievements: SteamAchievement[]; + unlockedAchievements: UnlockedAchievement[]; +} + +export interface UserPreferences { + downloadsPath?: string | null; + language?: string; + realDebridApiToken?: string | null; + torBoxApiToken?: string | null; + preferQuitInsteadOfHiding?: boolean; + runAtStartup?: boolean; + startMinimized?: boolean; + disableNsfwAlert?: boolean; + seedAfterDownloadComplete?: boolean; + showHiddenAchievementsDescription?: boolean; + downloadNotificationsEnabled?: boolean; + repackUpdatesNotificationsEnabled?: boolean; + achievementNotificationsEnabled?: boolean; +} diff --git a/src/types/real-debrid.types.ts b/src/types/real-debrid.types.ts deleted file mode 100644 index 6b16ecfd..00000000 --- a/src/types/real-debrid.types.ts +++ /dev/null @@ -1,66 +0,0 @@ -export interface RealDebridUnrestrictLink { - id: string; - filename: string; - mimeType: string; - filesize: number; - link: string; - host: string; - host_icon: string; - chunks: number; - crc: number; - download: string; - streamable: number; -} - -export interface RealDebridAddMagnet { - id: string; - // URL of the created resource - uri: string; -} - -export interface RealDebridTorrentInfo { - id: string; - filename: string; - original_filename: string; - hash: string; - bytes: number; - original_bytes: number; - host: string; - split: number; - progress: number; - status: - | "magnet_error" - | "magnet_conversion" - | "waiting_files_selection" - | "queued" - | "downloading" - | "downloaded" - | "error" - | "virus" - | "compressing" - | "uploading" - | "dead"; - added: string; - files: { - id: number; - path: string; - bytes: number; - selected: number; - }[]; - links: string[]; - ended: string; - speed: number; - seeders: number; -} - -export interface RealDebridUser { - id: number; - username: string; - email: string; - points: number; - locale: string; - avatar: string; - type: string; - premium: number; - expiration: string; -} diff --git a/src/types/torbox.types.ts b/src/types/torbox.types.ts deleted file mode 100644 index a53ccc4c..00000000 --- a/src/types/torbox.types.ts +++ /dev/null @@ -1,77 +0,0 @@ -export interface TorBoxUser { - id: number; - email: string; - plan: string; - expiration: string; -} - -export interface TorBoxUserRequest { - success: boolean; - detail: string; - error: string; - data: TorBoxUser; -} - -export interface TorBoxFile { - id: number; - md5: string; - s3_path: string; - name: string; - size: number; - mimetype: string; - short_name: string; -} - -export interface TorBoxTorrentInfo { - id: number; - hash: string; - created_at: string; - updated_at: string; - magnet: string; - size: number; - active: boolean; - cached: boolean; - auth_id: string; - download_state: - | "downloading" - | "uploading" - | "stalled (no seeds)" - | "paused" - | "completed" - | "cached" - | "metaDL" - | "checkingResumeData"; - seeds: number; - ratio: number; - progress: number; - download_speed: number; - upload_speed: number; - name: string; - eta: number; - files: TorBoxFile[]; -} - -export interface TorBoxTorrentInfoRequest { - success: boolean; - detail: string; - error: string; - data: TorBoxTorrentInfo[]; -} - -export interface TorBoxAddTorrentRequest { - success: boolean; - detail: string; - error: string; - data: { - torrent_id: number; - name: string; - hash: string; - }; -} - -export interface TorBoxRequestLinkRequest { - success: boolean; - detail: string; - error: string; - data: string; -} diff --git a/tsconfig.web.json b/tsconfig.web.json index ca29bd89..6dc0c4ab 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -6,7 +6,9 @@ "src/renderer/src/**/*.tsx", "src/preload/*.d.ts", "src/locales/index.ts", - "src/shared/**/*" + "src/shared/**/*", + "src/stories/**/*", + ".storybook/**/*" ], "compilerOptions": { "composite": true, diff --git a/yarn.lock b/yarn.lock index 003c15d2..b087b828 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2899,11 +2899,6 @@ "@smithy/types" "^3.7.2" tslib "^2.6.2" -"@sqltools/formatter@^1.2.5": - version "1.2.5" - resolved "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz" - integrity sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw== - "@svgr/babel-plugin-add-jsx-attribute@8.0.0": version "8.0.0" resolved "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz" @@ -3501,6 +3496,18 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" +abstract-level@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/abstract-level/-/abstract-level-2.0.2.tgz#8d965e731afb42a72f163874410c1687fb2e4bdb" + integrity sha512-pPJixmXk/kTKLB2sSue7o4Uj6TlLD2XfaP2gWZomHVCC6cuUGX/VslQqKG1yZHfXwBb/3lS6oSTMPGzh1P1iig== + dependencies: + buffer "^6.0.3" + is-buffer "^2.0.5" + level-supports "^6.0.0" + level-transcoder "^1.0.1" + maybe-combine-errors "^1.0.0" + module-error "^1.0.1" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" @@ -3611,11 +3618,6 @@ ansi-styles@^6.1.0: resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== -any-promise@^1.0.0: - version "1.3.0" - resolved "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz" - integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== - anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" @@ -3667,11 +3669,6 @@ app-builder-lib@25.1.8: tar "^6.1.12" temp-file "^3.4.0" -app-root-path@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz" - integrity sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA== - applescript@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz" @@ -4223,6 +4220,16 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +classic-level@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/classic-level/-/classic-level-2.0.0.tgz#6fd9ca686bbcd645e35caaf403c3f3a56495d11b" + integrity sha512-ftiMvKgCQK+OppXcvMieDoYlYLYWhScK6yZRFBrrlHQRbm4k6Gr+yDgu/wt3V0k1/jtNbuiXAsRmuAFcD0Tx5Q== + dependencies: + abstract-level "^2.0.0" + module-error "^1.0.1" + napi-macros "^2.2.2" + node-gyp-build "^4.3.0" + classnames@^2.2.1, classnames@^2.2.6, classnames@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" @@ -4240,18 +4247,6 @@ cli-cursor@^3.1.0: dependencies: restore-cursor "^3.1.0" -cli-highlight@^2.1.11: - version "2.1.11" - resolved "https://registry.yarnpkg.com/cli-highlight/-/cli-highlight-2.1.11.tgz#49736fa452f0aaf4fae580e30acb26828d2dc1bf" - integrity sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg== - dependencies: - chalk "^4.0.0" - highlight.js "^10.7.1" - mz "^2.4.0" - parse5 "^5.1.1" - parse5-htmlparser2-tree-adapter "^6.0.0" - yargs "^16.0.0" - cli-spinners@^2.5.0: version "2.9.2" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" @@ -4265,15 +4260,6 @@ cli-truncate@^2.1.0: slice-ansi "^3.0.0" string-width "^4.2.0" -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^7.0.0" - cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -4567,11 +4553,6 @@ date-fns@^3.6.0: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf" integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww== -dayjs@^1.11.9: - version "1.11.13" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" - integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== - debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" @@ -4760,16 +4741,16 @@ dotenv-expand@^11.0.6: dependencies: dotenv "^16.4.4" -dotenv@^16.0.3, dotenv@^16.4.4, dotenv@^16.4.5: - version "16.4.5" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" - integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== - dotenv@^16.3.1: version "16.4.7" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26" integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ== +dotenv@^16.4.4, dotenv@^16.4.5: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + dunder-proto@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.0.tgz#c2fce098b3c8f8899554905f4377b6d85dabaa80" @@ -5710,17 +5691,6 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob@^10.3.10: - version "10.3.15" - resolved "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz" - integrity sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw== - dependencies: - foreground-child "^3.1.0" - jackspeak "^2.3.6" - minimatch "^9.0.1" - minipass "^7.0.4" - path-scurry "^1.11.0" - glob@^10.3.12, glob@^10.3.7: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" @@ -5912,11 +5882,6 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: dependencies: function-bind "^1.1.2" -highlight.js@^10.7.1: - version "10.7.3" - resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" - integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== - hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -6088,7 +6053,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4: +inherits@2, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -6194,6 +6159,11 @@ is-boolean-object@^1.2.0: call-bound "^1.0.2" has-tostringtag "^1.0.2" +is-buffer@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" @@ -6458,15 +6428,6 @@ iterator.prototype@^1.1.3: reflect.getprototypeof "^1.0.8" set-function-name "^2.0.2" -jackspeak@^2.3.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" - integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== - dependencies: - "@isaacs/cliui" "^8.0.2" - optionalDependencies: - "@pkgjs/parseargs" "^0.11.0" - jackspeak@^3.1.2: version "3.4.3" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" @@ -6688,6 +6649,19 @@ lazy-val@^1.0.5: resolved "https://registry.yarnpkg.com/lazy-val/-/lazy-val-1.0.5.tgz#6cf3b9f5bc31cee7ee3e369c0832b7583dcd923d" integrity sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q== +level-supports@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/level-supports/-/level-supports-6.2.0.tgz#e78b228973a24acdc5199c5f51e244e70f26c611" + integrity sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w== + +level-transcoder@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/level-transcoder/-/level-transcoder-1.0.1.tgz#f8cef5990c4f1283d4c86d949e73631b0bc8ba9c" + integrity sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w== + dependencies: + buffer "^6.0.3" + module-error "^1.0.1" + levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -6928,6 +6902,11 @@ math-intrinsics@^1.0.0: resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.0.0.tgz#4e04bf87c85aa51e90d078dac2252b4eb5260817" integrity sha512-4MqMiKP90ybymYvsut0CH2g4XWbfLtmlCkXmtmdcDCxNB+mQcu1w/1+L/VD7vi/PSv7X2JYV7SCcR+jiPXnQtA== +maybe-combine-errors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/maybe-combine-errors/-/maybe-combine-errors-1.0.0.tgz#e9592832e61fc47643a92cff3c1f33e27211e5be" + integrity sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A== + meow@^12.0.1: version "12.1.1" resolved "https://registry.yarnpkg.com/meow/-/meow-12.1.1.tgz#e558dddbab12477b69b2e9a2728c327f191bace6" @@ -7006,7 +6985,7 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.1, minimatch@^9.0.3, minimatch@^9.0.4: +minimatch@^9.0.3, minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== @@ -7112,16 +7091,16 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mkdirp@^2.1.3: - version "2.1.6" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19" - integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A== - mkdirp@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== +module-error@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/module-error/-/module-error-1.0.2.tgz#8d1a48897ca883f47a45816d4fb3e3c6ba404d86" + integrity sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA== + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -7132,15 +7111,6 @@ ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -mz@^2.4.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" - integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== - dependencies: - any-promise "^1.0.0" - object-assign "^4.0.1" - thenify-all "^1.0.0" - nan@^2.18.0: version "2.22.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.22.0.tgz#31bc433fc33213c97bad36404bb68063de604de3" @@ -7156,6 +7126,11 @@ napi-build-utils@^1.0.1: resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== +napi-macros@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.2.2.tgz#817fef20c3e0e40a963fbf7b37d1600bd0201044" + integrity sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -7214,6 +7189,11 @@ node-fetch@^3.3.0: fetch-blob "^3.1.4" formdata-polyfill "^4.0.10" +node-gyp-build@^4.3.0: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + node-gyp@^9.0.0: version "9.4.1" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-9.4.1.tgz#8a1023e0d6766ecb52764cc3a734b36ff275e185" @@ -7273,7 +7253,7 @@ nwsapi@^2.2.12: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.13.tgz#e56b4e98960e7a040e5474536587e599c4ff4655" integrity sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ== -object-assign@^4.0.1, object-assign@^4.1.1: +object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -7446,23 +7426,6 @@ parse-torrent@^11.0.17: queue-microtask "^1.2.3" uint8-util "^2.2.5" -parse5-htmlparser2-tree-adapter@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" - integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== - dependencies: - parse5 "^6.0.1" - -parse5@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" - integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== - -parse5@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" - integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== - parse5@^7.0.0, parse5@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" @@ -7495,7 +7458,7 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-scurry@^1.11.0, path-scurry@^1.11.1, path-scurry@^1.6.1: +path-scurry@^1.11.1, path-scurry@^1.6.1: version "1.11.1" resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== @@ -7850,11 +7813,6 @@ redux@^5.0.1: resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== -reflect-metadata@^0.2.1: - version "0.2.2" - resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" - integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== - reflect.getprototypeof@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.8.tgz#c58afb17a4007b4d1118c07b92c23fca422c5d82" @@ -8301,14 +8259,6 @@ set-function-name@^2.0.1, set-function-name@^2.0.2: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" -sha.js@^2.4.11: - version "2.4.11" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" - integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -8723,20 +8673,6 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== -thenify-all@^1.0.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" - integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== - dependencies: - thenify ">= 3.1.0 < 4" - -"thenify@>= 3.1.0 < 4": - version "3.3.1" - resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" - integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== - dependencies: - any-promise "^1.0.0" - "through@>=2.2.7 <3": version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" @@ -8857,7 +8793,7 @@ tslib@^2.0.0, tslib@^2.1.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== -tslib@^2.0.3, tslib@^2.5.0, tslib@^2.6.2: +tslib@^2.0.3, tslib@^2.6.2: version "2.7.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== @@ -8935,27 +8871,6 @@ typed-array-length@^1.0.6: is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" -typeorm@^0.3.20: - version "0.3.20" - resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.3.20.tgz#4b61d737c6fed4e9f63006f88d58a5e54816b7ab" - integrity sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q== - dependencies: - "@sqltools/formatter" "^1.2.5" - app-root-path "^3.1.0" - buffer "^6.0.3" - chalk "^4.1.2" - cli-highlight "^2.1.11" - dayjs "^1.11.9" - debug "^4.3.4" - dotenv "^16.0.3" - glob "^10.3.10" - mkdirp "^2.1.3" - reflect-metadata "^0.2.1" - sha.js "^2.4.11" - tslib "^2.5.0" - uuid "^9.0.0" - yargs "^17.6.2" - typescript@^5.3.3, typescript@^5.4.3: version "5.6.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" @@ -9097,7 +9012,7 @@ util-deprecate@^1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -uuid@^9.0.0, uuid@^9.0.1: +uuid@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== @@ -9387,29 +9302,11 @@ yaml@^2.6.1: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.6.1.tgz#42f2b1ba89203f374609572d5349fb8686500773" integrity sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg== -yargs-parser@^20.2.2: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@^16.0.0: - version "16.2.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.0" - y18n "^5.0.5" - yargs-parser "^20.2.2" - yargs@^17.0.0, yargs@^17.0.1, yargs@^17.6.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"