diff --git a/.gitignore b/.gitignore index 36e620fc8..f9f329774 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 cd08b6d40..2b7048c46 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -38,6 +38,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 2895f20c2..f7fac4ca5 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,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", @@ -74,7 +75,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 40e30ccdc..71e4b57ea 100644 --- a/python_rpc/http_downloader.py +++ b/python_rpc/http_downloader.py @@ -11,11 +11,12 @@ def __init__(self): ) ) - 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 03df83dee..2deb20297 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -28,14 +28,14 @@ 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 @@ 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 ca4c2fa8f..8de8764ee 100644 --- a/python_rpc/torrent_downloader.py +++ b/python_rpc/torrent_downloader.py @@ -102,7 +102,7 @@ def __init__(self, torrent_session, flags = lt.torrent_flags.auto_managed): "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 2c269e7e1..5443c3dc7 100644 --- a/src/locales/ar/translation.json +++ b/src/locales/ar/translation.json @@ -1,423 +1,445 @@ { - "language_name": "اَلْعَرَبِيَّةُ", + "language_name": "العربية", "app": { "successfully_signed_in": "تم تسجيل الدخول بنجاح" }, "home": { - "featured": "مُتَمَيِّز", - "surprise_me": "فَاجِئْنِي", - "no_results": "لَمْ يُعْثَرْ عَلَى نَتائِج", - "start_typing": "اِبْدَأْ بِالْكِتَابَةِ لِلْبَحْثِ...", - "hot": "اَلْأَكْثَرُ شُيُوعًا الْآن", - "weekly": "📅 أَفْضَلُ أَلْعَابِ الْأُسْبُوعِ", - "achievements": "🏆 أَلْعَابٌ لِلتَّغَلُّبِ عَلَيْهَا" + "featured": "مميز", + "surprise_me": "مفاجئني", + "no_results": "لم يتم العثور على نتائج", + "start_typing": "ابدأ الكتابة للبحث...", + "hot": "الأكثر شيوعًا الآن", + "weekly": "📅 أفضل ألعاب الأسبوع", + "achievements": "🏆 ألعاب للتغلب عليها" }, "sidebar": { - "catalogue": "الْفِهْرِسُ", - "downloads": "التَّنْزِيلَاتُ", - "settings": "الإعْدَادَاتُ", - "my_library": "مَكْتَبَتِي", - "downloading_metadata": "{{title}} (جَارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ...)", - "paused": "{{title}} (مُوْقَفٌ)", - "downloading": "{{title}} ({{percentage}} - جَارٍ التَّنْزِيلُ...)", - "filter": "تَصْفِيَةُ الْمَكْتَبَةِ", - "home": "الرَّئِيسِيَّةُ", - "queued": "{{title}} (فِي الْانْتِظَارِ)", - "game_has_no_executable": "اللُّعْبَةُ لَيْسَ لَدَيْهَا مِلَفٌّ تَنْفِيذِيٌّ مُحَدَّدٌ", - "sign_in": "تَسْجِيلُ الدُّخُولِ", - "friends": "الْأَصْدِقَاءُ", - "need_help": "هَلْ تَحْتَاجُ إِلَى مُسَاعَدَةٍ؟" + "catalogue": "الكـتالوج", + "downloads": "التنزيلات", + "settings": "الإعدادات", + "my_library": "مكتبتي", + "downloading_metadata": "{{title}} (جارٍ تنزيل البيانات الوصفية...)", + "paused": "{{title}} (معلّق)", + "downloading": "{{title}} ({{percentage}} - جاري التنزيل...)", + "filter": "تصفية المكتبة", + "home": "الرئيسية", + "queued": "{{title}} (في قائمة الانتظار)", + "game_has_no_executable": "اللعبة لا تحتوي على ملف تشغيل", + "sign_in": "تسجيل الدخول", + "friends": "الأصدقاء", + "need_help": "تحتاج مساعدة؟" }, "header": { - "search": "بَحْثُ الْأَلْعَابِ", - "home": "الرَّئِيسِيَّةُ", - "catalogue": "الْفِهْرِسُ", - "downloads": "التَّنْزِيلَاتُ", - "search_results": "نَتائِجُ الْبَحْثِ", - "settings": "الإعْدَادَاتُ", - "version_available_install": "الْإِصْدَارُ {{version}} مَتَوَفِّرٌ. انْقُرْ هُنَا لِإِعَادَةِ التَّشْغِيلِ وَالتَّثْبِيتِ.", - "version_available_download": "الْإِصْدَارُ {{version}} مَتَوَفِّرٌ. انْقُرْ هُنَا لِلتَّنْزِيلِ." + "search": "ابحث عن الألعاب", + "home": "الرئيسية", + "catalogue": "الكـتالوج", + "downloads": "التنزيلات", + "search_results": "نتائج البحث", + "settings": "الإعدادات", + "version_available_install": "الإصدار {{version}} متوفر. انقر هنا لإعادة التشغيل والتثبيت.", + "version_available_download": "الإصدار {{version}} متوفر. انقر هنا للتنزيل." }, "bottom_panel": { - "no_downloads_in_progress": "لَا تَوْجَدُ تَنْزِيلَاتٌ جَارِيَةٌ", - "downloading_metadata": "جَارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ لِـ {{title}}...", - "downloading": "جَارٍ تَنْزِيلُ {{title}}... ({{percentage}} مَكْتُومٌ) - الِاكْتِمَالُ {{eta}} - {{speed}}", - "calculating_eta": "جَارٍ تَنْزِيلُ {{title}}... ({{percentage}} مَكْتُومٌ) - جَارٍ حِسَابُ الْوَقْتِ الْمُتَبَقِّي...", - "checking_files": "جَارٍ التَّحَقُّقُ مِنْ مَلَفَّاتِ {{title}}... ({{percentage}} مَكْتُومٌ)" + "no_downloads_in_progress": "لا توجد تنزيلات قيد التقدم", + "downloading_metadata": "جارٍ تنزيل البيانات الوصفية لـ {{title}}...", + "downloading": "جارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - الوقت المتبقي {{eta}} - السرعة {{speed}}", + "calculating_eta": "جارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - جاري حساب الوقت المتبقي...", + "checking_files": "جارٍ فحص ملفات {{title}}... ({{percentage}} اكتمال)" }, "catalogue": { - "search": "تَصْفِيَةٌ...", - "developers": "الْمُطَوِّرُونَ", - "genres": "الْأَنْوَاعُ", - "tags": "الْعَلَامَاتُ", - "publishers": "النَّاشِرُونَ", - "download_sources": "مَصَادِرُ التَّنْزِيلِ", - "result_count": "{{resultCount}} نَتائِجُ", - "filter_count": "{{filterCount}} مَتَوَفِّرٌ", - "clear_filters": "مَسْحُ {{filterCount}} الْمُخْتَارَةِ" + "search": "تصفية...", + "developers": "المطورون", + "genres": "الأنواع", + "tags": "العلامات", + "publishers": "الناشرون", + "download_sources": "مصادر التنزيل", + "result_count": "{{resultCount}} نتيجة", + "filter_count": "{{filterCount}} متاح", + "clear_filters": "مسح {{filterCount}} المحددة" }, "game_details": { - "open_download_options": "فَتْحُ خِيَارَاتِ التَّنْزِيلِ", - "download_options_zero": "لَا تَوْجَدُ خِيَارَاتُ تَنْزِيلٍ", - "download_options_one": "{{count}} خِيَارُ تَنْزِيلٍ", - "download_options_other": "{{count}} خِيَارَاتُ تَنْزِيلٍ", - "updated_at": "تَمَّ التَّحْدِيثُ فِي {{updated_at}}", - "install": "تَثْبِيتٌ", - "resume": "اسْتِئْنَافٌ", - "pause": "إِيقَافٌ", - "cancel": "إِلْغَاءٌ", - "remove": "إِزَالَةٌ", - "space_left_on_disk": "{{space}} مُتَبَقٍّ عَلَى الْقُرْصِ", - "eta": "الِاكْتِمَالُ {{eta}}", - "calculating_eta": "جَارٍ حِسَابُ الْوَقْتِ الْمُتَبَقِّي...", - "downloading_metadata": "جَارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ...", - "filter": "تَصْفِيَةُ الْإِصْدَارَاتِ الْمُعَادِ تَغْلِيفُهَا", - "requirements": "مُتَطَلَّبَاتُ النِّظَامِ", - "minimum": "الْأَدْنَى", - "recommended": "الْمُوَصَّى بِهِ", - "paused": "مُوْقَفٌ", - "release_date": "تَمَّ الْإِصْدَارُ فِي {{date}}", - "publisher": "نُشِرَ بِوَاسِطَةِ {{publisher}}", - "hours": "سَاعَاتٌ", - "minutes": "دَقَائِقُ", - "amount_hours": "{{amount}} سَاعَاتٌ", - "amount_minutes": "{{amount}} دَقَائِقُ", - "accuracy": "دِقَّةٌ {{accuracy}}%", - "add_to_library": "إِضَافَةٌ إِلَى الْمَكْتَبَةِ", - "remove_from_library": "إِزَالَةٌ مِنَ الْمَكْتَبَةِ", - "no_downloads": "لَا تَوْجَدُ تَنْزِيلَاتٌ مَتَوَفِّرَةٌ", - "play_time": "لُعِبَ لِمُدَّةِ {{amount}}", - "last_time_played": "آخِرُ مَرَّةٍ لُعِبَتْ {{period}}", - "not_played_yet": "لَمْ تَلْعَبْ {{title}} بَعْدُ", - "next_suggestion": "الِاقْتِرَاحُ التَّالِي", - "play": "لَعِبٌ", - "deleting": "جَارٍ حَذْفُ الْمُثَبِّتِ...", - "close": "إِغْلَاقٌ", - "playing_now": "جَارِي اللَّعِبُ الْآن", - "change": "تَغْيِيرٌ", - "repacks_modal_description": "اخْتَرِ الْإِصْدَارَ الْمُعَادَ تَغْلِيفُهُ الَّذِي تُرِيدُ تَنْزِيلَهُ", - "select_folder_hint": "لِتَغْيِيرِ الْمَجَلَّدِ الافْتِرَاضِيِّ، اذْهَبْ إِلَى <0>الإعْدَادَاتِ", - "download_now": "تَنْزِيلٌ الْآن", - "no_shop_details": "لَمْ يَتَمَكَّنْ مِنْ اسْتِرْدَادِ تَفَاصِيلِ الْمَتْجَرِ.", - "download_options": "خِيَارَاتُ التَّنْزِيلِ", - "download_path": "مَسَارُ التَّنْزِيلِ", - "previous_screenshot": "لَقْطَةُ الشَّاشَةِ السَّابِقَةُ", - "next_screenshot": "لَقْطَةُ الشَّاشَةِ التَّالِيَةُ", - "screenshot": "لَقْطَةُ الشَّاشَةِ {{number}}", - "open_screenshot": "فَتْحُ لَقْطَةِ الشَّاشَةِ {{number}}", - "download_settings": "إعْدَادَاتُ التَّنْزِيلِ", - "downloader": "الْمُنَزِّلُ", - "select_executable": "تَحْدِيدٌ", - "no_executable_selected": "لَمْ يُحَدَّدْ مِلَفٌّ تَنْفِيذِيٌّ", - "open_folder": "فَتْحُ الْمَجَلَّدِ", - "open_download_location": "مُشَاهَدَةُ الْمَلَفَّاتِ الْمُنَزَّلَةِ", - "create_shortcut": "إِنْشَاءُ طَرِيقٍ مُخْتَصَرٍ عَلَى سَطْحِ الْمَكْتَبِ", - "clear": "مَسْحٌ", - "remove_files": "إِزَالَةُ الْمَلَفَّاتِ", - "remove_from_library_title": "هَلْ أَنْتَ مُتَأَكِّدٌ؟", - "remove_from_library_description": "سَيُؤَدِّي هَذَا إِلَى إِزَالَةِ {{game}} مِنْ مَكْتَبَتِكَ", - "options": "خِيَارَاتٌ", - "executable_section_title": "الْمِلَفُّ التَّنْفِيذِيُّ", - "executable_section_description": "مَسَارُ الْمِلَفِّ الَّذِي سَيَتِمُّ تَنْفِيذُهُ عِنْدَ النَّقْرِ عَلَى \"لَعِبٌ\"", - "downloads_secion_title": "التَّنْزِيلَاتُ", - "downloads_section_description": "تَحَقَّقْ مِنَ التَّحْدِيثَاتِ أَوِ الْإِصْدَارَاتِ الْأُخْرَى لِهَذِهِ اللُّعْبَةِ", - "danger_zone_section_title": "مِنْطَقَةُ الْخَطَرِ", - "danger_zone_section_description": "إِزَالَةُ هَذِهِ اللُّعْبَةِ مِنْ مَكْتَبَتِكَ أَوِ الْمَلَفَّاتِ الْمُنَزَّلَةِ بِوَاسِطَةِ Hydra", - "download_in_progress": "جَارٍ التَّنْزِيلُ", - "download_paused": "التَّنْزِيلُ مُوْقَفٌ", - "last_downloaded_option": "خِيَارُ التَّنْزِيلِ الْأَخِيرُ", - "create_shortcut_success": "تَمَّ إِنْشَاءُ الطَّرِيقِ الْمُخْتَصَرِ بِنَجَاحٍ", - "create_shortcut_error": "خَطَأٌ فِي إِنْشَاءِ الطَّرِيقِ الْمُخْتَصَرِ", - "nsfw_content_title": "هَذِهِ اللُّعْبَةُ تَحْتَوِي عَلَى مُحْتَوًى غَيْرِ لَائِقٍ", - "nsfw_content_description": "{{title}} تَحْتَوِي عَلَى مُحْتَوًى قَدْ لَا يَكُونُ مُنَاسِبًا لِجَمِيعِ الْأَعْمَارِ. هَلْ أَنْتَ مُتَأَكِّدٌ مِنْ أَنَّكَ تُرِيدُ الْمُتَابَعَةَ؟", - "allow_nsfw_content": "الْمُتَابَعَةُ", - "refuse_nsfw_content": "الرُّجُوعُ", - "stats": "الإحْصَائِيَّاتُ", - "download_count": "التَّنْزِيلَاتُ", - "player_count": "اللَّاعِبُونَ النَّشِطُونَ", - "download_error": "هَذَا خِيَارُ التَّنْزِيلِ غَيْرُ مَتَوَفِّرٍ", - "download": "تَنْزِيلٌ", - "executable_path_in_use": "الْمِلَفُّ التَّنْفِيذِيُّ مُسْتَخْدَمٌ بِوَاسِطَةِ \"{{game}}\"", - "warning": "تَنْبِيهٌ:", - "hydra_needs_to_remain_open": "لِهَذَا التَّنْزِيلِ، يَجِبُ أَنْ يَبْقَى Hydra مَفْتُوحًا حَتَّى يَتِمَّ الِاكْتِمَالُ. إِذَا أُغْلِقَ Hydra قَبْلَ الِاكْتِمَالِ، سَتَفْقِدُ تَقَدُّمَكَ.", - "achievements": "الإِنْجَازَاتُ", - "achievements_count": "الإِنْجَازَاتُ {{unlockedCount}}/{{achievementsCount}}", - "cloud_save": "حِفْظٌ سَحَابِيٌّ", - "cloud_save_description": "احْفَظْ تَقَدُّمَكَ فِي السَّحَابَةِ وَاسْتَمِرَّ فِي اللَّعِبِ عَلَى أَيِّ جِهَازٍ", - "backups": "الْنُسَخُ الِاحْتِيَاطِيَّةُ", - "install_backup": "تَثْبِيتٌ", - "delete_backup": "حَذْفٌ", - "create_backup": "نُسْخَةٌ احْتِيَاطِيَّةٌ جَدِيدَةٌ", - "last_backup_date": "آخِرُ نُسْخَةٍ احْتِيَاطِيَّةٍ فِي {{date}}", - "no_backup_preview": "لَمْ يُعْثَرْ عَلَى أَيِّ أَلْعَابٍ مَحْفُوظَةٍ لِهَذَا الْعُنْوَانِ", - "restoring_backup": "جَارٍ اسْتِعَادَةُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ ({{progress}} مَكْتُومٌ)...", - "uploading_backup": "جَارٍ رَفْعُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ...", - "no_backups": "لَمْ تَقُمْ بِإِنْشَاءِ أَيِّ نُسَخٍ احْتِيَاطِيَّةٍ لِهَذِهِ اللُّعْبَةِ بَعْدُ", - "backup_uploaded": "تَمَّ رَفْعُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", - "backup_deleted": "تَمَّ حَذْفُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", - "backup_restored": "تَمَّ اسْتِعَادَةُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", - "see_all_achievements": "عَرْضُ جَمِيعِ الإِنْجَازَاتِ", - "sign_in_to_see_achievements": "سَجِّلِ الدُّخُولَ لِعَرْضِ الإِنْجَازَاتِ", - "mapping_method_automatic": "آلِيٌّ", - "mapping_method_manual": "يَدَوِيٌّ", - "mapping_method_label": "طَرِيقَةُ التَّحْدِيدِ", - "files_automatically_mapped": "تَمَّ تَحْدِيدُ الْمَلَفَّاتِ تِلْقَائِيًّا", - "no_backups_created": "لَمْ تُنْشَأْ أَيُّ نُسَخٍ احْتِيَاطِيَّةٍ لِهَذِهِ اللُّعْبَةِ", - "manage_files": "إِدَارَةُ الْمَلَفَّاتِ", - "loading_save_preview": "جَارٍ الْبَحْثُ عَنْ أَلْعَابٍ مَحْفُوظَةٍ...", - "wine_prefix": "بَادِئَةُ Wine", - "wine_prefix_description": "بَادِئَةُ Wine الْمُسْتَخْدَمَةُ لِتَشْغِيلِ هَذِهِ اللُّعْبَةِ", - "launch_options": "خِيَارَاتُ الْإِطْلَاقِ", - "launch_options_description": "يُمْكِنُ لِلْمُسْتَخْدِمِينَ الْمُتَقَدِّمِينَ إِدْخَالُ تَعْدِيلَاتٍ عَلَى خِيَارَاتِ الْإِطْلَاقِ", - "launch_options_placeholder": "لَمْ يُحَدَّدْ أَيُّ مُعَامِلٍ", - "no_download_option_info": "لَا تَوْجَدُ مَعْلُومَاتٌ مَتَوَفِّرَةٌ", - "backup_deletion_failed": "فَشَلَ فِي حَذْفِ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", - "max_number_of_artifacts_reached": "تَمَّ بَلُوغُ الْعَدَدِ الْأَقْصَى لِلنُّسَخِ الِاحْتِيَاطِيَّةِ لِهَذِهِ اللُّعْبَةِ", - "achievements_not_sync": "تَعَرَّفْ عَلَى كَيْفِيَّةِ مَزْجِ إِنْجَازَاتِكَ", - "manage_files_description": "إِدَارَةُ الْمَلَفَّاتِ الَّتِي سَيَتِمُّ نَسْخُهَا احْتِيَاطِيًّا وَاسْتِعَادَتُهَا", - "select_folder": "تَحْدِيدُ الْمَجَلَّدِ", - "backup_from": "نُسْخَةٌ احْتِيَاطِيَّةٌ مِنْ {{date}}", - "custom_backup_location_set": "تَمَّ تَحْدِيدُ مَوْقِعِ النُّسْخَةِ الِاحْتِيَاطِيَّةِ الْمُخَصَّصِ", - "no_directory_selected": "لَمْ يُحَدَّدْ أَيُّ دَلِيلٍ" + "open_download_options": "فتح خيارات التنزيل", + "download_options_zero": "لا توجد خيارات تنزيل", + "download_options_one": "خيار تنزيل واحد", + "download_options_other": "{{count}} خيارات تنزيل", + "updated_at": "تم التحديث في {{updated_at}}", + "install": "تثبيت", + "resume": "استئناف", + "pause": "إيقاف مؤقت", + "cancel": "إلغاء", + "remove": "إزالة", + "space_left_on_disk": "{{space}} متبقي على القرص", + "eta": "الانتهاء {{eta}}", + "calculating_eta": "جارٍ حساب الوقت المتبقي...", + "downloading_metadata": "جارٍ تنزيل البيانات الوصفية...", + "filter": "تصفية الحزم المعاد تعبئتها", + "requirements": "متطلبات النظام", + "minimum": "الحد الأدنى", + "recommended": "مُوصى به", + "paused": "معلّق", + "release_date": "تاريخ الإصدار {{date}}", + "publisher": "نشر بواسطة {{publisher}}", + "hours": "ساعات", + "minutes": "دقائق", + "amount_hours": "{{amount}} ساعات", + "amount_minutes": "{{amount}} دقائق", + "accuracy": "دقة {{accuracy}}%", + "add_to_library": "إضافة إلى المكتبة", + "remove_from_library": "إزالة من المكتبة", + "no_downloads": "لا توجد تنزيلات متاحة", + "play_time": "لعب لمدة {{amount}}", + "last_time_played": "آخر تشغيل {{period}}", + "not_played_yet": "لم تلعب {{title}} بعد", + "next_suggestion": "الاقتراح التالي", + "play": "تشغيل", + "deleting": "جارٍ حذف المثبت...", + "close": "إغلاق", + "playing_now": "يتم التشغيل الآن", + "change": "تغيير", + "repacks_modal_description": "اختر الحزمة المعاد تعبئتها التي تريد تنزيلها", + "select_folder_hint": "لتغيير المجلد الافتراضي، انتقل إلى <0>الإعدادات", + "download_now": "تنزيل الآن", + "no_shop_details": "تعذر الحصول على تفاصيل المتجر.", + "download_options": "خيارات التنزيل", + "download_path": "مسار التنزيل", + "previous_screenshot": "لقطة الشاشة السابقة", + "next_screenshot": "لقطة الشاشة التالية", + "screenshot": "لقطة الشاشة {{number}}", + "open_screenshot": "فتح لقطة الشاشة {{number}}", + "download_settings": "إعدادات التنزيل", + "downloader": "أداة التنزيل", + "select_executable": "تحديد", + "no_executable_selected": "لم يتم تحديد ملف تشغيل", + "open_folder": "فتح المجلد", + "open_download_location": "عرض الملفات المحملة", + "create_shortcut": "إنشاء اختصار على سطح المكتب", + "clear": "مسح", + "remove_files": "إزالة الملفات", + "remove_from_library_title": "هل أنت متأكد؟", + "remove_from_library_description": "سيؤدي هذا إلى إزالة {{game}} من مكتبتك", + "options": "خيارات", + "executable_section_title": "ملف التشغيل", + "executable_section_description": "مسار الملف الذي سيتم تشغيله عند النقر على \"تشغيل\"", + "downloads_secion_title": "التنزيلات", + "downloads_section_description": "تحقق من التحديثات أو الإصدارات الأخرى لهذه اللعبة", + "danger_zone_section_title": "منطقة الخطر", + "danger_zone_section_description": "إزالة هذه اللعبة من مكتبتك أو الملفات التي تم تنزيلها بواسطة Hydra", + "download_in_progress": "تنزيل قيد التقدم", + "download_paused": "التنزيل معلق", + "last_downloaded_option": "خيار التنزيل الأخير", + "create_shortcut_success": "تم إنشاء الاختصار بنجاح", + "create_shortcut_error": "خطأ في إنشاء الاختصار", + "nsfw_content_title": "هذه اللعبة تحتوي على محتوى غير لائق", + "nsfw_content_description": "{{title}} يحتوي على محتوى قد لا يناسب جميع الأعمار. هل تريد المتابعة؟", + "allow_nsfw_content": "متابعة", + "refuse_nsfw_content": "رجوع", + "stats": "الإحصائيات", + "download_count": "مرات التنزيل", + "player_count": "اللاعبون النشطون", + "download_error": "خيار التنزيل هذا غير متاح", + "download": "تنزيل", + "executable_path_in_use": "مسار التشغيل مستخدم بالفعل بواسطة \"{{game}}\"", + "warning": "تحذير:", + "hydra_needs_to_remain_open": "لهذا التنزيل، يجب أن يبقى Hydra مفتوحًا حتى اكتماله. إذا أغلق Hydra قبل الاكتمال، ستفقد تقدمك.", + "achievements": "الإنجازات", + "achievements_count": "الإنجازات {{unlockedCount}}/{{achievementsCount}}", + "cloud_save": "حفظ سحابي", + "cloud_save_description": "احفظ تقدمك على السحابة واستمر في اللعب من أي جهاز", + "backups": "النسخ الاحتياطية", + "install_backup": "تثبيت", + "delete_backup": "حذف", + "create_backup": "نسخة احتياطية جديدة", + "last_backup_date": "آخر نسخة احتياطية في {{date}}", + "no_backup_preview": "لم يتم العثور على حفظات لهذا العنوان", + "restoring_backup": "جارٍ استعادة النسخة الاحتياطية ({{progress}} اكتمال)...", + "uploading_backup": "جارٍ رفع النسخة الاحتياطية...", + "no_backups": "لم تقم بإنشاء أي نسخ احتياطية لهذه اللعبة بعد", + "backup_uploaded": "تم رفع النسخة الاحتياطية", + "backup_deleted": "تم حذف النسخة الاحتياطية", + "backup_restored": "تم استعادة النسخة الاحتياطية", + "see_all_achievements": "عرض جميع الإنجازات", + "sign_in_to_see_achievements": "سجل الدخول لعرض الإنجازات", + "mapping_method_automatic": "تلقائي", + "mapping_method_manual": "يدوي", + "mapping_method_label": "طريقة التعيين", + "files_automatically_mapped": "تم تعيين الملفات تلقائيًا", + "no_backups_created": "لم يتم إنشاء نسخ احتياطية لهذه اللعبة", + "manage_files": "إدارة الملفات", + "loading_save_preview": "جارٍ البحث عن حفظات الألعاب...", + "wine_prefix": "بادئة Wine", + "wine_prefix_description": "بادئة Wine المستخدمة لتشغيل هذه اللعبة", + "launch_options": "خيارات التشغيل", + "launch_options_description": "يمكن للمستخدمين المتقدمين إدخال تعديلات على خيارات التشغيل (ميزة تجريبية)", + "launch_options_placeholder": "لم يتم تحديد أي معاملات", + "no_download_option_info": "لا توجد معلومات متاحة", + "backup_deletion_failed": "فشل حذف النسخة الاحتياطية", + "max_number_of_artifacts_reached": "تم الوصول إلى الحد الأقصى لعدد النسخ الاحتياطية لهذه اللعبة", + "achievements_not_sync": "تعرف على كيفية مزامنة إنجازاتك", + "manage_files_description": "إدارة الملفات التي سيتم نسخها احتياطيًا واستعادتها", + "select_folder": "حدد المجلد", + "backup_from": "نسخة احتياطية من {{date}}", + "custom_backup_location_set": "تم تعيين موقع نسخ احتياطي مخصص", + "no_directory_selected": "لم يتم تحديد مجلد", + "no_write_permission": "لا يمكن التنزيل إلى هذا المجلد. انقر هنا لمعرفة المزيد.", + "reset_achievements": "إعادة تعيين الإنجازات", + "reset_achievements_description": "سيؤدي هذا إلى إعادة تعيين جميع إنجازات {{game}}", + "reset_achievements_title": "هل أنت متأكد؟", + "reset_achievements_success": "تم إعادة تعيين الإنجازات بنجاح", + "reset_achievements_error": "فشل إعادة تعيين الإنجازات" }, "activation": { - "title": "تَفْعِيلُ Hydra", - "installation_id": "مُعَرِّفُ التَّثْبِيتِ:", - "enter_activation_code": "أَدْخِلْ رَمْزَ التَّفْعِيلِ", - "message": "إِذَا كُنْتَ لَا تَعْرِفُ أَيْنَ تَطْلُبُ هَذَا، فَلا يَجِبُ أَنْ تَكُونَ لَدَيْكَ.", - "activate": "تَفْعِيلٌ", - "loading": "جَارٍ التَّحْمِيلُ..." + "title": "تفعيل Hydra", + "installation_id": "معرف التثبيت:", + "enter_activation_code": "أدخل رمز التفعيل الخاص بك", + "message": "إذا كنت لا تعرف أين تطلب هذا، فلا يجب أن يكون لديك هذا.", + "activate": "تفعيل", + "loading": "جارٍ التحميل..." }, "downloads": { - "resume": "اسْتِئْنَافٌ", - "pause": "إِيقَافٌ", - "eta": "الِاكْتِمَالُ {{eta}}", - "paused": "مُوْقَفٌ", - "verifying": "جَارٍ التَّحَقُّقُ...", - "completed": "مَكْتُومٌ", - "removed": "لَمْ يُنَزَّلْ", - "cancel": "إِلْغَاءٌ", - "filter": "تَصْفِيَةُ الْأَلْعَابِ الْمُنَزَّلَةِ", - "remove": "إِزَالَةٌ", - "downloading_metadata": "جَارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ...", - "deleting": "جَارٍ حَذْفُ الْمُثَبِّتِ...", - "delete": "حَذْفُ الْمُثَبِّتِ", - "delete_modal_title": "هَلْ أَنْتَ مُتَأَكِّدٌ؟", - "delete_modal_description": "سَيُؤَدِّي هَذَا إِلَى إِزَالَةِ جَمِيعِ مَلَفَّاتِ التَّثْبِيتِ مِنْ حَاسُوبِكَ", - "install": "تَثْبِيتٌ", - "download_in_progress": "جَارٍ التَّنْفِيذُ", - "queued_downloads": "التَّنْزِيلَاتُ فِي الْانْتِظَارِ", - "downloads_completed": "مَكْتُومٌ", - "queued": "فِي الْانْتِظَارِ", - "no_downloads_title": "فَرَاغٌ تَامٌ", - "no_downloads_description": "لَمْ تَقُمْ بِتَنْزِيلِ أَيِّ شَيْءٍ بِاسْتِخْدَامِ Hydra بَعْدُ، لَكِنَّهُ لَيْسَ مُتَأَخِّرًا لِلْبَدْءِ.", - "checking_files": "جَارٍ التَّحَقُّقُ مِنَ الْمَلَفَّاتِ...", - "seeding": "الْبَذْرُ", - "stop_seeding": "إِيقَافُ الْبَذْرِ", - "resume_seeding": "اسْتِئْنَافُ الْبَذْرِ", - "options": "إِدَارَةٌ" + "resume": "استئناف", + "pause": "إيقاف مؤقت", + "eta": "الانتهاء {{eta}}", + "paused": "معلّق", + "verifying": "جارٍ التحقق...", + "completed": "مكتمل", + "removed": "غير محمل", + "cancel": "إلغاء", + "filter": "تصفية الألعاب المحملة", + "remove": "إزالة", + "downloading_metadata": "جارٍ تنزيل البيانات الوصفية...", + "deleting": "جارٍ حذف المثبت...", + "delete": "إزالة المثبت", + "delete_modal_title": "هل أنت متأكد؟", + "delete_modal_description": "سيؤدي هذا إلى إزالة جميع ملفات التثبيت من جهازك", + "install": "تثبيت", + "download_in_progress": "قيد التقدم", + "queued_downloads": "التنزيلات في قائمة الانتظار", + "downloads_completed": "مكتمل", + "queued": "في قائمة الانتظار", + "no_downloads_title": "فارغ جدًا", + "no_downloads_description": "لم تقم بتنزيل أي شيء باستخدام Hydra بعد، ولكن لم يفت الأوان للبدء.", + "checking_files": "جارٍ فحص الملفات...", + "seeding": "التوزيع", + "stop_seeding": "إيقاف التوزيع", + "resume_seeding": "استئناف التوزيع", + "options": "إدارة" }, "settings": { - "downloads_path": "مَسَارُ التَّنْزِيلَاتِ", - "change": "تَحْدِيثٌ", - "notifications": "الإِشْعَارَاتُ", - "enable_download_notifications": "عِنْدَ اكْتِمَالِ التَّنْزِيلِ", - "enable_repack_list_notifications": "عِنْدَ إِضَافَةِ إِصْدَارٍ مُعَادٍ تَغْلِيفِهِ جَدِيدٍ", - "real_debrid_api_token_label": "رَمْزُ واجهة برمجة التطبيقات Real-Debrid", - "quit_app_instead_hiding": "لا تُخْفِ Hydra عِنْدَ الإِغْلَاقِ", - "launch_with_system": "تَشْغِيلُ Hydra عِنْدَ بَدْءِ النِّظَامِ", - "general": "عَامٌ", - "behavior": "سُلُوكٌ", - "download_sources": "مَصَادِرُ التَّنْزِيلِ", - "language": "اللُّغَةُ", - "real_debrid_api_token": "رَمْزُ واجهة برمجة التطبيقات", - "enable_real_debrid": "تَمْكِينُ Real-Debrid", - "real_debrid_description": "Real-Debrid هُوَ مُنَزِّلٌ غَيْرُ مَقْيُودٍ يَتِيحُ لَكَ تَنْزِيلَ الْمَلَفَّاتِ بِسُرْعَةٍ، مَحْدُودٌ فَقَطْ بِسُرْعَةِ الْإِنْتَرْنِتِ لَدَيْكَ.", - "real_debrid_invalid_token": "رَمْزُ واجهة برمجة التطبيقات غَيْرُ صَالِحٍ", - "real_debrid_api_token_hint": "يُمْكِنُكَ الْحُصُولُ عَلَى رَمْزِ واجهة برمجة التطبيقات <0>هُنَا", - "real_debrid_free_account_error": "الْحِسَابُ \"{{username}}\" هُوَ حِسَابٌ مَجَّانِيٌّ. يَرْجَى الِاشْتِرَاكُ فِي Real-Debrid", - "real_debrid_linked_message": "تَمَّ رَبْطُ الْحِسَابِ \"{{username}}\"", - "save_changes": "حِفْظُ التَّغْيِيرَاتِ", - "changes_saved": "تَمَّ حِفْظُ التَّغْيِيرَاتِ بِنَجَاحٍ", - "download_sources_description": "سَتَقُومُ Hydra بِجَلْبِ رَوَابِطِ التَّنْزِيلِ مِنْ هَذِهِ الْمَصَادِرِ. يَجِبُ أَنْ يَكُونَ عُنْوَانُ URL لِلْمَصْدَرِ رَابِطًا مُبَاشِرًا إِلَى مِلَفٍّ .json يَحْتَوِي عَلَى رَوَابِطِ التَّنْزِيلِ.", - "validate_download_source": "تَصْدِيقٌ", - "remove_download_source": "إِزَالَةٌ", + "downloads_path": "مسار التنزيلات", + "change": "تحديث", + "notifications": "الإشعارات", + "enable_download_notifications": "عند اكتمال التنزيل", + "enable_repack_list_notifications": "عند إضافة حزمة معاد تعبئتها جديدة", + "real_debrid_api_token_label": "رمز واجهة برمجة تطبيقات Real-Debrid", + "quit_app_instead_hiding": "لا تخفي Hydra عند الإغلاق", + "launch_with_system": "تشغيل Hydra مع بدء النظام", + "general": "عام", + "behavior": "السلوك", + "download_sources": "مصادر التنزيل", + "language": "اللغة", + "api_token": "رمز API", + "enable_real_debrid": "تفعيل Real-Debrid", + "real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، مقيدة فقط بسرعة الإنترنت لديك.", + "debrid_invalid_token": "رمز API غير صالح", + "debrid_api_token_hint": "يمكنك الحصول على رمز API الخاص بك <0>هنا", + "real_debrid_free_account_error": "الحساب \"{{username}}\" هو حساب مجاني. يرجى الاشتراك في Real-Debrid", + "debrid_linked_message": "تم ربط الحساب \"{{username}}\"", + "save_changes": "حفظ التغييرات", + "changes_saved": "تم حفظ التغييرات بنجاح", + "download_sources_description": "سيقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL المصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.", + "validate_download_source": "تحقق", + "remove_download_source": "إزالة", "removed_download_sources": "تمت إزالة مصادر التنزيل", "cancel_button_confirmation_delete_all_sources": "لا", "confirm_button_confirmation_delete_all_sources": "نعم، احذف كل شيء", "description_confirmation_delete_all_sources": "سوف تقوم بحذف جميع مصادر التنزيل", "title_confirmation_delete_all_sources": "احذف جميع مصادر التنزيل", "button_delete_all_sources": "قم بإزالة جميع مصادر التنزيل", - "add_download_source": "إِضَافَةُ مَصْدَرٍ", - "download_count_zero": "لَا تَوْجَدُ خِيَارَاتُ تَنْزِيلٍ", - "download_count_one": "{{countFormatted}} خِيَارُ تَنْزِيلٍ", - "download_count_other": "{{countFormatted}} خِيَارَاتُ تَنْزِيلٍ", - "download_source_url": "عُنْوَانُ مَصْدَرِ التَّنْزِيلِ", - "add_download_source_description": "أَدْخِلْ عُنْوَانَ URL لِمِلَفٍّ .json", - "download_source_up_to_date": "مُحَدَّثٌ", - "download_source_errored": "خَطَأٌ", - "sync_download_sources": "مَزْجُ الْمَصَادِرِ", - "removed_download_source": "تَمَّ إِزَالَةُ مَصْدَرِ التَّنْزِيلِ", - "added_download_source": "تَمَّتْ إِضَافَةُ مَصْدَرِ التَّنْزِيلِ", - "download_sources_synced": "تَمَّ مَزْجُ جَمِيعِ مَصَادِرِ التَّنْزِيلِ", - "insert_valid_json_url": "أَدْخِلْ عُنْوَانَ JSON صَالِحًا", - "found_download_option_zero": "لَمْ يُعْثَرْ عَلَى خِيَارِ تَنْزِيلٍ", - "found_download_option_one": "عُثِرَ عَلَى {{countFormatted}} خِيَارِ تَنْزِيلٍ", - "found_download_option_other": "عُثِرَ عَلَى {{countFormatted}} خِيَارَاتِ تَنْزِيلٍ", - "import": "اسْتِيرَادٌ", - "public": "عَامٌ", - "private": "خَاصٌ", - "friends_only": "الْأَصْدِقَاءُ فَقَطْ", - "privacy": "الْخُصُوصِيَّةُ", - "profile_visibility": "رُؤْيَةُ الْمَلَفِّ الشَّخْصِيِّ", - "profile_visibility_description": "اخْتَرْ مَنْ يُمْكِنُهُ رُؤْيَةُ مَلَفِّكَ الشَّخْصِيِّ وَمَكْتَبَتِكَ", - "required_field": "هَذَا الْحَقْلُ مَطْلُوبٌ", - "source_already_exists": "تَمَّتْ إِضَافَةُ هَذَا الْمَصْدَرِ مِنْ قَبْلُ", - "must_be_valid_url": "يَجِبُ أَنْ يَكُونَ الْمَصْدَرُ عُنْوَانَ URL صَالِحًا", - "blocked_users": "الْمُسْتَخْدِمُونَ الْمَحْظُورُونَ", - "user_unblocked": "تَمَّ إِزَالَةُ حَظْرِ الْمُسْتَخْدِمِ", - "enable_achievement_notifications": "عِنْدَ فَتْحِ إِنْجَازٍ", - "launch_minimized": "تَشْغِيلُ Hydra مُصَغَّرًا", - "disable_nsfw_alert": "تَعْطِيلُ تَنْبِيهِ الْمُحْتَوَى غَيْرِ اللَّائِقِ", - "seed_after_download_complete": "الْبَذْرُ بَعْدَ اكْتِمَالِ التَّنْزِيلِ", - "show_hidden_achievement_description": "إِظْهَارُ وَصْفِ الإِنْجَازَاتِ الْمَخْفِيَّةِ قَبْلَ فَتْحِهَا" + "add_download_source": "إضافة مصدر", + "download_count_zero": "لا توجد خيارات تنزيل", + "download_count_one": "{{countFormatted}} خيار تنزيل", + "download_count_other": "{{countFormatted}} خيارات تنزيل", + "download_source_url": "عنوان URL لمصدر التنزيل", + "add_download_source_description": "أدخل عنوان URL لملف .json", + "download_source_up_to_date": "محدث", + "download_source_errored": "خطأ", + "sync_download_sources": "مزامنة المصادر", + "removed_download_source": "تمت إزالة مصدر التنزيل", + "added_download_source": "تمت إضافة مصدر التنزيل", + "download_sources_synced": "تمت مزامنة جميع مصادر التنزيل", + "insert_valid_json_url": "أدخل عنوان JSON صالح", + "found_download_option_zero": "لم يتم العثور على خيارات تنزيل", + "found_download_option_one": "تم العثور على {{countFormatted}} خيار تنزيل", + "found_download_option_other": "تم العثور على {{countFormatted}} خيارات تنزيل", + "import": "استيراد", + "public": "عام", + "private": "خاص", + "friends_only": "الأصدقاء فقط", + "privacy": "الخصوصية", + "profile_visibility": "رؤية الملف الشخصي", + "profile_visibility_description": "اختر من يمكنه رؤية ملفك الشخصي ومكتبتك", + "required_field": "هذا الحقل مطلوب", + "source_already_exists": "تمت إضافة هذا المصدر مسبقًا", + "must_be_valid_url": "يجب أن يكون المصدر عنوان URL صالحًا", + "blocked_users": "المستخدمون المحظورون", + "user_unblocked": "تم إلغاء حظر المستخدم", + "enable_achievement_notifications": "عند فتح إنجاز", + "launch_minimized": "تشغيل Hydra مصغرًا", + "disable_nsfw_alert": "تعطيل تنبيه المحتوى غير اللائق", + "seed_after_download_complete": "التوزيع بعد اكتمال التنزيل", + "show_hidden_achievement_description": "عرض وصف الإنجازات المخفية قبل فتحها", + "account": "الحساب", + "no_users_blocked": "لا يوجد مستخدمون محظورون", + "subscription_active_until": "اشتراك Hydra Cloud نشط حتى {{date}}", + "manage_subscription": "إدارة الاشتراك", + "update_email": "تحديث البريد الإلكتروني", + "update_password": "تحديث كلمة المرور", + "current_email": "البريد الإلكتروني الحالي:", + "no_email_account": "لم تقم بتعيين بريد إلكتروني بعد", + "account_data_updated_successfully": "تم تحديث بيانات الحساب بنجاح", + "renew_subscription": "تجديد اشتراك Hydra Cloud", + "subscription_expired_at": "انتهى اشتراكك في {{date}}", + "no_subscription": "استمتع بـ Hydra بأفضل طريقة ممكنة", + "become_subscriber": "كن مشتركًا في Hydra Cloud", + "subscription_renew_cancelled": "تم تعطيل التجديد التلقائي", + "subscription_renews_on": "سيتم تجديد اشتراكك في {{date}}", + "bill_sent_until": "سيتم إرسال فاتورتك التالية حتى هذا اليوم" }, "notifications": { - "download_complete": "اكْتِمَالُ التَّنْزِيلِ", - "game_ready_to_install": "{{title}} جَاهِزٌ لِلتَّثْبِيتِ", - "repack_list_updated": "تَمَّ تَحْدِيثُ قَائِمَةِ الإِصْدَارَاتِ الْمُعَادَةِ تَغْلِيفُهَا", - "repack_count_one": "{{count}} إِصْدَارٌ مُعَادٌ تَغْلِيفُهُ أُضِيفَ", - "repack_count_other": "{{count}} إِصْدَارَاتٌ مُعَادَةٌ تَغْلِيفُهَا أُضِيفَتْ", - "new_update_available": "الْإِصْدَارُ {{version}} مَتَوَفِّرٌ", - "restart_to_install_update": "أَعِدْ تَشْغِيلَ Hydra لِتَثْبِيتِ التَّحْدِيثِ", - "notification_achievement_unlocked_title": "تَمَّ فَتْحُ إِنْجَازٍ لِـ {{game}}", - "notification_achievement_unlocked_body": "{{achievement}} وَ{{count}} أُخْرَى تَمَّ فَتْحُهَا" + "download_complete": "اكتمل التنزيل", + "game_ready_to_install": "{{title}} جاهز للتثبيت", + "repack_list_updated": "تم تحديث قائمة الحزم المعاد تعبئتها", + "repack_count_one": "تمت إضافة {{count}} حزمة معاد تعبئتها", + "repack_count_other": "تمت إضافة {{count}} حزم معاد تعبئتها", + "new_update_available": "الإصدار {{version}} متوفر", + "restart_to_install_update": "أعد تشغيل Hydra لتثبيت التحديث", + "notification_achievement_unlocked_title": "تم فتح إنجاز لـ {{game}}", + "notification_achievement_unlocked_body": "{{achievement}} و {{count}} آخرين تم فتحهم" }, "system_tray": { - "open": "فَتْحُ Hydra", - "quit": "الْخُرُوجُ" + "open": "فتح Hydra", + "quit": "خروج" }, "game_card": { - "no_downloads": "لَا تَوْجَدُ تَنْزِيلَاتٌ مَتَوَفِّرَةٌ" + "no_downloads": "لا توجد تنزيلات متاحة" }, "binary_not_found_modal": { - "title": "الْبَرَامِجُ غَيْرُ مُثَبَّتَةٍ", - "description": "لَمْ يُعْثَرْ عَلَى مَلَفَّاتٍ تَنْفِيذِيَّةٍ لِـ Wine أَوْ Lutris عَلَى نِظَامِكَ", - "instructions": "تَحَقَّقْ مِنَ الطَّرِيقَةِ الصَّحِيحَةِ لِتَثْبِيتِ أَيٍّ مِنْهُمَا عَلَى تَوْزِيعَةِ Linux لَدَيْكَ لِتَعْمَلَ اللُّعْبَةُ بِشَكْلٍ طَبِيعِيٍّ" + "title": "البرامج غير مثبتة", + "description": "لم يتم العثور على ملفات تشغيل Wine أو Lutris على نظامك", + "instructions": "تحقق من الطريقة الصحيحة لتثبيت أي منها على توزيعة لينكس الخاصة بك حتى تعمل اللعبة بشكل طبيعي" }, "modal": { - "close": "زِرُّ الإِغْلَاقِ" + "close": "زر الإغلاق" }, "forms": { - "toggle_password_visibility": "تَبْدِيلُ رُؤْيَةِ كَلِمَةِ الْمَرُورِ" + "toggle_password_visibility": "تبديل رؤية كلمة المرور" }, "user_profile": { - "amount_hours": "{{amount}} سَاعَاتٌ", - "amount_minutes": "{{amount}} دَقَائِقُ", - "last_time_played": "آخِرُ مَرَّةٍ لُعِبَتْ {{period}}", - "activity": "النَّشَاطُ الْأَخِيرُ", - "library": "الْمَكْتَبَةُ", - "total_play_time": "إِجْمَالِيُّ وَقْتِ اللَّعِبِ", - "no_recent_activity_title": "هَمَمْ... لَا شَيْءَ هُنَا", - "no_recent_activity_description": "لَمْ تَلْعَبْ أَيَّ أَلْعَابٍ مُؤَخَّرًا. حَانَ الْوَقْتُ لِتَغْيِيرِ ذَلِكَ!", - "display_name": "اسْمُ الْعَرْضِ", - "saving": "جَارٍ الْحِفْظُ", - "save": "حِفْظٌ", - "edit_profile": "تَحْرِيرُ الْمَلَفِّ الشَّخْصِيِّ", - "saved_successfully": "تَمَّ الْحِفْظُ بِنَجَاحٍ", - "try_again": "الرَّجَاءُ الْمُحَاوَلَةُ مَرَّةً أُخْرَى", - "sign_out_modal_title": "هَلْ أَنْتَ مُتَأَكِّدٌ؟", - "cancel": "إِلْغَاءٌ", - "successfully_signed_out": "تَمَّ تَسْجِيلُ الْخُرُوجِ بِنَجَاحٍ", - "sign_out": "تَسْجِيلُ الْخُرُوجِ", - "playing_for": "جَارِي اللَّعِبُ لِمُدَّةِ {{amount}}", - "sign_out_modal_text": "مَكْتَبَتُكَ مُرْتَبِطَةٌ بِحِسَابِكَ الْحَالِيِّ. عِنْدَ تَسْجِيلِ الْخُرُوجِ، لَنْ تَكُونَ مَكْتَبَتُكَ مَرْئِيَّةً بَعْدَ الْآنِ، وَلَنْ يَتِمَّ حِفْظُ أَيِّ تَقَدُّمٍ. هَلْ تُرِيدُ الْمُتَابَعَةَ مَعَ تَسْجِيلِ الْخُرُوجِ؟", - "add_friends": "إِضَافَةُ الْأَصْدِقَاءِ", - "add": "إِضَافَةٌ", - "friend_code": "رَمْزُ الصَّدِيقِ", - "see_profile": "رُؤْيَةُ الْمَلَفِّ الشَّخْصِيِّ", - "sending": "جَارٍ الْإِرْسَالُ", - "friend_request_sent": "تَمَّ إِرْسَالُ طَلَبِ الصَّدَاقَةِ", - "friends": "الْأَصْدِقَاءُ", - "friends_list": "قَائِمَةُ الْأَصْدِقَاءِ", - "user_not_found": "الْمُسْتَخْدِمُ غَيْرُ مَوْجُودٍ", - "block_user": "حَظْرُ الْمُسْتَخْدِمِ", - "add_friend": "إِضَافَةُ صَدِيقٍ", - "request_sent": "تَمَّ إِرْسَالُ الطَّلَبِ", - "request_received": "تَمَّ اسْتِقْبَالُ الطَّلَبِ", - "accept_request": "قَبُولُ الطَّلَبِ", - "ignore_request": "تَجَاهُلُ الطَّلَبِ", - "cancel_request": "إِلْغَاءُ الطَّلَبِ", - "undo_friendship": "إِلْغَاءُ الصَّدَاقَةِ", - "request_accepted": "تَمَّ قَبُولُ الطَّلَبِ", - "user_blocked_successfully": "تَمَّ حَظْرُ الْمُسْتَخْدِمِ بِنَجَاحٍ", - "user_block_modal_text": "سَيُؤَدِّي هَذَا إِلَى حَظْرِ {{displayName}}", - "blocked_users": "الْمُسْتَخْدِمُونَ الْمَحْظُورُونَ", - "unblock": "إِزَالَةُ الْحَظْرِ", - "no_friends_added": "لَيْسَ لَدَيْكَ أَصْدِقَاءٌ مُضَافُونَ", - "pending": "قَيْدُ الْانْتِظَارِ", - "no_pending_invites": "لَيْسَ لَدَيْكَ دَعَوَاتٌ قَيْدُ الْانْتِظَارِ", - "no_blocked_users": "لَيْسَ لَدَيْكَ مُسْتَخْدِمُونَ مَحْظُورُونَ", - "friend_code_copied": "تَمَّ نَسْخُ رَمْزِ الصَّدِيقِ", - "undo_friendship_modal_text": "سَيُؤَدِّي هَذَا إِلَى إِلْغَاءِ صَدَاقَتِكَ مَعَ {{displayName}}", - "privacy_hint": "لِتَعْدِيلِ مَنْ يُمْكِنُهُ رُؤْيَةُ هَذَا، اذْهَبْ إِلَى <0>الإعْدَادَاتِ", - "locked_profile": "هَذَا الْمَلَفُّ الشَّخْصِيُّ خَاصٌّ", - "image_process_failure": "فَشَلَ أَثْنَاءَ مُعَالَجَةِ الصُّورَةِ", - "required_field": "هَذَا الْحَقْلُ مَطْلُوبٌ", - "displayname_min_length": "يَجِبُ أَنْ يَكُونَ اسْمُ الْعَرْضِ عَلَى الْأَقَلِّ 3 أَحْرُفٍ", - "displayname_max_length": "يَجِبُ أَنْ يَكُونَ اسْمُ الْعَرْضِ عَلَى الْأَكْثَرِ 50 حَرْفًا", - "report_profile": "تَقْرِيرٌ عَنْ هَذَا الْمَلَفِّ الشَّخْصِيِّ", - "report_reason": "لِمَاذَا تُقَدِّمُ تَقْرِيرًا عَنْ هَذَا الْمَلَفِّ الشَّخْصِيِّ؟", - "report_description": "مَعْلُومَاتٌ إِضَافِيَّةٌ", - "report_description_placeholder": "مَعْلُومَاتٌ إِضَافِيَّةٌ", - "report": "تَقْرِيرٌ", - "report_reason_hate": "خِطَابُ الْكُرْهِ", - "report_reason_sexual_content": "مُحْتَوًى جِنْسِيٌّ", - "report_reason_violence": "عُنْفٌ", - "report_reason_spam": "رَاسِلَةٌ عَشْوَائِيَّةٌ", - "report_reason_other": "آخَرُ", - "profile_reported": "تَمَّ تَقْرِيرُ الْمَلَفِّ الشَّخْصِيِّ", - "your_friend_code": "رَمْزُ صَدِيقِكَ:", - "upload_banner": "رَفْعُ لَافِتَةٍ", - "uploading_banner": "جَارٍ رَفْعُ اللَّافِتَةِ...", - "background_image_updated": "تَمَّ تَحْدِيثُ صُورَةِ الْخَلْفِيَّةِ", - "stats": "الإحْصَائِيَّاتُ", - "achievements": "الإِنْجَازَاتُ", - "games": "الْأَلْعَابُ", - "top_percentile": "الْأَفْضَلُ {{percentile}}%", - "ranking_updated_weekly": "التَّرْتِيبُ يُحَدَّثُ أُسْبُوعِيًّا", - "playing": "جَارِي اللَّعِبُ {{game}}", - "achievements_unlocked": "الإِنْجَازَاتُ الْمَفْتُوحَةُ", - "earned_points": "النَّقَاطُ الْمَكْسُوبَةُ", - "show_achievements_on_profile": "عَرْضُ إِنْجَازَاتِكَ عَلَى مَلَفِّكَ الشَّخْصِيِّ", - "show_points_on_profile": "عَرْضُ النَّقَاطِ الْمَكْسُوبَةِ عَلَى مَلَفِّكَ الشَّخْصِيِّ" + "amount_hours": "{{amount}} ساعات", + "amount_minutes": "{{amount}} دقائق", + "last_time_played": "آخر تشغيل {{period}}", + "activity": "النشاط الأخير", + "library": "المكتبة", + "total_play_time": "إجمالي وقت اللعب", + "no_recent_activity_title": "همم... لا شيء هنا", + "no_recent_activity_description": "لم تلعب أي ألعاب مؤخرًا. حان الوقت لتغيير ذلك!", + "display_name": "اسم العرض", + "saving": "جارٍ الحفظ", + "save": "حفظ", + "edit_profile": "تعديل الملف الشخصي", + "saved_successfully": "تم الحفظ بنجاح", + "try_again": "يرجى المحاولة مرة أخرى", + "sign_out_modal_title": "هل أنت متأكد؟", + "cancel": "إلغاء", + "successfully_signed_out": "تم تسجيل الخروج بنجاح", + "sign_out": "تسجيل الخروج", + "playing_for": "يلعب لمدة {{amount}}", + "sign_out_modal_text": "مكتبتك مرتبطة بحسابك الحالي. عند تسجيل الخروج، لن تكون مكتبتك مرئية بعد الآن، ولن يتم حفظ أي تقدم. هل تتابع تسجيل الخروج؟", + "add_friends": "إضافة أصدقاء", + "add": "إضافة", + "friend_code": "رمز الصديق", + "see_profile": "عرض الملف الشخصي", + "sending": "جارٍ الإرسال", + "friend_request_sent": "تم إرسال طلب الصداقة", + "friends": "الأصدقاء", + "friends_list": "قائمة الأصدقاء", + "user_not_found": "المستخدم غير موجود", + "block_user": "حظر المستخدم", + "add_friend": "إضافة صديق", + "request_sent": "تم إرسال الطلب", + "request_received": "تم استلام الطلب", + "accept_request": "قبول الطلب", + "ignore_request": "تجاهل الطلب", + "cancel_request": "إلغاء الطلب", + "undo_friendship": "إلغاء الصداقة", + "request_accepted": "تم قبول الطلب", + "user_blocked_successfully": "تم حظر المستخدم بنجاح", + "user_block_modal_text": "سيؤدي هذا إلى حظر {{displayName}}", + "blocked_users": "المستخدمون المحظورون", + "unblock": "إلغاء الحظر", + "no_friends_added": "ليس لديك أصدقاء مضافون", + "pending": "قيد الانتظار", + "no_pending_invites": "ليس لديك دعوات معلقة", + "no_blocked_users": "ليس لديك مستخدمون محظورون", + "friend_code_copied": "تم نسخ رمز الصديق", + "undo_friendship_modal_text": "سيؤدي هذا إلى إلغاء صداقتك مع {{displayName}}", + "privacy_hint": "لضبط من يمكنه رؤية هذا، انتقل إلى <0>الإعدادات", + "locked_profile": "هذا الملف الشخصي خاص", + "image_process_failure": "فشل معالجة الصورة", + "required_field": "هذا الحقل مطلوب", + "displayname_min_length": "يجب أن يكون اسم العرض على الأقل 3 أحرف", + "displayname_max_length": "يجب ألا يتجاوز اسم العرض 50 حرفًا", + "report_profile": "الإبلاغ عن هذا الملف الشخصي", + "report_reason": "لماذا تقوم بالإبلاغ عن هذا الملف الشخصي؟", + "report_description": "معلومات إضافية", + "report_description_placeholder": "معلومات إضافية", + "report": "الإبلاغ", + "report_reason_hate": "خطاب كراهية", + "report_reason_sexual_content": "محتوى جنسي", + "report_reason_violence": "عنف", + "report_reason_spam": "بريد عشوائي", + "report_reason_other": "أخرى", + "profile_reported": "تم الإبلاغ عن الملف الشخصي", + "your_friend_code": "رمز صديقك:", + "upload_banner": "تحميل بانر", + "uploading_banner": "جارٍ تحميل البانر...", + "background_image_updated": "تم تحديث صورة الخلفية", + "stats": "الإحصائيات", + "achievements": "إنجازات", + "games": "الألعاب", + "top_percentile": "ال{{percentile}}% الأعلى", + "ranking_updated_weekly": "يتم تحديث التصنيف أسبوعيًا", + "playing": "يلعب {{game}}", + "achievements_unlocked": "الإنجازات المفتوحة", + "earned_points": "النقاط المكتسبة", + "show_achievements_on_profile": "عرض إنجازاتك على ملفك الشخصي", + "show_points_on_profile": "عرض نقاطك المكتسبة على ملفك الشخصي" }, "achievement": { - "achievement_unlocked": "إِنْجَازٌ مَفْتُوحٌ", - "user_achievements": "إِنْجَازَاتُ {{displayName}}", - "your_achievements": "إِنْجَازَاتُكَ", - "unlocked_at": "تَمَّ الْفَتْحُ فِي: {{date}}", - "subscription_needed": "يَحْتَاجُ اشْتِرَاكُ Hydra Cloud لِرُؤْيَةِ هَذَا الْمُحْتَوَى", - "new_achievements_unlocked": "تَمَّ فَتْحُ {{achievementCount}} إِنْجَازَاتٍ جَدِيدَةٍ مِنْ {{gameCount}} أَلْعَابٍ", - "achievement_progress": "{{unlockedCount}}/{{totalCount}} إِنْجَازَاتٍ", - "achievements_unlocked_for_game": "تَمَّ فَتْحُ {{achievementCount}} إِنْجَازَاتٍ جَدِيدَةٍ لِـ {{gameTitle}}", - "hidden_achievement_tooltip": "هَذَا إِنْجَازٌ مَخْفِيٌّ", - "achievement_earn_points": "اكْسِبْ {{points}} نَقَاطًا بِهَذَا الإِنْجَازِ", - "earned_points": "النَّقَاطُ الْمَكْسُوبَةُ:", - "available_points": "النَّقَاطُ الْمُتَوَفِّرَةُ:", - "how_to_earn_achievements_points": "كَيْفَ تَكْسِبُ نَقَاطَ الإِنْجَازَاتِ؟" + "achievement_unlocked": "تم فتح الإنجاز", + "user_achievements": "إنجازات {{displayName}}", + "your_achievements": "إنجازاتك", + "unlocked_at": "تم الفتح في: {{date}}", + "subscription_needed": "يحتاج إلى اشتراك Hydra Cloud لرؤية هذا المحتوى", + "new_achievements_unlocked": "تم فتح {{achievementCount}} إنجازات جديدة من {{gameCount}} ألعاب", + "achievement_progress": "{{unlockedCount}}/{{totalCount}} إنجازات", + "achievements_unlocked_for_game": "تم فتح {{achievementCount}} إنجازات جديدة لـ {{gameTitle}}", + "hidden_achievement_tooltip": "هذا إنجاز مخفي", + "achievement_earn_points": "اكسب {{points}} نقطة مع هذا الإنجاز", + "earned_points": "النقاط المكتسبة:", + "available_points": "النقاط المتاحة:", + "how_to_earn_achievements_points": "كيفية كسب نقاط الإنجازات؟" }, "hydra_cloud": { - "subscription_tour_title": "اشْتِرَاكُ Hydra Cloud", - "subscribe_now": "اشْتَرِكِ الْآنَ", - "cloud_saving": "حِفْظٌ سَحَابِيٌّ", - "cloud_achievements": "حِفْظُ إِنْجَازَاتِكَ فِي السَّحَابَةِ", - "animated_profile_picture": "صُورُ الْمَلَفِّ الشَّخْصِيِّ الْمُتَحَرِّكَةِ", - "premium_support": "الدَّعْمُ الْمُتَقَدِّمُ", - "show_and_compare_achievements": "عَرْضٌ وَمُقَارَنَةُ إِنْجَازَاتِكَ مَعَ مُسْتَخْدِمِينَ آخَرِينَ", - "animated_profile_banner": "لَافِتَةُ الْمَلَفِّ الشَّخْصِيِّ الْمُتَحَرِّكَةِ", + "subscription_tour_title": "اشتراك Hydra Cloud", + "subscribe_now": "اشترك الآن", + "cloud_saving": "حفظ سحابي", + "cloud_achievements": "احفظ إنجازاتك على السحابة", + "animated_profile_picture": "صورة ملف شخصي متحركة", + "premium_support": "دعم ممتاز", + "show_and_compare_achievements": "اعرض وقارن إنجازاتك مع المستخدمين الآخرين", + "animated_profile_banner": "بانر ملف شخصي متحرك", "hydra_cloud": "Hydra Cloud", - "hydra_cloud_feature_found": "لَقَدْ اكْتَشَفْتَ مِيزَةً مِنْ Hydra Cloud!", - "learn_more": "تَعَلَّمْ أَكْثَرَ" + "hydra_cloud_feature_found": "لقد اكتشفت ميزة Hydra Cloud!", + "learn_more": "معرفة المزيد" } } diff --git a/src/locales/bg/translation.json b/src/locales/bg/translation.json index b87310197..6d2f7bb72 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 904d2b4aa..25fce6a7b 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 8351709a7..296d4bca3 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 f9b026038..11746a766 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 73adecb1b..1ee2254f1 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 7231a63db..d81753b7c 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.", @@ -302,7 +306,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 2f9694c30..c0ef19199 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", @@ -277,7 +286,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 22b4c7c61..1b5f70d84 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 728d02b8b..ad4ab62dd 100644 --- a/src/locales/fa/translation.json +++ b/src/locales/fa/translation.json @@ -116,7 +116,7 @@ "removed_download_sources": "منابع دانلود حذف شد", "button_delete_all_sources": "تمام منابع دانلود را حذف کنید", "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 89ca891b5..71030f335 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 1b6897344..3b1566a4f 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -124,7 +124,7 @@ "title_confirmation_delete_all_sources": "Elimina tutte le fonti di download", "removed_download_sources": "Fonti di download rimosse", "button_delete_all_sources": "Rimuovi tutte le fonti di download", - "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 09987f30b..d14f957b2 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 4e6cf623c..ff9657eba 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -116,7 +116,7 @@ "removed_download_sources": "제거된 글꼴", "button_delete_all_sources": "모든 다운로드 소스 제거", "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 ad13ef1f4..00f95626f 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 7bce32295..4936e88d6 100644 --- a/src/locales/nl/translation.json +++ b/src/locales/nl/translation.json @@ -117,7 +117,7 @@ "removed_download_sources": "Downloadbronnen verwijderd", "button_delete_all_sources": "Verwijder alle downloadbronnen", "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 38c885fb9..576ebe7d8 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -124,7 +124,7 @@ "title_confirmation_delete_all_sources": "Usuń wszystkie źródła pobierania", "button_delete_all_sources": "Usuń wszystkie źródła pobierania", "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 637c68e0e..87b30e177 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.", @@ -290,7 +295,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 a229efc74..18b913a40 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 7c42dcfcf..46fdc5ee3 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -124,7 +124,7 @@ "general": "General", "behavior": "Comportament", "language": "Limbă", - "real_debrid_api_token": "Token API", + "api_token": "Token API", "cancel_button_confirmation_delete_all_sources": "Nu", "confirm_button_confirmation_delete_all_sources": "Da, șterge totul", "description_confirmation_delete_all_sources": "Veți șterge toate sursele de descărcare", @@ -133,10 +133,10 @@ "button_delete_all_sources": "Eliminați toate sursele de descărcare", "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 65a1171da..493e55642 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 a8edc60a3..47a0b38ea 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 dbd71a3cc..6c853635b 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -180,13 +180,13 @@ "title_confirmation_delete_all_sources": "Видалити всі джерела завантаження", "removed_download_sources": "Джерела завантажень видалено", "button_delete_all_sources": "Видаліть усі джерела завантаження", - "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 523632dad..37ebde11a 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 b98b59352..66bf7af9d 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 51c8522ee..000000000 --- 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 cf618947d..000000000 --- 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 0cb15f6eb..000000000 --- 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 3382da1ca..000000000 --- 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 0fcdcc77d..000000000 --- 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 1625ac8ad..000000000 --- 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 f34e23ecd..000000000 --- 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 a850b42fd..000000000 --- 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 e74ada489..000000000 --- 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 c9dd39cc0..c81e09652 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 6b720015b..2ab5e4581 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 1dcc80f3c..7ea60d0b7 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 08366abc6..39f8425b6 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 acfebfd66..364ceeb90 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 daffa4874..0dc471e37 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 b3a514f5c..0c18d0a67 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 c74f01e70..af896e983 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 f47a2a689..000000000 --- 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 c78a0ede6..0403095ff 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 000000000..89a0c611a --- /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 25882c3fc..570fa378c 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 898c25cde..e27709e97 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 iconUrl = steamGame?.clientIcon - ? steamUrlBuilder.icon(objectId, steamGame.clientIcon) - : null; - - await gameRepository.insert({ - title, - iconUrl, - objectID: objectId, - shop, - }); - } - - const game = await gameRepository.findOne({ - where: { objectID: objectId }, - }); - - updateLocalUnlockedAchivements(game!); - - createGame(game!).catch(() => {}); + const gameKey = levelKeys.game(shop, objectId); + const game = await gamesSublevel.get(gameKey); + + if (game) { + await downloadsSublevel.del(gameKey); + + 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 f69bf1207..d01f3f4fd 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 4e6935f45..6e278871a 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 bdae9b3eb..9c290fe0d 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, - }, - ], - }); - - if (!game) return; - - if (game.folderName) { + const downloadKey = levelKeys.game(shop, objectId); + + const download = await downloadsSublevel.get(downloadKey); + + 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 d68aac699..239bcb8d8 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 ad9823089..86c0fd298 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 09a0837c4..96a993a6c 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 dd7383ad6..b61246fa6 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 b21a6f165..9cf1d978e 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 cf73c8109..64e3d5fbd 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); + + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + + if (!game) return; + + await gamesSublevel.put(gameKey, { + ...game, + executablePath: parsedPath, + launchOptions, + }); - await gameRepository.update( - { id: gameId }, - { executablePath: parsedPath, launchOptions } - ); + if (parsedParams.length === 0) { + shell.openPath(parsedPath); + return; + } - shell.openPath(parsedPath); + 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 a8fc8b017..6a33ffaf8 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); - }); -}; - -const removeRemoveGameFromLibrary = async (gameId: number) => { - const game = await gameRepository.findOne({ where: { id: gameId } }); + if (game) { + await gamesSublevel.put(gameKey, { + ...game, + isDeleted: true, + executablePath: null, + }); - 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 687366c5e..a5310bc98 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 8d52a3a69..b3d2daa26 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 d9f01c084..c085dbada 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 aee807715..e753706b8 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 b33d031c8..3e6c15cf1 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 22295ac7d..a48a0d380 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 ba48f03b8..76316a6ec 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 5230c209a..356b1b161 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 7b90e4833..f5a04f0d0 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 fbdf27615..5d80337ff 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 03bb2781a..e3e14dec9 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 transactionalEntityManager - .getRepository(Game) - .update({ id: gameId }, { status: "paused" }); - }); + await downloadsSublevel.put(gameKey, { + ...download, + status: "paused", + queued: false, + }); + } }; registerEvent("pauseGameDownload", pauseGameDownload); diff --git a/src/main/events/torrenting/pause-game-seed.ts b/src/main/events/torrenting/pause-game-seed.ts index df2af756c..b19da5256 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 c8c755452..48bb1c122 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, - }, - }); - - if (!game) return; - - if (game.status === "paused") { - await dataSource.transaction(async (transactionalEntityManager) => { - await DownloadManager.pauseDownload(); + const gameKey = levelKeys.game(shop, objectId); - await transactionalEntityManager - .getRepository(Game) - .update({ status: "active", progress: Not(1) }, { status: "paused" }); + const download = await downloadsSublevel.get(gameKey); - await DownloadManager.resumeDownload(game); + if (download?.status === "paused") { + await DownloadManager.pauseDownload(); - await transactionalEntityManager - .getRepository(DownloadQueue) - .delete({ game: { id: gameId } }); + for await (const [key, value] of downloadsSublevel.iterator()) { + if (value.status === "active" && value.progress !== 1) { + await downloadsSublevel.put(key, { + ...value, + status: "paused", + }); + } + } - await transactionalEntityManager - .getRepository(DownloadQueue) - .insert({ game: { id: gameId } }); + await DownloadManager.resumeDownload(download); - 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 9f79e53a4..63bab9523 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 de10b07da..8b5f19180 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(); + for await (const [key, value] of downloadsSublevel.iterator()) { + if (value.status === "active" && value.progress !== 1) { + await downloadsSublevel.put(key, { + ...value, + status: "paused", + }); + } + } - await gameRepository.update( - { status: "active", progress: Not(1) }, - { status: "paused" } - ); + const game = await gamesSublevel.get(gameKey); - 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", - }); + /* Delete any previous download */ + await downloadsSublevel.del(gameKey); - const iconUrl = steamGame?.clientIcon - ? steamUrlBuilder.icon(objectId, steamGame.clientIcon) - : null; - - await gameRepository.insert({ - title, - iconUrl, - objectID: objectId, - downloader, - shop, - status: "active", - downloadPath, - uri, - }); - } + if (game?.isDeleted) { + 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 updatedGame = await gameRepository.findOne({ - where: { - objectID: objectId, - }, + await gamesSublevel.put(gameKey, { + title, + iconUrl, + objectId, + shop, + remoteId: null, + playTimeInMilliseconds: 0, + lastTimePlayed: null, + isDeleted: false, }); + } - await DownloadManager.cancelDownload(updatedGame!.id); - await DownloadManager.startDownload(updatedGame!); + await DownloadManager.cancelDownload(gameKey); - await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); - await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); + 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 000000000..87fc4ba82 --- /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 2a2df2541..c67f72b97 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 f45af5193..275a6f276 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(() => {}); + } + + if (preferences.realDebridApiToken) { + preferences.realDebridApiToken = Crypto.encrypt( + preferences.realDebridApiToken + ); } - return userPreferencesRepository.upsert( + 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 0b6652121..697ad7164 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 ffa25399b..6deecbadf 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 9a6f156c2..aefc70522 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 ca49a9fb0..2a18fa313 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 dataSource.initialize(); + await loadState(); - 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 821efc808..57982332c 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 000000000..90a34be35 --- /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 000000000..9819efad0 --- /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 000000000..230306708 --- /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 000000000..4b1fa0c8d --- /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 000000000..8187e5c07 --- /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 000000000..ce7492f1c --- /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 000000000..a96a464ce --- /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 000000000..53eae44b3 --- /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 add619e18..4824a1a57 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"; + +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", + }); + }); -const loadState = async (userPreferences: UserPreferences | null) => { - import("./events"); + 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; + + const downloadsToSeed = downloads.filter( + (download) => + download.shouldSeed && + download.downloader === Downloader.Torrent && + download.progress === 1 && + download.uri !== null + ); - await DownloadManager.startRPC(nextQueueItem?.game, seedList); + 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 6013f714e..000000000 --- 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 18bb9a597..000000000 --- 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 3297eb0db..000000000 --- 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 64fbcd2ef..000000000 --- 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 d23662ed3..000000000 --- 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 791eeb29b..000000000 --- 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 a4f48265e..000000000 --- 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 5f9ecab19..000000000 --- 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 b377c6508..000000000 --- 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 517f6fb52..000000000 --- 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 69ede1897..000000000 --- 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 a248dd2b6..000000000 --- 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 7e90a3b1e..000000000 --- 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 75b94577a..000000000 --- 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 36771c439..000000000 --- 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 417eeb63f..000000000 --- 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 299b3fc20..000000000 --- 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 e0c4204e9..000000000 --- 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 6a1eb11c5..8b076d9e6 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 4fc6a4cd6..7c0660cc3 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 daac7e113..0d0c58f98 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 dd8c877dd..7e6ebf0a0 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 0393477c4..8832a475d 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 000000000..63a50668e --- /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 134a74e60..5c19c1b18 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 3eade81df..8011cae8a 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 8cacdcb7f..0e868318a 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 2c23556fc..5560ad318 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 8cff7bd25..556897cd3 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 000000000..babb7e7d4 --- /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 16bbc21ff..ba972b449 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) { + + return Promise.reject(error as Error); + } + + if (error.request) { const errorData = error.toJSON(); - logger.error("Request error:", errorData.message); - } else { - logger.error("Error", error.message); + logger.error("Request error:", errorData.code, errorData.message); + return Promise.reject( + new Error( + `Request failed with ${errorData.code} ${errorData.message}` + ) + ); } - logger.error(" ----- END RESPONSE ERROR -------"); - return Promise.reject(error); + + 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 5aaf53226..d2034f15d 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 f26d65f1f..20989bc94 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 6c701c9aa..54718c1d6 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 d286ad6c5..2b6eebb09 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 28c3bed3e..3689b302d 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 79559a355..95b843c93 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -1,24 +1,28 @@ -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); + const gamesChunks = chunk(games, 50); for (const chunk of gamesChunks) { await HydraApi.post( "/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 95a399ea3..03bf6ad76 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 a1c2b449b..12b6e3a7f 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 f3e2541be..63c666dc7 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 c6cb7e102..0b04defe7 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(), + }); + + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + ...game, playTimeInMilliseconds: game.playTimeInMilliseconds + delta, lastTimePlayed: new Date(), }); - gamesPlaytime.set(game.id, { + 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 1384a1be4..22e604617 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 000000000..9a277dd76 --- /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 7e9244547..d26c995df 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 f7e82f072..2d0bf24d8 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 5f9bc02c1..439327cd6 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.css.ts b/src/renderer/src/app.css.ts index 25c453c85..b5c4740e5 100644 --- a/src/renderer/src/app.css.ts +++ b/src/renderer/src/app.css.ts @@ -123,7 +123,7 @@ export const titleBar = style({ alignItems: "center", padding: `0 ${SPACING_UNIT * 2}px`, WebkitAppRegion: "drag", - zIndex: "4", + zIndex: vars.zIndex.titleBar, borderBottom: `1px solid ${vars.color.border}`, } as ComplexStyleRule); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 5fefe90c2..c50e5017d 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 { @@ -29,6 +29,7 @@ import { downloadSourcesWorker } from "./workers"; import { downloadSourcesTable } from "./dexie"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; +import { SPACING_UNIT } from "./theme.css"; export interface AppProps { children: React.ReactNode; @@ -84,7 +85,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 +213,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]); @@ -250,12 +267,24 @@ 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/dropdown-menu/dropdown-menu.tsx b/src/renderer/src/components/dropdown-menu/dropdown-menu.tsx index 145318680..9e3a1dec1 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 d8d0554d6..af09ef387 100644 --- a/src/renderer/src/components/modal/modal.tsx +++ b/src/renderer/src/components/modal/modal.tsx @@ -52,6 +52,7 @@ export function Modal({ ) ) return false; + const openModals = document.querySelectorAll("[role=dialog]"); return ( diff --git a/src/renderer/src/components/sidebar/sidebar.css.ts b/src/renderer/src/components/sidebar/sidebar.css.ts deleted file mode 100644 index 4fd4b9818..000000000 --- a/src/renderer/src/components/sidebar/sidebar.css.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { style } from "@vanilla-extract/css"; -import { recipe } from "@vanilla-extract/recipes"; - -import { SPACING_UNIT, vars } from "../../theme.css"; - -export const sidebar = recipe({ - base: { - backgroundColor: vars.color.darkBackground, - color: vars.color.muted, - flexDirection: "column", - display: "flex", - transition: "opacity ease 0.2s", - borderRight: `solid 1px ${vars.color.border}`, - position: "relative", - overflow: "hidden", - justifyContent: "space-between", - }, - variants: { - resizing: { - true: { - opacity: vars.opacity.active, - pointerEvents: "none", - }, - }, - darwin: { - true: { - paddingTop: `${SPACING_UNIT * 6}px`, - }, - false: { - paddingTop: `${SPACING_UNIT}px`, - }, - }, - }, -}); - -export const content = style({ - display: "flex", - flexDirection: "column", - padding: `${SPACING_UNIT * 2}px`, - gap: `${SPACING_UNIT * 2}px`, - width: "100%", - overflow: "auto", -}); - -export const handle = style({ - width: "5px", - height: "100%", - cursor: "col-resize", - position: "absolute", - right: "0", -}); - -export const menu = style({ - listStyle: "none", - padding: "0", - margin: "0", - gap: `${SPACING_UNIT / 2}px`, - display: "flex", - flexDirection: "column", - overflow: "hidden", -}); - -export const menuItem = recipe({ - base: { - transition: "all ease 0.1s", - cursor: "pointer", - textWrap: "nowrap", - display: "flex", - color: vars.color.muted, - borderRadius: "4px", - ":hover": { - backgroundColor: "rgba(255, 255, 255, 0.15)", - }, - }, - variants: { - active: { - true: { - backgroundColor: "rgba(255, 255, 255, 0.1)", - }, - }, - muted: { - true: { - opacity: vars.opacity.disabled, - ":hover": { - opacity: "1", - }, - }, - }, - }, -}); - -export const menuItemButton = style({ - color: "inherit", - display: "flex", - alignItems: "center", - gap: `${SPACING_UNIT}px`, - cursor: "pointer", - overflow: "hidden", - width: "100%", - padding: `9px ${SPACING_UNIT}px`, -}); - -export const menuItemButtonLabel = style({ - textOverflow: "ellipsis", - overflow: "hidden", -}); - -export const gameIcon = style({ - width: "20px", - height: "20px", - minWidth: "20px", - minHeight: "20px", - borderRadius: "4px", - backgroundSize: "cover", -}); - -export const sectionTitle = style({ - textTransform: "uppercase", - fontWeight: "bold", -}); - -export const section = style({ - gap: `${SPACING_UNIT * 2}px`, - display: "flex", - flexDirection: "column", - paddingBottom: `${SPACING_UNIT}px`, -}); - -export const helpButton = style({ - color: vars.color.muted, - padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`, - gap: "9px", - display: "flex", - alignItems: "center", - cursor: "pointer", - borderTop: `solid 1px ${vars.color.border}`, - transition: "background-color ease 0.1s", - ":hover": { - backgroundColor: "rgba(255, 255, 255, 0.15)", - }, -}); - -export const helpButtonIcon = style({ - background: "linear-gradient(0deg, #16B195 50%, #3E62C0 100%)", - width: "24px", - height: "24px", - display: "flex", - alignItems: "center", - justifyContent: "center", - color: "#fff", - borderRadius: "50%", -}); diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss new file mode 100644 index 000000000..c11a1041e --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -0,0 +1,136 @@ +@use "../../scss/globals.scss"; + +.sidebar { + background-color: globals.$dark-background-color; + color: globals.$muted-color; + flex-direction: column; + display: flex; + transition: opacity ease 0.2s; + border-right: solid 1px globals.$border-color; + position: relative; + overflow: hidden; + padding-top: globals.$spacing-unit; + + &--resizing { + opacity: globals.$active-opacity; + pointer-events: none; + } + + &--darwin { + padding-top: calc(globals.$spacing-unit * 6); + } + + &__content { + display: flex; + flex-direction: column; + padding: calc(globals.$spacing-unit * 2); + gap: calc(globals.$spacing-unit * 2); + width: 100%; + overflow: auto; + } + + &__handle { + width: 5px; + height: 100%; + cursor: col-resize; + position: absolute; + right: 0; + } + + &__menu { + list-style: none; + padding: 0; + margin: 0; + gap: calc(globals.$spacing-unit / 2); + display: flex; + flex-direction: column; + overflow: hidden; + } + + &__menu-item { + transition: all ease 0.1s; + cursor: pointer; + text-wrap: nowrap; + display: flex; + color: globals.$muted-color; + border-radius: 4px; + &:hover { + background-color: rgba(255, 255, 255, 0.15); + } + + &--active { + background-color: rgba(255, 255, 255, 0.1); + } + + &--muted { + opacity: globals.$disabled-opacity; + + &:hover { + opacity: 1; + } + } + } + + &__menu-item-button { + color: inherit; + display: flex; + align-items: center; + gap: globals.$spacing-unit; + cursor: pointer; + overflow: hidden; + width: 100%; + padding: 9px globals.$spacing-unit; + } + + &__menu-item-button-label { + text-overflow: ellipsis; + overflow: hidden; + } + + &__game-icon { + width: 20px; + height: 20px; + min-width: 20px; + min-height: 20px; + border-radius: 4px; + background-size: cover; + } + + &__section-title { + text-transform: uppercase; + font-weight: bold; + } + + &__section { + gap: calc(globals.$spacing-unit * 2); + display: flex; + flex-direction: column; + padding-bottom: globals.$spacing-unit; + } + + &__help-button { + color: globals.$muted-color; + padding: globals.$spacing-unit calc(globals.$spacing-unit * 2); + gap: 9px; + display: flex; + align-items: center; + cursor: pointer; + border-top: solid 1px globals.$border-color; + transition: background-color ease 0.1s; + + &:hover { + background-color: rgba(255, 255, 255, 0.15); + } + } + + &__help-button-icon { + background: linear-gradient(0deg, #16b195 50%, #3e62c0 100%); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + border-radius: 50%; + } +} diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 355d04b20..585f164ea 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -14,12 +14,14 @@ import { import { routes } from "./routes"; -import * as styles from "./sidebar.css"; +import "./sidebar.scss"; + import { buildGameDetailsPath } from "@renderer/helpers"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { SidebarProfile } from "./sidebar-profile"; import { sortBy } from "lodash-es"; +import cn from "classnames"; import { CommentDiscussionIcon } from "@primer/octicons-react"; const SIDEBAR_MIN_WIDTH = 200; @@ -56,7 +58,7 @@ export function Sidebar() { useEffect(() => { updateLibrary(); - }, [lastPacket?.game.id, updateLibrary]); + }, [lastPacket?.gameId, updateLibrary]); const sidebarRef = useRef(null); @@ -118,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; }; @@ -146,7 +147,7 @@ export function Sidebar() { ) => { const path = buildGameDetailsPath({ ...game, - objectId: game.objectID, + objectId: game.objectId, }); if (path !== location.pathname) { navigate(path); @@ -155,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 ); @@ -168,9 +170,9 @@ export function Sidebar() { return ( diff --git a/src/renderer/src/components/text-field/text-field.css.ts b/src/renderer/src/components/text-field/text-field.css.ts index f983508ee..8c993993c 100644 --- a/src/renderer/src/components/text-field/text-field.css.ts +++ b/src/renderer/src/components/text-field/text-field.css.ts @@ -14,7 +14,7 @@ export const textField = recipe({ base: { display: "inline-flex", transition: "all ease 0.2s", - width: "100%", + flex: 1, alignItems: "center", borderRadius: "8px", border: `solid 1px ${vars.color.border}`, diff --git a/src/renderer/src/components/toast/toast.css.ts b/src/renderer/src/components/toast/toast.css.ts deleted file mode 100644 index a07bb105a..000000000 --- a/src/renderer/src/components/toast/toast.css.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { keyframes, style } from "@vanilla-extract/css"; - -import { SPACING_UNIT, vars } from "../../theme.css"; -import { recipe } from "@vanilla-extract/recipes"; - -const TOAST_HEIGHT = 80; - -export const slideIn = keyframes({ - "0%": { transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)` }, - "100%": { transform: "translateY(0)" }, -}); - -export const slideOut = keyframes({ - "0%": { transform: `translateY(0)` }, - "100%": { transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)` }, -}); - -export const toast = recipe({ - base: { - animationDuration: "0.2s", - animationTimingFunction: "ease-in-out", - maxHeight: TOAST_HEIGHT, - position: "fixed", - backgroundColor: vars.color.background, - borderRadius: "4px", - border: `solid 1px ${vars.color.border}`, - right: `${SPACING_UNIT * 2}px`, - /* Bottom panel height + 16px */ - bottom: `${26 + SPACING_UNIT * 2}px`, - overflow: "hidden", - display: "flex", - flexDirection: "column", - justifyContent: "space-between", - zIndex: vars.zIndex.toast, - maxWidth: "500px", - }, - variants: { - closing: { - true: { - animationName: slideOut, - transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)`, - }, - false: { - animationName: slideIn, - transform: `translateY(0)`, - }, - }, - }, -}); - -export const toastContent = style({ - display: "flex", - gap: `${SPACING_UNIT * 2}px`, - padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`, - justifyContent: "center", - alignItems: "center", -}); - -export const progress = style({ - width: "100%", - height: "5px", - "::-webkit-progress-bar": { - backgroundColor: vars.color.darkBackground, - }, - "::-webkit-progress-value": { - backgroundColor: vars.color.muted, - }, -}); - -export const closeButton = style({ - color: vars.color.body, - cursor: "pointer", - padding: "0", - margin: "0", -}); - -export const successIcon = style({ - color: vars.color.success, -}); - -export const errorIcon = style({ - color: vars.color.danger, -}); - -export const warningIcon = style({ - color: vars.color.warning, -}); diff --git a/src/renderer/src/components/toast/toast.scss b/src/renderer/src/components/toast/toast.scss new file mode 100644 index 000000000..5cb801e83 --- /dev/null +++ b/src/renderer/src/components/toast/toast.scss @@ -0,0 +1,85 @@ +@use "../../scss/globals.scss"; + +.toast { + animation-duration: 0.2s; + animation-timing-function: ease-in-out; + position: absolute; + background-color: globals.$dark-background-color; + border-radius: 4px; + border: solid 1px globals.$border-color; + right: 0; + bottom: 0; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: space-between; + z-index: globals.$toast-z-index; + max-width: 420px; + animation-name: enter; + transform: translateY(0); + + &--closing { + animation-name: exit; + transform: translateY(100%); + } + + &__content { + display: flex; + gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2); + justify-content: center; + align-items: center; + } + + &__progress { + width: 100%; + height: 5px; + + &::-webkit-progress-bar { + background-color: globals.$dark-background-color; + } + &::-webkit-progress-value { + background-color: globals.$muted-color; + } + } + + &__close-button { + color: globals.$body-color; + cursor: pointer; + padding: 0; + margin: 0; + transition: color 0.2s ease-in-out; + + &:hover { + color: globals.$muted-color; + } + } + + &__icon { + &--success { + color: globals.$success-color; + } + + &--error { + color: globals.$danger-color; + } + + &--warning { + color: globals.$warning-color; + } + } +} + +@keyframes enter { + 0% { + opacity: 0; + transform: translateY(100%); + } +} + +@keyframes exit { + 0% { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/renderer/src/components/toast/toast.tsx b/src/renderer/src/components/toast/toast.tsx index 2aa575396..116d26fa4 100644 --- a/src/renderer/src/components/toast/toast.tsx +++ b/src/renderer/src/components/toast/toast.tsx @@ -6,19 +6,28 @@ import { XIcon, } from "@primer/octicons-react"; -import * as styles from "./toast.css"; -import { SPACING_UNIT } from "@renderer/theme.css"; +import "./toast.scss"; +import cn from "classnames"; export interface ToastProps { visible: boolean; - message: string; + title: string; + message?: string; type: "success" | "error" | "warning"; + duration?: number; onClose: () => void; } const INITIAL_PROGRESS = 100; -export function Toast({ visible, message, type, onClose }: ToastProps) { +export function Toast({ + visible, + title, + message, + type, + duration = 2500, + onClose, +}: Readonly) { const [isClosing, setIsClosing] = useState(false); const [progress, setProgress] = useState(INITIAL_PROGRESS); @@ -31,7 +40,7 @@ export function Toast({ visible, message, type, onClose }: ToastProps) { closingAnimation.current = requestAnimationFrame( function animateClosing(time) { - if (time - zero <= 200) { + if (time - zero <= 150) { closingAnimation.current = requestAnimationFrame(animateClosing); } else { onClose(); @@ -43,17 +52,13 @@ export function Toast({ visible, message, type, onClose }: ToastProps) { useEffect(() => { if (visible) { const zero = performance.now(); - progressAnimation.current = requestAnimationFrame( function animateProgress(time) { const elapsed = time - zero; - - const progress = Math.min(elapsed / 2500, 1); + const progress = Math.min(elapsed / duration, 1); const currentValue = INITIAL_PROGRESS + (0 - INITIAL_PROGRESS) * progress; - setProgress(currentValue); - if (progress < 1) { progressAnimation.current = requestAnimationFrame(animateProgress); } else { @@ -70,37 +75,62 @@ export function Toast({ visible, message, type, onClose }: ToastProps) { setIsClosing(false); }; } - return () => {}; - }, [startAnimateClosing, visible]); + }, [startAnimateClosing, duration, visible]); if (!visible) return null; return ( -
-
-
- {type === "success" && ( - - )} - - {type === "error" && } - - {type === "warning" && } - {message} -
- - +
+ {type === "success" && ( + + )} + + {type === "error" && ( + + )} + + {type === "warning" && ( + + )} + + {title} + + +
+ + {message &&

{message}

} +
- + ); } diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index d0797caf2..1d7aa1b16 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 9242d9a6a..da7fd1e8a 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 49718430f..5cecd7b3f 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 31c99df52..c19235624 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 0a419370e..cc7c40ea6 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 e1dd609fe..18718785f 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 c0b8753dc..9d48c0df6 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 e27480fa1..f5df1d1c3 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 4ea79b934..b84ac515f 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 485470f0c..8b4c3e0f1 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 0679cde84..2fdac3826 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 61c561f12..cb6ba45f3 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 ef178b501..8862ce48c 100644 --- a/src/renderer/src/pages/achievements/achievement-list.tsx +++ b/src/renderer/src/pages/achievements/achievement-list.tsx @@ -1,43 +1,38 @@ import { useDate } from "@renderer/hooks"; import type { UserAchievement } from "@types"; import { useTranslation } from "react-i18next"; -import * as styles from "./achievements.css"; +import "./achievements.scss"; import { EyeClosedIcon } from "@primer/octicons-react"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import { useSubscription } from "@renderer/hooks/use-subscription"; -import { vars } from "@renderer/theme.css"; interface AchievementListProps { achievements: UserAchievement[]; } -export function AchievementList({ achievements }: AchievementListProps) { +export function AchievementList({ + achievements, +}: Readonly) { const { t } = useTranslation("achievement"); const { showHydraCloudModal } = useSubscription(); const { formatDateTime } = useDate(); return ( -
    +
      {achievements.map((achievement) => ( -
    • +
    • {achievement.displayName} -
      -

      +
      +

      {achievement.hidden && ( @@ -47,41 +42,36 @@ export function AchievementList({ achievements }: AchievementListProps) {

      {achievement.description}

      -
      + +
      {achievement.points != undefined ? (
      - -

      {achievement.points}

      + +

      + {achievement.points} +

      ) : ( )} {achievement.unlockTime != null && (
      {formatDateTime(achievement.unlockTime)}
      diff --git a/src/renderer/src/pages/achievements/achievements.scss b/src/renderer/src/pages/achievements/achievements.scss new file mode 100644 index 000000000..5a5de8e65 --- /dev/null +++ b/src/renderer/src/pages/achievements/achievements.scss @@ -0,0 +1,262 @@ +@use "../../scss/globals.scss"; +@use "sass:math"; + +$hero-height: 150px; +$logo-height: 100px; +$logo-max-width: 200px; + +.achievements { + display: flex; + flex-direction: column; + overflow: hidden; + width: 100%; + height: 100%; + transition: all ease 0.3s; + + &__hero { + width: 100%; + height: $hero-height; + min-height: $hero-height; + display: flex; + flex-direction: column; + position: relative; + transition: all ease 0.2s; + + &-content { + padding: globals.$spacing-unit * 2; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + } + + &-logo-backdrop { + width: 100%; + height: 100%; + position: absolute; + display: flex; + flex-direction: column; + justify-content: flex-end; + } + + &-image-skeleton { + height: 150px; + } + } + + &__game-logo { + width: $logo-max-width; + height: $logo-height; + object-fit: contain; + transition: all ease 0.2s; + + &:hover { + transform: scale(1.05); + } + } + + &__container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; + z-index: 1; + } + + &__table-header { + width: 100%; + background-color: var(--color-dark-background); + transition: all ease 0.2s; + border-bottom: solid 1px var(--color-border); + position: sticky; + top: 0; + z-index: 1; + + &--stuck { + box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8); + } + } + + &__list { + list-style: none; + margin: 0; + display: flex; + flex-direction: column; + gap: globals.$spacing-unit * 2; + padding: globals.$spacing-unit * 2; + width: 100%; + background-color: var(--color-background); + } + + &__item { + display: flex; + transition: all ease 0.1s; + color: var(--color-muted); + width: 100%; + overflow: hidden; + border-radius: 4px; + padding: globals.$spacing-unit globals.$spacing-unit; + gap: globals.$spacing-unit * 2; + align-items: center; + text-align: left; + + &:hover { + background-color: rgba(255, 255, 255, 0.15); + text-decoration: none; + } + + &-image { + width: 54px; + height: 54px; + border-radius: 4px; + object-fit: cover; + + &--locked { + filter: grayscale(100%); + } + } + + &-content { + flex: 1; + } + + &-title { + display: flex; + align-items: center; + gap: 4px; + } + + &-hidden-icon { + display: flex; + color: var(--color-warning); + opacity: 0.8; + + &:hover { + opacity: 1; + } + + svg { + width: 12px; + height: 12px; + } + } + + &-eye-closed { + width: 12px; + height: 12px; + color: globals.$warning-color; + scale: 4; + } + + &-meta { + display: flex; + flex-direction: column; + gap: 8px; + } + + &-points { + display: flex; + align-items: center; + gap: 4px; + margin-right: 4px; + font-weight: 600; + + &--locked { + cursor: pointer; + color: var(--color-warning); + } + + &-icon { + width: 18px; + height: 18px; + } + + &-value { + font-size: 1.1em; + } + } + + &-unlock-time { + white-space: nowrap; + gap: 4px; + display: flex; + } + + &-compared { + display: grid; + grid-template-columns: 3fr 1fr 1fr; + + &--no-owner { + grid-template-columns: 3fr 2fr; + } + } + + &-main { + display: flex; + flex-direction: row; + align-items: center; + gap: globals.$spacing-unit; + } + + &-status { + display: flex; + padding: globals.$spacing-unit; + justify-content: center; + + &--unlocked { + white-space: nowrap; + flex-direction: row; + gap: globals.$spacing-unit; + padding: 0; + } + } + } + + &__progress-bar { + width: 100%; + height: 8px; + transition: all ease 0.2s; + + &::-webkit-progress-bar { + background-color: rgba(255, 255, 255, 0.15); + border-radius: 4px; + } + + &::-webkit-progress-value { + background-color: var(--color-muted); + border-radius: 4px; + } + } + + &__profile-avatar { + height: 54px; + width: 54px; + border-radius: 4px; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--color-background); + position: relative; + object-fit: cover; + + &--small { + height: 32px; + width: 32px; + } + } + + &__subscription-button { + text-decoration: none; + display: flex; + justify-content: center; + width: 100%; + gap: math.div(globals.$spacing-unit, 2); + color: var(--color-body); + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/src/renderer/src/pages/achievements/achievements.tsx b/src/renderer/src/pages/achievements/achievements.tsx index 605300ef6..f467cf895 100644 --- a/src/renderer/src/pages/achievements/achievements.tsx +++ b/src/renderer/src/pages/achievements/achievements.tsx @@ -44,7 +44,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.css.ts b/src/renderer/src/pages/downloads/download-group.css.ts deleted file mode 100644 index cbbb4f8eb..000000000 --- a/src/renderer/src/pages/downloads/download-group.css.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { style } from "@vanilla-extract/css"; - -import { SPACING_UNIT, vars } from "../../theme.css"; - -export const downloadTitleWrapper = style({ - display: "flex", - alignItems: "center", - marginBottom: `${SPACING_UNIT}px`, - gap: `${SPACING_UNIT}px`, -}); - -export const downloadTitle = style({ - fontWeight: "bold", - cursor: "pointer", - color: vars.color.body, - textAlign: "left", - fontSize: "16px", - display: "block", - ":hover": { - textDecoration: "underline", - }, -}); - -export const downloads = style({ - width: "100%", - gap: `${SPACING_UNIT * 2}px`, - display: "flex", - flexDirection: "column", - margin: "0", - padding: "0", - marginTop: `${SPACING_UNIT}px`, -}); - -export const downloadCover = style({ - width: "280px", - minWidth: "280px", - height: "auto", - borderRight: `solid 1px ${vars.color.border}`, - position: "relative", - zIndex: "1", -}); - -export const downloadCoverContent = style({ - width: "100%", - height: "100%", - padding: `${SPACING_UNIT}px`, - display: "flex", - alignItems: "flex-end", - justifyContent: "flex-end", -}); - -export const downloadCoverBackdrop = style({ - width: "100%", - height: "100%", - background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)", - display: "flex", - overflow: "hidden", - zIndex: "1", -}); - -export const downloadCoverImage = style({ - width: "100%", - height: "100%", - position: "absolute", - zIndex: "-1", -}); - -export const download = style({ - width: "100%", - backgroundColor: vars.color.background, - display: "flex", - borderRadius: "8px", - border: `solid 1px ${vars.color.border}`, - overflow: "hidden", - boxShadow: "0px 0px 5px 0px #000000", - transition: "all ease 0.2s", - height: "140px", - minHeight: "140px", - maxHeight: "140px", -}); - -export const downloadDetails = style({ - display: "flex", - flexDirection: "column", - flex: "1", - justifyContent: "center", - gap: `${SPACING_UNIT / 2}px`, - fontSize: "14px", -}); - -export const downloadRightContent = style({ - display: "flex", - padding: `${SPACING_UNIT * 2}px`, - flex: "1", - gap: `${SPACING_UNIT}px`, - background: "linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%)", -}); - -export const downloadActions = style({ - display: "flex", - alignItems: "center", - gap: `${SPACING_UNIT}px`, -}); - -export const downloadGroup = style({ - display: "flex", - flexDirection: "column", - gap: `${SPACING_UNIT * 2}px`, -}); diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss new file mode 100644 index 000000000..2c5e9701b --- /dev/null +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -0,0 +1,140 @@ +@use "../../scss/globals.scss"; + +.download-group { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(globals.$spacing-unit * 2); + + &-divider { + flex: 1; + background-color: globals.$border-color; + height: 1px; + } + + &-count { + font-weight: 400; + } + } + + &__title-wrapper { + display: flex; + align-items: center; + margin-bottom: globals.$spacing-unit; + gap: globals.$spacing-unit; + } + + &__title { + font-weight: bold; + cursor: pointer; + color: globals.$body-color; + text-align: left; + font-size: 16px; + display: block; + + &:hover { + text-decoration: underline; + } + } + + &__downloads { + width: 100%; + gap: calc(globals.$spacing-unit * 2); + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + margin-top: globals.$spacing-unit; + } + + &__item { + width: 100%; + background-color: globals.$background-color; + display: flex; + border-radius: 8px; + border: solid 1px globals.$border-color; + overflow: hidden; + box-shadow: 0px 0px 5px 0px #000000; + transition: all ease 0.2s; + height: 140px; + min-height: 140px; + max-height: 140px; + position: relative; + } + + &__cover { + width: 280px; + min-width: 280px; + height: auto; + border-right: solid 1px globals.$border-color; + position: relative; + z-index: 1; + + &-content { + width: 100%; + height: 100%; + padding: globals.$spacing-unit; + display: flex; + align-items: flex-end; + justify-content: flex-end; + } + + &-backdrop { + width: 100%; + height: 100%; + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0.8) 5%, + transparent 100% + ); + display: flex; + overflow: hidden; + z-index: 1; + } + + &-image { + width: 100%; + height: 100%; + position: absolute; + z-index: -1; + } + } + + &__right-content { + display: flex; + padding: calc(globals.$spacing-unit * 2); + flex: 1; + gap: globals.$spacing-unit; + background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%); + } + + &__details { + display: flex; + flex-direction: column; + flex: 1; + justify-content: center; + gap: calc(globals.$spacing-unit / 2); + font-size: 14px; + } + + &__actions { + display: flex; + align-items: center; + gap: globals.$spacing-unit; + } + + &__menu-button { + position: absolute; + top: 12px; + right: 12px; + border-radius: 50%; + border: none; + padding: 8px; + min-height: unset; + } +} diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 88cf1433d..d84d66014 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 { @@ -12,9 +12,8 @@ import { Downloader, formatBytes, steamUrlBuilder } from "@shared"; import { DOWNLOADER_NAME } from "@renderer/constants"; import { useAppSelector, useDownload } from "@renderer/hooks"; -import * as styles from "./download-group.css"; +import "./download-group.scss"; import { useTranslation } from "react-i18next"; -import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { useMemo } from "react"; import { DropdownMenu, @@ -31,11 +30,14 @@ import { XCircleIcon, } from "@primer/octicons-react"; +import torBoxLogo from "@renderer/assets/icons/torbox.webp"; +import { SPACING_UNIT, vars } from "@renderer/theme.css"; + 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[]; } @@ -45,7 +47,7 @@ export function DownloadGroup({ openDeleteGameModal, openGameInstaller, seedingStatus, -}: DownloadGroupProps) { +}: Readonly) { const navigate = useNavigate(); const { t } = useTranslation("downloads"); @@ -66,18 +68,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); @@ -87,7 +90,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); @@ -114,11 +119,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 @@ -127,11 +132,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

      } @@ -141,41 +146,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: , }, { @@ -183,53 +191,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: , }, ]; @@ -238,59 +266,64 @@ export function DownloadGroup({ if (!library.length) return null; return ( -
      -
      +
      +

      {title}

      - -
      -

      {library.length}

      +
      +

      {library.length}

      -
        +
          {library.map((game) => { return ( -
        • -
          -
          +
        • +
          +
          {game.title} -
          - {DOWNLOADER_NAME[game.downloader]} +
          + {game.download?.downloader === Downloader.TorBox ? ( +
          + TorBox + TorBox +
          + ) : ( + + {DOWNLOADER_NAME[game.download!.downloader]} + + )}
          -
          -
          -
          +
          +
          +
          - ) - } - />
          - )} + + + {t("clear")} + + ) + } + /> +

          {t("downloads_secion_title")}

          @@ -322,7 +335,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 635c7f998..7d176de71 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -19,7 +19,7 @@ export interface RepacksModalProps { repack: GameRepack, downloader: Downloader, downloadPath: string - ) => Promise; + ) => Promise<{ ok: boolean; error?: string }>; onClose: () => void; } @@ -27,7 +27,7 @@ export function RepacksModal({ visible, startDownload, onClose, -}: RepacksModalProps) { +}: Readonly) { const [filteredRepacks, setFilteredRepacks] = useState([]); const [repack, setRepack] = useState(null); const [showSelectFolderModal, setShowSelectFolderModal] = useState(false); @@ -67,8 +67,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 ( @@ -111,7 +111,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.scss b/src/renderer/src/pages/game-details/sidebar/sidebar.scss new file mode 100644 index 000000000..15bc74c34 --- /dev/null +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.scss @@ -0,0 +1,174 @@ +@use "../../../scss/globals.scss"; + +.content-sidebar { + border-left: solid 1px globals.$border-color; + background-color: globals.$dark-background-color; + width: 100%; + height: 100%; + + @media (min-width: 1024px) { + max-width: 300px; + width: 100%; + } + + @media (min-width: 1280px) { + width: 100%; + max-width: 400px; + } +} + +.requirement { + &__button-container { + width: 100%; + display: flex; + } + + &__button { + border: solid 1px globals.$border-color; + border-left: none; + border-right: none; + border-radius: 0; + width: 100%; + } + + &__details { + padding: calc(globals.$spacing-unit * 2); + line-height: 22px; + font-size: globals.$body-font-size; + + a { + display: flex; + color: globals.$body-color; + } + } + + &__details-skeleton { + display: flex; + flex-direction: column; + gap: globals.$spacing-unit; + padding: calc(globals.$spacing-unit * 2); + font-size: globals.$body-font-size; + } +} + +.how-long-to-beat { + &__categories-list { + margin: 0; + padding: calc(globals.$spacing-unit * 2); + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + } + + &__category { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + background: linear-gradient( + 90deg, + transparent 20%, + rgb(255 255 255 / 2%) 100% + ); + border-radius: 4px; + padding: globals.$spacing-unit calc(globals.$spacing-unit * 2); + border: solid 1px globals.$border-color; + } + + &__category-label { + color: globals.$muted-color; + } + + &__category-skeleton { + border: solid 1px globals.$border-color; + border-radius: 4px; + height: 76px; + } +} + +.stats { + &__section { + display: flex; + gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2); + justify-content: space-between; + transition: max-height ease 0.5s; + overflow: hidden; + + @media (min-width: 1024px) { + flex-direction: column; + } + + @media (min-width: 1280px) { + flex-direction: row; + } + } + + &__category-title { + font-size: globals.$small-font-size; + font-weight: bold; + display: flex; + align-items: center; + gap: globals.$spacing-unit; + } + + &__category { + display: flex; + flex-direction: row; + gap: calc(globals.$spacing-unit / 2); + justify-content: space-between; + align-items: center; + } +} + +.list { + list-style: none; + margin: 0; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2); + + &__item { + display: flex; + cursor: pointer; + transition: all ease 0.1s; + color: globals.$muted-color; + width: 100%; + overflow: hidden; + border-radius: 4px; + padding: globals.$spacing-unit; + gap: calc(globals.$spacing-unit * 2); + align-items: center; + text-align: left; + + &:hover { + background-color: rgba(255, 255, 255, 0.15); + text-decoration: none; + } + } + + &__item-image { + width: 54px; + height: 54px; + border-radius: 4px; + object-fit: cover; + + &--locked { + filter: grayscale(100%); + } + } +} + +.subscription-required-button { + text-decoration: none; + display: flex; + justify-content: center; + width: 100%; + gap: calc(globals.$spacing-unit / 2); + color: globals.$warning-color; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index 7787b22aa..36ee24fd0 100644 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -7,7 +7,6 @@ import type { import { useTranslation } from "react-i18next"; import { Button, Link } from "@renderer/components"; -import * as styles from "./sidebar.css"; import { gameDetailsContext } from "@renderer/context"; import { useDate, useFormat, useUserDetails } from "@renderer/hooks"; import { @@ -20,10 +19,10 @@ import { HowLongToBeatSection } from "./how-long-to-beat-section"; import { howLongToBeatEntriesTable } from "@renderer/dexie"; import { SidebarSection } from "../sidebar-section/sidebar-section"; import { buildGameAchievementPath } from "@renderer/helpers"; -import { SPACING_UNIT } from "@renderer/theme.css"; import { useSubscription } from "@renderer/hooks/use-subscription"; +import "./sidebar.scss"; -const fakeAchievements: UserAchievement[] = [ +const achievementsPlaceholder: UserAchievement[] = [ { displayName: "Timber!!", name: "", @@ -64,7 +63,6 @@ export function Sidebar() { }>({ isLoading: true, data: null }); const { userDetails, hasActiveSubscription } = useUserDetails(); - const [activeRequirement, setActiveRequirement] = useState("minimum"); @@ -72,10 +70,8 @@ export function Sidebar() { useContext(gameDetailsContext); const { showHydraCloudModal } = useSubscription(); - const { t } = useTranslation("game_details"); const { formatDateTime } = useDate(); - const { numberFormatter } = useFormat(); useEffect(() => { @@ -118,7 +114,7 @@ export function Sidebar() { }, [objectId, shop, gameTitle]); return ( -