diff --git a/.env.example b/.env.example index c2ad43d9..47d1a1e3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY MAIN_VITE_API_URL=API_URL MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN +SENTRY_AUTH_TOKEN= diff --git a/.eslintignore b/.eslintignore index a6f34fea..a9960b13 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ node_modules dist out .gitignore +migration.stub diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 88467f6a..472ed853 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -27,7 +27,7 @@ body: label: Expected behavior description: A clear and concise description of what you expected to happen. validations: - required: true + required: false - type: textarea id: screenshots attributes: @@ -56,3 +56,12 @@ body: description: Please provide any additional information and context about your problem. validations: required: false + - type: checkboxes + id: terms + attributes: + label: Before opening this Issue + options: + - label: I have searched the issues of this repository and believe that this is not a duplicate. + required: true + - label: I am aware that Hydra team does not offer any support or help regarding the downloaded games. + required: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 83636af6..85a7fe1f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,8 +40,6 @@ jobs: sudo apt-get install -y libarchive-tools yarn build:linux env: - MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }} - MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} @@ -51,8 +49,6 @@ jobs: if: matrix.os == 'windows-latest' run: yarn build:win env: - MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }} - MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a5296f0..e21acfcb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,8 +42,6 @@ jobs: sudo apt-get install -y libarchive-tools yarn build:linux env: - MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }} - MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} @@ -53,8 +51,6 @@ jobs: if: matrix.os == 'windows-latest' run: yarn build:win env: - MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }} - MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} diff --git a/.gitignore b/.gitignore index 7a6496a5..fb4badd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .vscode node_modules hydra-download-manager/ -aria2/ fastlist.exe __pycache__ dist diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..852e7cc3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,68 @@ +# Security Policy + +## Purpose of the Policy + +The purpose of this Security Policy is to ensure the security of our project and maintain the trust of the community. + +## Who is Affected by the Policy + +This policy applies to all members of our project community, including developers, testers, repository administrators, and users. + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 2.0.x | :white_check_mark: | +| < 1.2.0 | :x: | + +## Development Recommendations + +### Best Practices + +- Follow secure coding principles. +- Use well-established libraries and frameworks. +- Regularly update dependencies. +- Conduct thorough testing, including security-related tests. + +### Unrecommended Practices + +- Do not use known vulnerabilities that have not been patched. +- Do not publish sensitive information such as API keys or passwords. +- Do not vote for changes that degrade the security of the project. + +### User-Generated Content + +- Ensure that user-generated content does not contain hidden threats. +- Be cautious when handling user data. + +### Community Interaction + +- Treat each other with respect and politeness. +- Do not spread spam or spam bots. +- Follow community guidelines. + +### Vulnerability Discovery and Reporting + +- If you discover a vulnerability, report it as an issue on GitHub. +- Your report should contain detailed information about the vulnerability, including steps to resolve it. + +### Reporting Method + +To report a vulnerability, create a new issue on GitHub and use branch isolation to provide details about the vulnerability. + +### Details to Provide + +Please provide the following information about the vulnerability: + +- Description of the vulnerability +- Steps to resolve the vulnerability +- Versions on which the vulnerability was found +- Code examples illustrating the vulnerability (if it is safe to do so) + +### Expected Behavior + +- If we accept the reported vulnerability, we will release a patch and update the security information on GitHub. +- If we reject the reported vulnerability, we will provide an explanation. diff --git a/electron-builder.yml b/electron-builder.yml index 65c847a2..8d94d3ed 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,7 +3,6 @@ productName: Hydra directories: buildResources: build extraResources: - - aria2 - hydra-download-manager - seeds - from: node_modules/create-desktop-shortcuts/src/windows.vbs diff --git a/package.json b/package.json index 1b99734d..1727e383 100644 --- a/package.json +++ b/package.json @@ -23,29 +23,27 @@ "start": "electron-vite preview", "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", - "postinstall": "electron-builder install-app-deps && node ./postinstall.cjs", + "postinstall": "electron-builder install-app-deps", "build:unpack": "npm run build && electron-builder --dir", "build:win": "electron-vite build && electron-builder --win", "build:mac": "electron-vite build && electron-builder --mac", "build:linux": "electron-vite build && electron-builder --linux", "prepare": "husky", - "typeorm:migration-create": "yarn typeorm migration:create" + "knex:migrate:make": "knex --knexfile src/main/knexfile.ts migrate:make --esm" }, "dependencies": { "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", - "@fontsource/fira-mono": "^5.0.13", - "@fontsource/fira-sans": "^5.0.20", + "@fontsource/noto-sans": "^5.0.22", "@primer/octicons-react": "^19.9.0", "@reduxjs/toolkit": "^2.2.3", "@sentry/electron": "^5.1.0", "@vanilla-extract/css": "^1.14.2", "@vanilla-extract/dynamic": "^2.1.1", "@vanilla-extract/recipes": "^0.5.2", - "aria2": "^4.1.2", "auto-launch": "^5.0.6", - "axios": "^1.6.8", - "better-sqlite3": "^9.5.0", + "axios": "^1.7.7", + "better-sqlite3": "^11.2.1", "check-disk-space": "^3.4.0", "classnames": "^2.5.1", "color": "^4.2.3", @@ -60,9 +58,9 @@ "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", "icojs": "^0.19.3", - "iso-639-1": "3.1.2", "jsdom": "^24.0.0", "jsonwebtoken": "^9.0.2", + "knex": "^3.1.0", "lodash-es": "^4.17.21", "lottie-react": "^2.4.0", "parse-torrent": "^11.0.16", @@ -97,7 +95,7 @@ "@types/user-agents": "^1.0.4", "@vanilla-extract/vite-plugin": "^4.0.7", "@vitejs/plugin-react": "^4.2.1", - "electron": "^30.0.9", + "electron": "^30.3.0", "electron-builder": "^24.9.1", "electron-vite": "^2.0.0", "eslint": "^8.56.0", @@ -108,6 +106,7 @@ "prettier": "^3.2.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "ts-node": "^10.9.2", "typescript": "^5.3.3", "vite": "^5.0.12", "vite-plugin-svgr": "^4.2.0" diff --git a/postinstall.cjs b/postinstall.cjs deleted file mode 100644 index 547af988..00000000 --- a/postinstall.cjs +++ /dev/null @@ -1,50 +0,0 @@ -const { default: axios } = require("axios"); -const util = require("node:util"); -const fs = require("node:fs"); - -const exec = util.promisify(require("node:child_process").exec); - -const downloadAria2 = async () => { - if (fs.existsSync("aria2")) { - console.log("Aria2 already exists, skipping download..."); - return; - } - - const file = - process.platform === "win32" - ? "aria2-1.37.0-win-64bit-build1.zip" - : "aria2-1.37.0-1-x86_64.pkg.tar.zst"; - - const downloadUrl = - process.platform === "win32" - ? `https://github.com/aria2/aria2/releases/download/release-1.37.0/${file}` - : "https://archlinux.org/packages/extra/x86_64/aria2/download/"; - - console.log(`Downloading ${file}...`); - - const response = await axios.get(downloadUrl, { responseType: "stream" }); - - const stream = response.data.pipe(fs.createWriteStream(file)); - - stream.on("finish", async () => { - console.log(`Downloaded ${file}, extracting...`); - - if (process.platform === "win32") { - await exec(`npx extract-zip ${file}`); - console.log("Extracted. Renaming folder..."); - - fs.renameSync(file.replace(".zip", ""), "aria2"); - } else { - await exec(`tar --zstd -xvf ${file} usr/bin/aria2c`); - console.log("Extracted. Copying binary file..."); - fs.mkdirSync("aria2"); - fs.copyFileSync("usr/bin/aria2c", "aria2/aria2c"); - fs.rmSync("usr", { recursive: true }); - } - - console.log(`Extracted ${file}, removing compressed downloaded file...`); - fs.rmSync(file); - }); -}; - -downloadAria2(); diff --git a/src/locales/ar/translation.json b/src/locales/ar/translation.json index e95db2f1..26f0654d 100644 --- a/src/locales/ar/translation.json +++ b/src/locales/ar/translation.json @@ -1,4 +1,5 @@ { + "language_name": "اَلْعَرَبِيَّةُ", "home": { "featured": "مميّز", "trending": "شائع", diff --git a/src/locales/be/translation.json b/src/locales/be/translation.json index 9e945f8d..b89946e8 100644 --- a/src/locales/be/translation.json +++ b/src/locales/be/translation.json @@ -1,4 +1,5 @@ { + "language_name": "беларуская мова", "home": { "featured": "Рэкамэндаванае", "trending": "Актуальнае", diff --git a/src/locales/ca/translation.json b/src/locales/ca/translation.json index c756745a..393ea587 100644 --- a/src/locales/ca/translation.json +++ b/src/locales/ca/translation.json @@ -1,4 +1,8 @@ { + "language_name": "Català", + "app": { + "successfully_signed_in": "Has entrat correctament" + }, "home": { "featured": "Destacats", "trending": "Populars", @@ -14,7 +18,10 @@ "paused": "{{title}} (Pausat)", "downloading": "{{title}} ({{percentage}} - S'està baixant…)", "filter": "Filtra la biblioteca", - "home": "Inici" + "home": "Inici", + "queued": "{{title}} (En espera)", + "game_has_no_executable": "El joc encara no té un executable seleccionat", + "sign_in": "Entra" }, "header": { "search": "Cerca jocs", @@ -29,7 +36,9 @@ "bottom_panel": { "no_downloads_in_progress": "Cap baixada en curs", "downloading_metadata": "S'estan baixant les metadades de: {{title}}…", - "downloading": "S'està baixant: {{title}}… ({{percentage}} complet) - Finalització: {{eta}} - {{speed}}" + "downloading": "S'està baixant: {{title}}… ({{percentage}} complet) - Finalització: {{eta}} - {{speed}}", + "calculating_eta": "Descarregant {{title}}… ({{percentage}} completat) - Calculant el temps restant…", + "checking_files": "Comprovant els fitxers de {{title}}… ({{percentage}} completat)" }, "catalogue": { "next_page": "Pàgina següent", @@ -47,12 +56,14 @@ "cancel": "Cancel·la", "remove": "Elimina", "space_left_on_disk": "{{space}} lliures al disc", - "eta": "Finalització: {{eta}}", + "eta": "Finalitza en: {{eta}}", + "calculating_eta": "Calculant temps estimat…", "downloading_metadata": "S'estan baixant les metadades…", "filter": "Filtra els reempaquetats", "requirements": "Requisits del sistema", "minimum": "Mínims", "recommended": "Recomanats", + "paused": "Paused", "release_date": "Publicat el {{date}}", "publisher": "Publicat per {{publisher}}", "hours": "hores", @@ -81,7 +92,29 @@ "previous_screenshot": "Captura anterior", "next_screenshot": "Captura següent", "screenshot": "Captura {{number}}", - "open_screenshot": "Obre la captura {{number}}" + "open_screenshot": "Obre la captura {{number}}", + "download_settings": "Configuració de descàrrega", + "downloader": "Descarregador", + "select_executable": "Selecciona", + "no_executable_selected": "No hi ha executable selccionat", + "open_folder": "Obre carpeta", + "open_download_location": "Visualitzar fitxers descarregats", + "create_shortcut": "Crear accés directe a l'escriptori", + "remove_files": "Elimina fitxers", + "remove_from_library_title": "Segur?", + "remove_from_library_description": "Això eliminarà el videojoc {{game}} del teu catàleg", + "options": "Opcions", + "executable_section_title": "Executable", + "executable_section_description": "Directori del fitxer des d'on s'executarà quan es cliqui a \"Executar\"", + "downloads_secion_title": "Descàrregues", + "downloads_section_description": "Comprova actualitzacions o altres versions del videojoc", + "danger_zone_section_title": "Zona de perill", + "danger_zone_section_description": "Elimina aquest videojoc del teu catàleg o els fitxers descarregats per Hydra", + "download_in_progress": "Descàrrega en progrés", + "download_paused": "Descàrrega en pausa", + "last_downloaded_option": "Opció de l'última descàrrega", + "create_shortcut_success": "Accés directe creat satisfactòriament", + "create_shortcut_error": "Error al crear l'accés directe" }, "activation": { "title": "Activa l'Hydra", @@ -98,6 +131,7 @@ "paused": "Pausada", "verifying": "S'està verificant…", "completed": "Completada", + "removed": "No descarregat", "cancel": "Cancel·la", "filter": "Filtra els jocs baixats", "remove": "Elimina", @@ -106,7 +140,14 @@ "delete": "Elimina l'instal·lador", "delete_modal_title": "N'estàs segur?", "delete_modal_description": "S'eliminaran de l'ordinador tots els fitxers d'instal·lació", - "install": "Instal·la" + "install": "Instal·la", + "download_in_progress": "En progrés", + "queued_downloads": "Descàrregues en espera", + "downloads_completed": "Completat", + "queued": "En espera", + "no_downloads_title": "Buit", + "no_downloads_description": "No has descarregat res amb Hydra encara, però mai és tard per començar a fer-ho.", + "checking_files": "Comprovant fitxers…" }, "settings": { "downloads_path": "Ruta de baixades", @@ -119,16 +160,49 @@ "launch_with_system": "Inicia l'Hydra quan s'iniciï el sistema", "general": "General", "behavior": "Comportament", + "download_sources": "Fonts de descàrrega", + "language": "Idioma", + "real_debrid_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í.", - "save_changes": "Desa els canvis" + "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", + "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.", + "validate_download_source": "Valida", + "remove_download_source": "Elimina", + "add_download_source": "Afegeix font", + "download_count_zero": "No hi ha baixades a la llista", + "download_count_one": "{{countFormatted}} a la llista de baixades", + "download_count_other": "{{countFormatted}} baixades a la llista", + "download_options_zero": "No hi ha cap descàrrega disponible", + "download_options_one": "{{countFormatted}} descàrrega disponible", + "download_options_other": "{{countFormatted}} baixades disponibles", + "download_source_url": "Descarrega l'URL de la font", + "add_download_source_description": "Inseriu la URL que conté el fitxer .json", + "download_source_up_to_date": "Actualitzat", + "download_source_errored": "S'ha produït un error", + "sync_download_sources": "Sincronitza fonts", + "removed_download_source": "S'ha eliminat la font de descàrrega", + "added_download_source": "Added download source", + "download_sources_synced": "Totes les fonts de descàrrega estan sincronitzades", + "insert_valid_json_url": "Insereix una URL JSON vàlida", + "found_download_option_zero": "No s'ha trobat cap opció de descàrrega", + "found_download_option_one": "S'ha trobat l'opció de baixada de {{countFormatted}}", + "found_download_option_other": "S'han trobat {{countFormatted}} opcions de baixada", + "import": "Import" }, "notifications": { "download_complete": "La baixada ha finalitzat", "game_ready_to_install": "{{title}} ja es pot instal·lar", "repack_list_updated": "S'ha actualitzat la llista de reempaquetats", "repack_count_one": "S'ha afegit {{count}} reempaquetat", - "repack_count_other": "S'han afegit {{count}} reempaquetats" + "repack_count_other": "S'han afegit {{count}} reempaquetats", + "new_update_available": "Versió {{version}} disponible", + "restart_to_install_update": "Reinicieu Hydra per instal·lar l'actualització" }, "system_tray": { "open": "Obre l'Hydra", @@ -144,5 +218,39 @@ }, "modal": { "close": "Botó de tancar" + }, + "forms": { + "toggle_password_visibility": "Commuta la visibilitat de la contrasenya" + }, + "user_profile": { + "amount_hours": "{{amount}} hores", + "amount_minutes": "{{amount}} minuts", + "last_time_played": "Última partida {{period}}", + "activity": "Activitat recent", + "library": "Biblioteca", + "total_play_time": "Temps total de joc:{{amount}}", + "no_recent_activity_title": "Hmmm… encara no res", + "no_recent_activity_description": "No has jugat a cap joc recentment. És el moment de canviar-ho!", + "display_name": "Nom de visualització", + "saving": "Desant", + "save": "Desa", + "edit_profile": "Edita el Perfil", + "saved_successfully": "S'ha desat correctament", + "try_again": "Siusplau torna-ho a provar", + "sign_out_modal_title": "Segur?", + "cancel": "Cancel·la", + "successfully_signed_out": "S'ha tancat la sessió correctament", + "sign_out": "Tanca sessió", + "playing_for": "Jugant per {{amount}}", + "sign_out_modal_text": "La vostra biblioteca està enllaçada amb el vostre compte actual. Quan tanqueu la sessió, la vostra biblioteca ja no serà visible i cap progrés no es desarà. Voleu continuar amb tancar la sessió?", + "add_friends": "Afegeix amics", + "add": "Afegeix", + "friend_code": "Codi de l'amic", + "see_profile": "Veure Perfil", + "sending": "Enviant", + "friend_request_sent": "Sol·licitud d'amistat enviada", + "friends": "Amistats", + "friends_list": "Llista d'amistats", + "user_not_found": "Usuari no trobat" } } diff --git a/src/locales/da/translation.json b/src/locales/da/translation.json index d5cac8db..20b2df34 100644 --- a/src/locales/da/translation.json +++ b/src/locales/da/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Dansk", "home": { "featured": "Anbefalet", "trending": "Trender", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3f1bcdcd..ae9c2712 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1,4 +1,5 @@ { + "language_name": "English", "app": { "successfully_signed_in": "Successfully signed in" }, @@ -174,12 +175,9 @@ "validate_download_source": "Validate", "remove_download_source": "Remove", "add_download_source": "Add source", - "download_count_zero": "No downloads in list", - "download_count_one": "{{countFormatted}} download in list", - "download_count_other": "{{countFormatted}} downloads in list", - "download_options_zero": "No download available", - "download_options_one": "{{countFormatted}} download available", - "download_options_other": "{{countFormatted}} downloads available", + "download_count_zero": "No download options", + "download_count_one": "{{countFormatted}} download option", + "download_count_other": "{{countFormatted}} download options", "download_source_url": "Download source URL", "add_download_source_description": "Insert the URL containing the .json file", "download_source_up_to_date": "Up-to-date", @@ -250,6 +248,30 @@ "friend_request_sent": "Friend request sent", "friends": "Friends", "friends_list": "Friends list", - "user_not_found": "User not found" + "user_not_found": "User not found", + "block_user": "Block user", + "add_friend": "Add friend", + "request_sent": "Request sent", + "request_received": "Request received", + "accept_request": "Accept request", + "ignore_request": "Ignore request", + "cancel_request": "Cancel request", + "undo_friendship": "Undo friendship", + "request_accepted": "Request accepted", + "user_blocked_successfully": "User blocked successfully", + "user_block_modal_text": "This will block {{displayName}}", + "settings": "Settings", + "public": "Public", + "private": "Private", + "friends_only": "Friends only", + "privacy": "Privacy", + "blocked_users": "Blocked users", + "unblock": "Unblock", + "no_friends_added": "You still don't have added friends", + "pending": "Pending", + "no_pending_invites": "You have no pending invites", + "no_blocked_users": "You have no blocked users", + "friend_code_copied": "Friend code copied", + "undo_friendship_modal_text": "This will undo your friendship with {{displayName}}" } } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 5e016d34..f8fa12e4 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Español", "app": { "successfully_signed_in": "Sesión iniciada correctamente" }, @@ -177,9 +178,6 @@ "download_count_zero": "No hay descargas en la lista", "download_count_one": "{{countFormatted}} descarga en la lista", "download_count_other": "{{countFormatted}} descargas en la lista", - "download_options_zero": "No hay descargas disponibles", - "download_options_one": "{{countFormatted}} descarga disponible", - "download_options_other": "{{countFormatted}} descargas disponibles", "download_source_url": "Descargar URL de origen", "add_download_source_description": "Introduce la URL con el archivo .json", "download_source_up_to_date": "Al día", @@ -241,6 +239,38 @@ "successfully_signed_out": "Sesión cerrada exitosamente", "sign_out": "Cerrar sesión", "playing_for": "Jugando por {{amount}}", - "sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?" + "sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?", + "add_friends": "Añadir amigos", + "add": "Añadir", + "friend_code": "Código de amigo", + "see_profile": "Ver perfil", + "sending": "Enviando", + "friend_request_sent": "Solicitud de amistad enviada", + "friends": "Amigos", + "friends_list": "Lista de amigos", + "user_not_found": "Usuario no encontrado", + "block_user": "Bloquear usuario", + "add_friend": "Añadir amigo", + "request_sent": "Solicitud enviada", + "request_received": "Solicitud recibida", + "accept_request": "Aceptar solicitud", + "ignore_request": "Ignorar solicitud", + "cancel_request": "Cancelar solicitud", + "undo_friendship": "Eliminar amistad", + "request_accepted": "Solicitud aceptada", + "user_blocked_successfully": "Usuario bloqueado exitosamente", + "user_block_modal_text": "Esto va a bloquear a {{displayName}}", + "settings": "Ajustes", + "public": "Público", + "private": "Privado", + "friends_only": "Solo Amigos", + "privacy": "Privacidad", + "blocked_users": "Usuarios bloqueados", + "unblock": "Desbloquear", + "no_friends_added": "Todavía no tienes amigos añadidos", + "pending": "Pendiente", + "no_pending_invites": "No tienes invitaciones pendientes", + "no_blocked_users": "No has bloqueado a ningún usuario", + "friend_code_copied": "Código de amigo copiado" } } diff --git a/src/locales/fa/translation.json b/src/locales/fa/translation.json index 8629332f..2b8cd3fb 100644 --- a/src/locales/fa/translation.json +++ b/src/locales/fa/translation.json @@ -1,4 +1,5 @@ { + "language_name": "فارسی", "home": { "featured": "پیشنهادی", "trending": "پرطرفدار", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index c732b22c..f635f1de 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Français", "home": { "featured": "En vedette", "trending": "Tendance", diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 748ffe28..f68d71bd 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Magyar", "home": { "featured": "Featured", "trending": "Népszerű", diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index bb7f452b..3d0f1edf 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -1,134 +1,256 @@ { + "language_name": "Bahasa Indonesia", + "app": { + "successfully_signed_in": "Berhasil masuk" + }, "home": { "featured": "Unggulan", - "trending": "Trending", - "surprise_me": "Kejutkan Saya", - "no_results": "Tidak ada hasil" + "trending": "Sedang Tren", + "surprise_me": "Kejutkan saya", + "no_results": "Tidak ada hasil ditemukan" }, "sidebar": { "catalogue": "Katalog", "downloads": "Unduhan", "settings": "Pengaturan", - "my_library": "Koleksi saya", + "my_library": "Perpustakaan saya", "downloading_metadata": "{{title}} (Mengunduh metadata…)", - "paused": "{{title}} (Terhenti)", + "paused": "{{title}} (Dijeda)", "downloading": "{{title}} ({{percentage}} - Mengunduh…)", - "filter": "Filter koleksi", - "home": "Beranda" + "filter": "Filter perpustakaan", + "home": "Beranda", + "queued": "{{title}} (Antrian)", + "game_has_no_executable": "Game tidak punya file eksekusi yang dipilih", + "sign_in": "Masuk" }, "header": { - "search": "Pencarian", + "search": "Cari game", "home": "Beranda", "catalogue": "Katalog", "downloads": "Unduhan", "search_results": "Hasil pencarian", - "settings": "Pengaturan" + "settings": "Pengaturan", + "version_available_install": "Versi {{version}} tersedia. Klik di sini untuk restart dan instal.", + "version_available_download": "Versi {{version}} tersedia. Klik di sini untuk unduh." }, "bottom_panel": { - "no_downloads_in_progress": "Tidak ada unduhan berjalan", - "downloading_metadata": "Mengunduh metadata {{title}}...", - "downloading": "Mengunduh {{title}}… ({{percentage}} selesai) - Perkiraan {{eta}} - {{speed}}" + "no_downloads_in_progress": "Tidak ada unduhan yang sedang berjalan", + "downloading_metadata": "Mengunduh metadata {{title}}…", + "downloading": "Mengunduh {{title}}… ({{percentage}} selesai) - Estimasi selesai {{eta}} - {{speed}}", + "calculating_eta": "Mengunduh {{title}}… ({{percentage}} selesai) - Menghitung waktu yang tersisa…", + "checking_files": "Memeriksa file {{title}}… ({{percentage}} selesai)" }, "catalogue": { - "next_page": "Halaman berikutnya", - "previous_page": "Halaman sebelumnya" + "next_page": "Halaman Berikutnya", + "previous_page": "Halaman Sebelumnya" }, "game_details": { "open_download_options": "Buka opsi unduhan", "download_options_zero": "Tidak ada opsi unduhan", "download_options_one": "{{count}} opsi unduhan", "download_options_other": "{{count}} opsi unduhan", - "updated_at": "Diperbarui {{updated_at}}", - "install": "Install", + "updated_at": "Diperbarui pada {{updated_at}}", + "install": "Instal", "resume": "Lanjutkan", - "pause": "Hentikan sementara", - "cancel": "Batalkan", + "pause": "Jeda", + "cancel": "Batal", "remove": "Hapus", - "space_left_on_disk": "{{space}} tersisa pada disk", - "eta": "Perkiraan {{eta}}", + "space_left_on_disk": "{{space}} tersisa di disk", + "eta": "Estimasi {{eta}}", + "calculating_eta": "Menghitung waktu yang tersisa…", "downloading_metadata": "Mengunduh metadata…", - "filter": "Saring repacks", - "requirements": "Keperluan sistem", + "filter": "Filter repack", + "requirements": "Persyaratan sistem", "minimum": "Minimum", - "recommended": "Rekomendasi", + "recommended": "Dianjurkan", + "paused": "Dijeda", "release_date": "Dirilis pada {{date}}", - "publisher": "Dipublikasikan oleh {{publisher}}", + "publisher": "Diterbitkan oleh {{publisher}}", "hours": "jam", "minutes": "menit", "amount_hours": "{{amount}} jam", "amount_minutes": "{{amount}} menit", "accuracy": "{{accuracy}}% akurasi", - "add_to_library": "Tambahkan ke koleksi", - "remove_from_library": "Hapus dari koleksi", - "no_downloads": "Tidak ada unduhan tersedia", + "add_to_library": "Tambah ke perpustakaan", + "remove_from_library": "Hapus dari perpustakaan", + "no_downloads": "Tidak ada yang bisa diunduh", "play_time": "Dimainkan selama {{amount}}", "last_time_played": "Terakhir dimainkan {{period}}", "not_played_yet": "Kamu belum memainkan {{title}}", - "next_suggestion": "Rekomendasi berikutnya", - "play": "Mainkan", + "next_suggestion": "Saran berikutnya", + "play": "Main", "deleting": "Menghapus installer…", "close": "Tutup", - "playing_now": "Memainkan sekarang", + "playing_now": "Sedang dimainkan", "change": "Ubah", - "repacks_modal_description": "Pilih repack yang kamu ingin unduh", - "select_folder_hint": "Untuk merubah folder bawaan, akses melalui", - "download_now": "Unduh sekarang" + "repacks_modal_description": "Pilih repack yang ingin kamu unduh", + "select_folder_hint": "Untuk ganti folder default, buka <0>Pengaturan", + "download_now": "Unduh sekarang", + "no_shop_details": "Gagal mendapatkan detail toko.", + "download_options": "Opsi unduhan", + "download_path": "Path unduhan", + "previous_screenshot": "Screenshot sebelumnya", + "next_screenshot": "Screenshot berikutnya", + "screenshot": "Screenshot {{number}}", + "open_screenshot": "Buka screenshot {{number}}", + "download_settings": "Pengaturan unduhan", + "downloader": "Pengunduh", + "select_executable": "Pilih", + "no_executable_selected": "Tidak ada file eksekusi yang dipilih", + "open_folder": "Buka folder", + "open_download_location": "Lihat file yang diunduh", + "create_shortcut": "Buat pintasan desktop", + "remove_files": "Hapus file", + "remove_from_library_title": "Apa kamu yakin?", + "remove_from_library_description": "Ini akan menghapus {{game}} dari perpustakaan kamu", + "options": "Opsi", + "executable_section_title": "Eksekusi", + "executable_section_description": "Path file eksekusi saat \"Main\" diklik", + "downloads_secion_title": "Unduhan", + "downloads_section_description": "Cek update atau versi lain dari game ini", + "danger_zone_section_title": "Zona Berbahaya", + "danger_zone_section_description": "Hapus game ini dari perpustakaan kamu atau file yang diunduh oleh Hydra", + "download_in_progress": "Sedang mengunduh", + "download_paused": "Unduhan dijeda", + "last_downloaded_option": "Opsi terakhir diunduh", + "create_shortcut_success": "Pintasan berhasil dibuat", + "create_shortcut_error": "Gagal membuat pintasan" }, "activation": { - "title": "Aktivasi Hydra", - "installation_id": "ID instalasi:", - "enter_activation_code": "Masukkan kode aktivasi", - "message": "Jika kamu tidak tau dimana bertanya untuk ini, maka kamu tidak seharusnya memiliki ini.", + "title": "Aktifkan Hydra", + "installation_id": "ID Instalasi:", + "enter_activation_code": "Masukkan kode aktivasi kamu", + "message": "Kalau tidak tahu harus tanya ke siapa, berarti kamu tidak perlu ini.", "activate": "Aktifkan", "loading": "Memuat…" }, "downloads": { "resume": "Lanjutkan", - "pause": "Hentikan sementara", - "eta": "Perkiraan {{eta}}", - "paused": "Terhenti sementara", - "verifying": "Memeriksa…", + "pause": "Jeda", + "eta": "Estimasi {{eta}}", + "paused": "Dijeda", + "verifying": "Verifikasi…", "completed": "Selesai", - "cancel": "Batalkan", - "filter": "Saring game yang diunduh", + "removed": "Tidak diunduh", + "cancel": "Batal", + "filter": "Filter game yang diunduh", "remove": "Hapus", "downloading_metadata": "Mengunduh metadata…", - "deleting": "Menghapus file instalasi…", - "delete": "Hapus file instalasi", - "delete_modal_title": "Kamu yakin?", - "delete_modal_description": "Proses ini akan menghapus semua file instalasi dari komputer kamu", - "install": "Install" + "deleting": "Menghapus installer…", + "delete": "Hapus installer", + "delete_modal_title": "Apa kamu yakin?", + "delete_modal_description": "Ini akan menghapus semua file instalasi dari komputer kamu", + "install": "Instal", + "download_in_progress": "Sedang berlangsung", + "queued_downloads": "Unduhan dalam antrian", + "downloads_completed": "Selesai", + "queued": "Dalam antrian", + "no_downloads_title": "Kosong", + "no_downloads_description": "Kamu belum mengunduh apa pun dengan Hydra, tapi belum terlambat untuk mulai.", + "checking_files": "Memeriksa file…" }, "settings": { - "downloads_path": "Lokasi unduhan", - "change": "Perbarui", - "notifications": "Pengingat", + "downloads_path": "Path unduhan", + "change": "Ganti", + "notifications": "Notifikasi", "enable_download_notifications": "Saat unduhan selesai", - "enable_repack_list_notifications": "Saat repack terbaru ditambahkan", + "enable_repack_list_notifications": "Saat ada repack baru", + "real_debrid_api_token_label": "Token API Real-Debrid", + "quit_app_instead_hiding": "Jangan sembunyikan Hydra saat ditutup", + "launch_with_system": "Jalankan Hydra saat sistem dinyalakan", + "general": "Umum", "behavior": "Perilaku", - "quit_app_instead_hiding": "Tutup aplikasi alih-alih menyembunyikan aplikasi", - "launch_with_system": "Jalankan saat memulai sistem" + "download_sources": "Sumber unduhan", + "language": "Bahasa", + "real_debrid_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", + "real_debrid_free_account_error": "Akun \"{{username}}\" adalah akun gratis. Silakan berlangganan Real-Debrid", + "real_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.", + "validate_download_source": "Validasi", + "remove_download_source": "Hapus", + "add_download_source": "Tambahkan sumber", + "download_count_zero": "Tidak ada unduhan dalam daftar", + "download_count_one": "{{countFormatted}} unduhan dalam daftar", + "download_count_other": "{{countFormatted}} unduhan dalam daftar", + "download_options_zero": "Tidak ada unduhan tersedia", + "download_options_one": "{{countFormatted}} unduhan tersedia", + "download_options_other": "{{countFormatted}} unduhan tersedia", + "download_source_url": "URL sumber unduhan", + "add_download_source_description": "Masukkan URL yang berisi file .json", + "download_source_up_to_date": "Terkini", + "download_source_errored": "Terjadi kesalahan", + "sync_download_sources": "Sinkronkan sumber", + "removed_download_source": "Sumber unduhan dihapus", + "added_download_source": "Sumber unduhan ditambahkan", + "download_sources_synced": "Semua sumber unduhan disinkronkan", + "insert_valid_json_url": "Masukkan URL JSON yang valid", + "found_download_option_zero": "Tidak ada opsi unduhan ditemukan", + "found_download_option_one": "Ditemukan {{countFormatted}} opsi unduhan", + "found_download_option_other": "Ditemukan {{countFormatted}} opsi unduhan", + "import": "Impor" }, "notifications": { "download_complete": "Unduhan selesai", - "game_ready_to_install": "{{title}} sudah siap untuk instalasi", + "game_ready_to_install": "{{title}} siap untuk diinstal", "repack_list_updated": "Daftar repack diperbarui", "repack_count_one": "{{count}} repack ditambahkan", - "repack_count_other": "{{count}} repack ditambahkan" + "repack_count_other": "{{count}} repack ditambahkan", + "new_update_available": "Versi {{version}} tersedia", + "restart_to_install_update": "Restart Hydra untuk instal pembaruan" }, "system_tray": { "open": "Buka Hydra", - "quit": "Tutup" + "quit": "Keluar" }, "game_card": { - "no_downloads": "Tidak ada unduhan tersedia" + "no_downloads": "Tidak ada unduhan yang tersedia" }, "binary_not_found_modal": { - "title": "Program tidak terinstal", - "description": "Wine atau Lutris exe tidak ditemukan pada sistem kamu", - "instructions": "Periksa cara instalasi yang benar pada Linux distro-mu agar game dapat dimainkan dengan benar" + "title": "Program tidak terpasang", + "description": "Executable Wine atau Lutris tidak ditemukan di sistem kamu", + "instructions": "Cek cara instalasi yang benar di distro Linux kamu agar game bisa jalan normal" }, "modal": { - "close": "Tombol tutup" + "close": "Tutup" + }, + "forms": { + "toggle_password_visibility": "Tampilkan/Sembunyikan kata sandi" + }, + "user_profile": { + "amount_hours": "{{amount}} jam", + "amount_minutes": "{{amount}} menit", + "last_time_played": "Terakhir dimainkan {{period}}", + "activity": "Aktivitas terbaru", + "library": "Perpustakaan", + "total_play_time": "Total waktu bermain: {{amount}}", + "no_recent_activity_title": "Hmm… kosong di sini", + "no_recent_activity_description": "Kamu belum main game baru-baru ini. Yuk, mulai main!", + "display_name": "Nama tampilan", + "saving": "Menyimpan", + "save": "Simpan", + "edit_profile": "Edit Profil", + "saved_successfully": "Berhasil disimpan", + "try_again": "Coba lagi yuk", + "sign_out_modal_title": "Apa kamu yakin?", + "cancel": "Batal", + "successfully_signed_out": "Berhasil keluar", + "sign_out": "Keluar", + "playing_for": "Bermain selama {{amount}}", + "sign_out_modal_text": "Perpustakaan kamu terhubung dengan akun saat ini. Saat keluar, perpustakaan kamu tidak akan terlihat lagi, dan progres tidak akan disimpan. Lanjutkan keluar?", + "add_friends": "Tambah Teman", + "add": "Tambah", + "friend_code": "Kode teman", + "see_profile": "Lihat profil", + "sending": "Mengirim", + "friend_request_sent": "Permintaan teman terkirim", + "friends": "Teman", + "friends_list": "Daftar teman", + "user_not_found": "Pengguna tidak ditemukan" } } diff --git a/src/locales/index.ts b/src/locales/index.ts index 10f128ae..ea0783c2 100644 --- a/src/locales/index.ts +++ b/src/locales/index.ts @@ -1,21 +1,47 @@ -export { default as en } from "./en/translation.json"; -export { default as pt } from "./pt/translation.json"; -export { default as es } from "./es/translation.json"; -export { default as nl } from "./nl/translation.json"; -export { default as fr } from "./fr/translation.json"; -export { default as hu } from "./hu/translation.json"; -export { default as it } from "./it/translation.json"; -export { default as pl } from "./pl/translation.json"; -export { default as ru } from "./ru/translation.json"; -export { default as tr } from "./tr/translation.json"; -export { default as be } from "./be/translation.json"; -export { default as uk } from "./uk/translation.json"; -export { default as zh } from "./zh/translation.json"; -export { default as id } from "./id/translation.json"; -export { default as ko } from "./ko/translation.json"; -export { default as da } from "./da/translation.json"; -export { default as ar } from "./ar/translation.json"; -export { default as fa } from "./fa/translation.json"; -export { default as ro } from "./ro/translation.json"; -export { default as ca } from "./ca/translation.json"; -export { default as kk } from "./kk/translation.json"; +import en from "./en/translation.json"; +import ptPT from "./pt-PT/translation.json"; +import ptBR from "./pt-BR/translation.json"; +import es from "./es/translation.json"; +import nl from "./nl/translation.json"; +import fr from "./fr/translation.json"; +import hu from "./hu/translation.json"; +import it from "./it/translation.json"; +import pl from "./pl/translation.json"; +import ru from "./ru/translation.json"; +import tr from "./tr/translation.json"; +import be from "./be/translation.json"; +import uk from "./uk/translation.json"; +import zh from "./zh/translation.json"; +import id from "./id/translation.json"; +import ko from "./ko/translation.json"; +import da from "./da/translation.json"; +import ar from "./ar/translation.json"; +import fa from "./fa/translation.json"; +import ro from "./ro/translation.json"; +import ca from "./ca/translation.json"; +import kk from "./kk/translation.json"; + +export default { + "pt-BR": ptBR, + "pt-PT": ptPT, + en, + es, + nl, + fr, + hu, + it, + pl, + ru, + tr, + be, + uk, + zh, + id, + ko, + da, + ar, + fa, + ro, + ca, + kk, +}; diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 55f21310..1d5145f9 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Italiano", "home": { "featured": "In primo piano", "trending": "Di tendenza", diff --git a/src/locales/kk/translation.json b/src/locales/kk/translation.json index d565e3b7..15683eb2 100644 --- a/src/locales/kk/translation.json +++ b/src/locales/kk/translation.json @@ -1,4 +1,5 @@ { + "language_name": "қазақ тілі", "app": { "successfully_signed_in": "Сәтті кіру" }, diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index 3d45bb88..933c7dde 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -1,4 +1,5 @@ { + "language_name": "한국어", "home": { "featured": "추천", "trending": "인기", diff --git a/src/locales/nl/translation.json b/src/locales/nl/translation.json index 59cf13e6..6f02c9a3 100644 --- a/src/locales/nl/translation.json +++ b/src/locales/nl/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Nederlands", "home": { "featured": "Uitgelicht", "trending": "Trending", diff --git a/src/locales/pl/translation.json b/src/locales/pl/translation.json index a8e9bdc7..5eb2c242 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Polski", "home": { "featured": "Wyróżnione", "trending": "Trendujące", diff --git a/src/locales/pt/translation.json b/src/locales/pt-BR/translation.json similarity index 89% rename from src/locales/pt/translation.json rename to src/locales/pt-BR/translation.json index 568116f8..1adac376 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt-BR/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Português (Brasil)", "app": { "successfully_signed_in": "Autenticado com sucesso" }, @@ -166,7 +167,7 @@ "real_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 fonte habilitadas. A URL da fonte deve ser um link direto para um arquivo .json contendo uma lista de links.", + "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.", "validate_download_source": "Validar", "remove_download_source": "Remover", "add_download_source": "Adicionar fonte", @@ -250,6 +251,30 @@ "add": "Adicionar", "sending": "Enviando", "friends_list": "Lista de amigos", - "user_not_found": "Usuário não encontrado" + "user_not_found": "Usuário não encontrado", + "block_user": "Bloquear", + "add_friend": "Adicionar amigo", + "request_sent": "Pedido enviado", + "request_received": "Pedido recebido", + "accept_request": "Aceitar pedido", + "ignore_request": "Ignorar pedido", + "cancel_request": "Cancelar pedido", + "undo_friendship": "Desfazer amizade", + "request_accepted": "Pedido de amizade aceito", + "user_blocked_successfully": "Usuário bloqueado com sucesso", + "user_block_modal_text": "Bloquear {{displayName}}", + "settings": "Configurações", + "privacy": "Privacidade", + "private": "Privado", + "friends_only": "Apenas amigos", + "public": "Público", + "blocked_users": "Usuários bloqueados", + "unblock": "Desbloquear", + "no_friends_added": "Você ainda não possui amigos adicionados", + "pending": "Pendentes", + "no_pending_invites": "Você não possui convites de amizade pendentes", + "no_blocked_users": "Você não tem nenhum usuário bloqueado", + "friend_code_copied": "Código de amigo copiado", + "undo_friendship_modal_text": "Isso irá remover sua amizade com {{displayName}}" } } diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json new file mode 100644 index 00000000..67f99921 --- /dev/null +++ b/src/locales/pt-PT/translation.json @@ -0,0 +1,281 @@ +{ + "language_name": "Português (Portugal)", + "app": { + "successfully_signed_in": "Sessão iniciada com sucesso" + }, + "home": { + "featured": "Destaques", + "trending": "Populares", + "surprise_me": "Surpreende-me", + "no_results": "Nenhum resultado encontrado" + }, + "sidebar": { + "catalogue": "Catálogo", + "downloads": "Transferências", + "settings": "Definições", + "my_library": "Biblioteca", + "downloading_metadata": "{{title}} (A transferir metadados…)", + "paused": "{{title}} (Pausado)", + "downloading": "{{title}} ({{percentage}} - A transferir…)", + "filter": "Procurar", + "home": "Início", + "queued": "{{title}} (Na fila)", + "game_has_no_executable": "Jogo não tem executável selecionado", + "sign_in": "Iniciar sessão" + }, + "header": { + "search": "Procurar jogos", + "catalogue": "Catálogo", + "downloads": "Transferências", + "search_results": "Resultados da pesquisa", + "settings": "Definições", + "home": "Início", + "version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.", + "version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download." + }, + "bottom_panel": { + "no_downloads_in_progress": "Sem transferências em andamento", + "downloading_metadata": "A transferir metadados de {{title}}…", + "downloading": "A transferir {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}", + "calculating_eta": "A transferir {{title}}… ({{percentage}} concluído) - A calcular tempo restante…", + "checking_files": "A verificar ficheiros de {{title}}…" + }, + "game_details": { + "open_download_options": "Ver opções de transferência", + "download_options_zero": "Sem opções de transferência", + "download_options_one": "{{count}} opção de transferência", + "download_options_other": "{{count}} opções de transferência", + "updated_at": "Atualizado a {{updated_at}}", + "resume": "Retomar", + "pause": "Pausar", + "cancel": "Cancelar", + "remove": "Remover", + "space_left_on_disk": "{{space}} livres no disco", + "eta": "Conclusão {{eta}}", + "calculating_eta": "A calcular tempo restante…", + "downloading_metadata": "A transferir metadados…", + "filter": "Filtrar repacks", + "requirements": "Requisitos do sistema", + "minimum": "Mínimos", + "recommended": "Recomendados", + "paused": "Pausado", + "release_date": "Lançado em {{date}}", + "publisher": "Publicado por {{publisher}}", + "hours": "horas", + "minutes": "minutos", + "amount_hours": "{{amount}} horas", + "amount_minutes": "{{amount}} minutos", + "accuracy": "{{accuracy}}% de precisão", + "add_to_library": "Adicionar à biblioteca", + "remove_from_library": "Remover da biblioteca", + "no_downloads": "Nenhuma transferência disponível", + "play_time": "Jogou por {{amount}}", + "next_suggestion": "Próxima sugestão", + "install": "Instalar", + "last_time_played": "Última sessão {{period}}", + "play": "Jogar", + "not_played_yet": "Ainda não jogou {{title}}", + "close": "Fechar", + "deleting": "A eliminar instalador…", + "playing_now": "A jogar agora", + "change": "Explorar", + "repacks_modal_description": "Escolha o repack do jogo que deseja transferir", + "select_folder_hint": "Para trocar o diretório padrão, aceda à <0>Tela de Definições", + "download_now": "Iniciar transferência", + "no_shop_details": "Não foi possível obter os detalhes da loja.", + "download_options": "Opções de transferência", + "download_path": "Diretório de transferência", + "previous_screenshot": "Captura de ecrã anterior", + "next_screenshot": "Próxima captura de ecrã", + "screenshot": "Captura de ecrã {{number}}", + "open_screenshot": "Ver captura de ecrã {{number}}", + "download_settings": "Definições de transferência", + "downloader": "Downloader", + "select_executable": "Explorar", + "no_executable_selected": "Nenhum executável selecionado", + "open_folder": "Abrir pasta", + "open_download_location": "Ver ficheiros transferidos", + "create_shortcut": "Criar atalho no ambiente de trabalho", + "remove_files": "Remover ficheiros", + "options": "Gerir", + "remove_from_library_description": "Isto irá remover {{game}} da sua biblioteca", + "remove_from_library_title": "Tem a certeza?", + "executable_section_title": "Executável", + "executable_section_description": "O caminho do ficheiro que será executado ao clicar em \"Jogar\"", + "downloads_secion_title": "Transferências", + "downloads_section_description": "Confira atualizações ou versões diferentes para este mesmo título", + "danger_zone_section_title": "Zona de perigo", + "danger_zone_section_description": "Remova o jogo da sua biblioteca ou os ficheiros que foram transferidos pelo Hydra", + "download_in_progress": "Transferência em andamento", + "download_paused": "Transferência pausada", + "last_downloaded_option": "Última opção transferida", + "create_shortcut_success": "Atalho criado com sucesso", + "create_shortcut_error": "Erro ao criar atalho" + }, + "activation": { + "title": "Ativação", + "installation_id": "ID da instalação:", + "enter_activation_code": "Insira o seu código de ativação", + "message": "Se não sabe onde conseguir o código, talvez não devesse estar aqui.", + "activate": "Ativar", + "loading": "A carregar…" + }, + "downloads": { + "resume": "Retomar", + "pause": "Pausar", + "eta": "Conclusão {{eta}}", + "paused": "Pausado", + "verifying": "A verificar…", + "completed": "Concluído", + "removed": "Cancelado", + "cancel": "Cancelar", + "filter": "Filtrar jogos transferidos", + "remove": "Remover", + "downloading_metadata": "A transferir metadados…", + "delete": "Remover instalador", + "delete_modal_description": "Isto removerá todos os ficheiros de instalação do seu computador", + "delete_modal_title": "Tem a certeza?", + "deleting": "A eliminar instalador…", + "install": "Instalar", + "download_in_progress": "A transferir agora", + "queued_downloads": "Na fila", + "downloads_completed": "Concluído", + "queued": "Na fila", + "no_downloads_title": "Nada por aqui…", + "no_downloads_description": "Ainda não transferiu nada pelo Hydra, mas nunca é tarde para começar.", + "checking_files": "A verificar ficheiros…" + }, + "settings": { + "downloads_path": "Diretório das transferências", + "change": "Explorar...", + "notifications": "Notificações", + "enable_download_notifications": "Quando uma transferência for concluída", + "enable_repack_list_notifications": "Quando a lista de repacks for atualizada", + "real_debrid_api_token_label": "Token de API do Real-Debrid", + "quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.", + "launch_with_system": "Iniciar o Hydra com o sistema", + "general": "Geral", + "behavior": "Comportamento", + "download_sources": "Fontes de transferência", + "language": "Idioma", + "real_debrid_api_token": "Token de API", + "enable_real_debrid": "Ativar Real-Debrid", + "real_debrid_api_token_hint": "Pode obter o seu token de API <0>aqui", + "real_debrid_description": "O Real-Debrid é um downloader sem restrições que permite transferir ficheiros instantaneamente e com a melhor velocidade da sua Internet.", + "real_debrid_invalid_token": "Token de API inválido", + "real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, subscreva o Real-Debrid", + "real_debrid_linked_message": "Conta \"{{username}}\" vinculada", + "save_changes": "Guardar alterações", + "changes_saved": "Definições guardadas com sucesso", + "download_sources_description": "O Hydra vai procurar links de transferência em todas as fontes ativadas. A URL da página de detalhes da loja não é guardada no seu dispositivo. Utilizamos um sistema de metadados criado pela comunidade para fornecer suporte a mais fontes de transferência de jogos.", + "enable_source": "Ativar", + "disable_source": "Desativar", + "validate_download_source": "Validar", + "remove_download_source": "Remover", + "add_download_source": "Adicionar fonte", + "download_count_zero": "Sem transferências na lista", + "download_count_one": "{{countFormatted}} transferência na lista", + "download_count_other": "{{countFormatted}} transferências na lista", + "download_options_zero": "Sem transferências disponíveis", + "download_options_one": "{{countFormatted}} transferência disponível", + "download_options_other": "{{countFormatted}} transferências disponíveis", + "download_source_url": "URL da fonte", + "add_download_source_description": "Insira o URL contendo o arquivo .json", + "download_source_up_to_date": "Sincronizada", + "download_source_errored": "Falhou", + "sync_download_sources": "Sincronizar", + "removed_download_source": "Fonte removida", + "added_download_source": "Fonte adicionada", + "download_sources_synced": "As fontes foram sincronizadas", + "insert_valid_json_url": "Insira o URL de um JSON válido", + "found_download_option_zero": "Nenhuma opção de transferência encontrada", + "found_download_option_one": "{{countFormatted}} opção de transferência encontrada", + "found_download_option_other": "{{countFormatted}} opções de transferências encontradas", + "import": "Importar" + }, + "notifications": { + "download_complete": "Transferência concluída", + "game_ready_to_install": "{{title}} está pronto para ser descarregado", + "repack_list_updated": "Lista de repacks atualizada", + "repack_count_one": "{{count}} novo repack", + "repack_count_other": "{{count}} novos repacks", + "new_update_available": "Versão {{version}} disponível", + "restart_to_install_update": "Reinicie o Hydra para instalar a nova versão" + }, + "system_tray": { + "open": "Abrir Hydra", + "quit": "Fechar" + }, + "game_card": { + "no_downloads": "Sem transferências disponíveis" + }, + "binary_not_found_modal": { + "title": "Programas não instalados", + "description": "Os executáveis do Wine ou Lutris não foram encontrados em seu sistema.", + "instructions": "Verifique a forma correta de instalar algum deles na sua distro Linux, garantindo assim a execução normal do jogo." + }, + "catalogue": { + "next_page": "Próxima página", + "previous_page": "Página anterior" + }, + "modal": { + "close": "Botão de fechar" + }, + "forms": { + "toggle_password_visibility": "Alternar visibilidade da palavra-passe" + }, + "user_profile": { + "amount_hours": "{{amount}} horas", + "amount_minutes": "{{amount}} minutos", + "last_time_played": "Última sessão {{period}}", + "activity": "Atividades recentes", + "library": "Biblioteca", + "total_play_time": "Tempo total de jogo: {{amount}}", + "no_recent_activity_title": "Hmmm… nada por aqui", + "no_recent_activity_description": "Parece que não jogaste nada recentemente. Que tal começar agora?", + "display_name": "Nome de exibição", + "saving": "a guardar…", + "save": "Guardar", + "edit_profile": "Editar perfil", + "saved_successfully": "Guardado com sucesso", + "try_again": "Por favor, tenta novamente", + "cancel": "Cancelar", + "successfully_signed_out": "Terminado com sucesso", + "sign_out": "Terminar sessão", + "sign_out_modal_title": "Tens a certeza?", + "playing_for": "A jogar há {{amount}}", + "sign_out_modal_text": "A tua biblioteca de jogos está associada com a tua conta atual. Ao sair, a tua biblioteca não aparecerá mais no Hydra e qualquer progresso não será guardado. Desejas continuar?", + "add_friends": "Adicionar Amigos", + "friend_code": "Código de amigo", + "see_profile": "Ver perfil", + "friend_request_sent": "Pedido de amizade enviado", + "friends": "Amigos", + "add": "Adicionar", + "sending": "A enviar", + "friends_list": "Lista de amigos", + "user_not_found": "Utilizador não encontrado", + "block_user": "Bloquear", + "add_friend": "Adicionar amigo", + "request_sent": "Pedido enviado", + "request_received": "Pedido recebido", + "accept_request": "Aceitar pedido", + "ignore_request": "Ignorar pedido", + "cancel_request": "Cancelar pedido", + "undo_friendship": "Desfazer amizade", + "request_accepted": "Pedido de amizade aceito", + "user_blocked_successfully": "Utilizador bloqueado com sucesso", + "user_block_modal_text": "Bloquear {{displayName}}", + "settings": "Definições", + "privacy": "Privacidade", + "private": "Privado", + "friends_only": "Apenas amigos", + "public": "Público", + "blocked_users": "Utilizadores bloqueados", + "unblock": "Desbloquear", + "no_friends_added": "Ainda não adicionaste amigos", + "pending": "Pendentes", + "no_pending_invites": "Não tens convites de amizade pendentes", + "no_blocked_users": "Não tens nenhum utilizador bloqueado", + "friend_code_copied": "Código de amigo copiado" + } +} diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json index 2aed7a7f..9fab3119 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Română", "home": { "featured": "Recomandate", "trending": "Populare", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index f6f18d11..be3a000e 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Русский", "app": { "successfully_signed_in": "Успешный вход" }, @@ -177,9 +178,6 @@ "download_count_zero": "В списке нет загрузок", "download_count_one": "{{countFormatted}} загрузка в списке", "download_count_other": "{{countFormatted}} загрузок в списке", - "download_options_zero": "Нет доступных загрузок", - "download_options_one": "{{countFormatted}} вариант загрузки доступен", - "download_options_other": "{{countFormatted}} вариантов загрузки доступно", "download_source_url": "Ссылка на источник", "add_download_source_description": "Вставьте ссылку на .json-файл", "download_source_up_to_date": "Обновлён", @@ -241,6 +239,38 @@ "successfully_signed_out": "Успешный выход из аккаунта", "sign_out": "Выйти", "playing_for": "Сыграно {{amount}}", - "sign_out_modal_text": "Ваша библиотека связана с текущей учетной записью. При выходе из системы ваша библиотека станет недоступна, и прогресс не будет сохранен. Выйти?" + "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}} будет заблокирован", + "settings": "Настройки", + "public": "Публичный", + "private": "Приватный", + "friends_only": "Только друзья", + "privacy": "Приватность", + "blocked_users": "Заблокированные пользователи", + "unblock": "Разблокировать", + "no_friends_added": "Вы ещё не добавили ни одного друга", + "pending": "Ожидание", + "no_pending_invites": "У вас нет запросов ожидающих ответа", + "no_blocked_users": "Вы не заблокировали ни одного пользователя", + "friend_code_copied": "Код друга скопирован" } } diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index 150eda84..2da9c977 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Türkçe", "home": { "featured": "Öne çıkan", "trending": "Popüler", diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index 48dec3e4..bb840bc2 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -1,4 +1,5 @@ { + "language_name": "Українська", "app": { "successfully_signed_in": "Успішний вхід в систему" }, diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index e0284b8d..0c793172 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1,4 +1,5 @@ { + "language_name": "中文", "app": { "successfully_signed_in": "已成功登录" }, diff --git a/src/main/data-source.ts b/src/main/data-source.ts index b47ce2c0..29c72f8c 100644 --- a/src/main/data-source.ts +++ b/src/main/data-source.ts @@ -6,32 +6,22 @@ import { GameShopCache, Repack, UserPreferences, + UserAuth, } from "@main/entity"; -import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions"; import { databasePath } from "./constants"; -import migrations from "./migrations"; -import { UserAuth } from "./entity/user-auth"; -export const createDataSource = ( - options: Partial -) => - new DataSource({ - type: "better-sqlite3", - entities: [ - Game, - Repack, - UserPreferences, - GameShopCache, - DownloadSource, - DownloadQueue, - UserAuth, - ], - synchronize: true, - database: databasePath, - ...options, - }); - -export const dataSource = createDataSource({ - migrations, +export const dataSource = new DataSource({ + type: "better-sqlite3", + entities: [ + Game, + Repack, + UserPreferences, + GameShopCache, + DownloadSource, + DownloadQueue, + UserAuth, + ], + synchronize: false, + database: databasePath, }); diff --git a/src/main/declaration.d.ts b/src/main/declaration.d.ts deleted file mode 100644 index ac2675a3..00000000 --- a/src/main/declaration.d.ts +++ /dev/null @@ -1,80 +0,0 @@ -declare module "aria2" { - export type Aria2Status = - | "active" - | "waiting" - | "paused" - | "error" - | "complete" - | "removed"; - - export interface StatusResponse { - gid: string; - status: Aria2Status; - totalLength: string; - completedLength: string; - uploadLength: string; - bitfield: string; - downloadSpeed: string; - uploadSpeed: string; - infoHash?: string; - numSeeders?: string; - seeder?: boolean; - pieceLength: string; - numPieces: string; - connections: string; - errorCode?: string; - errorMessage?: string; - followedBy?: string[]; - following: string; - belongsTo: string; - dir: string; - files: { - path: string; - length: string; - completedLength: string; - selected: string; - }[]; - bittorrent?: { - announceList: string[][]; - comment: string; - creationDate: string; - mode: "single" | "multi"; - info: { - name: string; - verifiedLength: string; - verifyIntegrityPending: string; - }; - }; - } - - export default class Aria2 { - constructor(options: any); - open: () => Promise; - call( - method: "addUri", - uris: string[], - options: { dir: string } - ): Promise; - call( - method: "tellStatus", - gid: string, - keys?: string[] - ): Promise; - call(method: "pause", gid: string): Promise; - call(method: "forcePause", gid: string): Promise; - call(method: "unpause", gid: string): Promise; - call(method: "remove", gid: string): Promise; - call(method: "forceRemove", gid: string): Promise; - call(method: "pauseAll"): Promise; - call(method: "forcePauseAll"): Promise; - listNotifications: () => [ - "onDownloadStart", - "onDownloadPause", - "onDownloadStop", - "onDownloadComplete", - "onDownloadError", - "onBtDownloadComplete", - ]; - on: (event: string, callback: (params: any) => void) => void; - } -} diff --git a/src/main/entity/repack.entity.ts b/src/main/entity/repack.entity.ts index 1d5259fd..36de2a7c 100644 --- a/src/main/entity/repack.entity.ts +++ b/src/main/entity/repack.entity.ts @@ -16,15 +16,12 @@ export class Repack { @Column("text", { unique: true }) title: string; + /** + * @deprecated Use uris instead + */ @Column("text", { unique: true }) magnet: string; - /** - * @deprecated - */ - @Column("int", { nullable: true }) - page: number; - @Column("text") repacker: string; @@ -37,6 +34,9 @@ export class Repack { @ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" }) downloadSource: DownloadSource; + @Column("text", { default: "[]" }) + uris: string; + @CreateDateColumn() createdAt: Date; diff --git a/src/main/events/auth/sign-out.ts b/src/main/events/auth/sign-out.ts index fe640b9d..9998c733 100644 --- a/src/main/events/auth/sign-out.ts +++ b/src/main/events/auth/sign-out.ts @@ -26,6 +26,8 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => { /* Disconnects libtorrent */ PythonInstance.killTorrent(); + HydraApi.handleSignOut(); + await Promise.all([ databaseOperations, HydraApi.post("/auth/logout").catch(() => {}), diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts index 8f24caad..b8565645 100644 --- a/src/main/events/download-sources/get-download-sources.ts +++ b/src/main/events/download-sources/get-download-sources.ts @@ -1,16 +1,11 @@ import { downloadSourceRepository } from "@main/repository"; import { registerEvent } from "../register-event"; -const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { - return downloadSourceRepository - .createQueryBuilder("downloadSource") - .leftJoin("downloadSource.repacks", "repacks") - .orderBy("downloadSource.createdAt", "DESC") - .loadRelationCountAndMap( - "downloadSource.repackCount", - "downloadSource.repacks" - ) - .getMany(); -}; +const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => + downloadSourceRepository.find({ + order: { + createdAt: "DESC", + }, + }); registerEvent("getDownloadSources", getDownloadSources); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index dd5e3263..3963e4b0 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -43,16 +43,19 @@ import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; import "./user/get-user"; +import "./user/get-user-blocks"; +import "./user/block-user"; +import "./user/unblock-user"; +import "./user/get-user-friends"; import "./profile/get-friend-requests"; import "./profile/get-me"; +import "./profile/undo-friendship"; import "./profile/update-friend-request"; import "./profile/update-profile"; import "./profile/send-friend-request"; +import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); ipcMain.handle("getVersion", () => app.getVersion()); -ipcMain.handle( - "isPortableVersion", - () => process.env.PORTABLE_EXECUTABLE_FILE != null -); +ipcMain.handle("isPortableVersion", () => isPortableVersion()); ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath); diff --git a/src/main/events/library/remove-game-from-library.ts b/src/main/events/library/remove-game-from-library.ts index 468f5b26..a8fc8b01 100644 --- a/src/main/events/library/remove-game-from-library.ts +++ b/src/main/events/library/remove-game-from-library.ts @@ -20,7 +20,7 @@ const removeRemoveGameFromLibrary = async (gameId: number) => { const game = await gameRepository.findOne({ where: { id: gameId } }); if (game?.remoteId) { - HydraApi.delete(`/games/${game.remoteId}`).catch(() => {}); + HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); } }; diff --git a/src/main/events/profile/undo-friendship.ts b/src/main/events/profile/undo-friendship.ts new file mode 100644 index 00000000..371bc5cc --- /dev/null +++ b/src/main/events/profile/undo-friendship.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const undoFriendship = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +) => { + await HydraApi.delete(`/profile/friends/${userId}`); +}; + +registerEvent("undoFriendship", undoFriendship); diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index 8620eaa1..50d2ab66 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -4,33 +4,22 @@ import axios from "axios"; import fs from "node:fs"; import path from "node:path"; import { fileTypeFromFile } from "file-type"; -import { UserProfile } from "@types"; +import { UpdateProfileProps, UserProfile } from "@types"; -const patchUserProfile = async ( - displayName: string, - profileImageUrl?: string -) => { - if (profileImageUrl) { - return HydraApi.patch("/profile", { - displayName, - profileImageUrl, - }); - } else { - return HydraApi.patch("/profile", { - displayName, - }); - } +const patchUserProfile = async (updateProfile: UpdateProfileProps) => { + return HydraApi.patch("/profile", updateProfile); }; const updateProfile = async ( _event: Electron.IpcMainInvokeEvent, - displayName: string, - newProfileImagePath: string | null + updateProfile: UpdateProfileProps ): Promise => { - if (!newProfileImagePath) { - return patchUserProfile(displayName); + if (!updateProfile.profileImageUrl) { + return patchUserProfile(updateProfile); } + const newProfileImagePath = updateProfile.profileImageUrl; + const stats = fs.statSync(newProfileImagePath); const fileBuffer = fs.readFileSync(newProfileImagePath); const fileSizeInBytes = stats.size; @@ -53,7 +42,7 @@ const updateProfile = async ( }) .catch(() => undefined); - return patchUserProfile(displayName, profileImageUrl); + return patchUserProfile({ ...updateProfile, profileImageUrl }); }; registerEvent("updateProfile", updateProfile); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index cea41596..f4db999f 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -18,7 +18,8 @@ const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, payload: StartGameDownloadPayload ) => { - const { repackId, objectID, title, shop, downloadPath, downloader } = payload; + const { repackId, objectID, title, shop, downloadPath, downloader, uri } = + payload; const [game, repack] = await Promise.all([ gameRepository.findOne({ @@ -54,7 +55,7 @@ const startGameDownload = async ( bytesDownloaded: 0, downloadPath, downloader, - uri: repack.magnet, + uri, isDeleted: false, } ); @@ -76,7 +77,7 @@ const startGameDownload = async ( shop, status: "active", downloadPath, - uri: repack.magnet, + uri, }) .then((result) => { if (iconUrl) { @@ -100,6 +101,7 @@ const startGameDownload = async ( await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); + await DownloadManager.cancelDownload(updatedGame!.id); await DownloadManager.startDownload(updatedGame!); }; diff --git a/src/main/events/user/block-user.ts b/src/main/events/user/block-user.ts new file mode 100644 index 00000000..c81231e5 --- /dev/null +++ b/src/main/events/user/block-user.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const blockUser = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +) => { + await HydraApi.post(`/users/${userId}/block`); +}; + +registerEvent("blockUser", blockUser); diff --git a/src/main/events/user/get-user-blocks.ts b/src/main/events/user/get-user-blocks.ts new file mode 100644 index 00000000..65bb3eb4 --- /dev/null +++ b/src/main/events/user/get-user-blocks.ts @@ -0,0 +1,13 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; +import { UserBlocks } from "@types"; + +export const getUserBlocks = async ( + _event: Electron.IpcMainInvokeEvent, + take: number, + skip: number +): Promise => { + return HydraApi.get(`/profile/blocks`, { take, skip }); +}; + +registerEvent("getUserBlocks", getUserBlocks); diff --git a/src/main/events/user/get-user-friends.ts b/src/main/events/user/get-user-friends.ts new file mode 100644 index 00000000..5ff4c8a4 --- /dev/null +++ b/src/main/events/user/get-user-friends.ts @@ -0,0 +1,29 @@ +import { userAuthRepository } from "@main/repository"; +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; +import { UserFriends } from "@types"; + +export const getUserFriends = async ( + userId: string, + take: number, + skip: number +): Promise => { + const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } }); + + if (loggedUser?.userId === userId) { + return HydraApi.get(`/profile/friends`, { take, skip }); + } + + return HydraApi.get(`/users/${userId}/friends`, { take, skip }); +}; + +const getUserFriendsEvent = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string, + take: number, + skip: number +) => { + return getUserFriends(userId, take, skip); +}; + +registerEvent("getUserFriends", getUserFriendsEvent); diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts index 7b4c0aa8..68d69969 100644 --- a/src/main/events/user/get-user.ts +++ b/src/main/events/user/get-user.ts @@ -1,55 +1,76 @@ import { registerEvent } from "../register-event"; import { HydraApi } from "@main/services"; import { steamGamesWorker } from "@main/workers"; -import { UserProfile } from "@types"; +import { GameRunning, UserGame, UserProfile } from "@types"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { getSteamAppAsset } from "@main/helpers"; +import { getUserFriends } from "./get-user-friends"; const getUser = async ( _event: Electron.IpcMainInvokeEvent, userId: string ): Promise => { try { - const profile = await HydraApi.get(`/user/${userId}`); + const [profile, friends] = await Promise.all([ + HydraApi.get(`/users/${userId}`), + getUserFriends(userId, 12, 0).catch(() => { + return { totalFriends: 0, friends: [] }; + }), + ]); const recentGames = await Promise.all( profile.recentGames.map(async (game) => { - const steamGame = await steamGamesWorker.run(Number(game.objectId), { - name: "getById", - }); - const iconUrl = steamGame?.clientIcon - ? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon) - : null; - - return { - ...game, - ...convertSteamGameToCatalogueEntry(steamGame), - iconUrl, - }; + return getSteamUserGame(game); }) ); const libraryGames = await Promise.all( profile.libraryGames.map(async (game) => { - const steamGame = await steamGamesWorker.run(Number(game.objectId), { - name: "getById", - }); - const iconUrl = steamGame?.clientIcon - ? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon) - : null; - - return { - ...game, - ...convertSteamGameToCatalogueEntry(steamGame), - iconUrl, - }; + return getSteamUserGame(game); }) ); - return { ...profile, libraryGames, recentGames }; + const currentGame = await getGameRunning(profile.currentGame); + + return { + ...profile, + libraryGames, + recentGames, + friends: friends.friends, + totalFriends: friends.totalFriends, + currentGame, + }; } catch (err) { return null; } }; +const getGameRunning = async (currentGame): Promise => { + if (!currentGame) { + return null; + } + + const gameRunning = await getSteamUserGame(currentGame); + + return { + ...gameRunning, + sessionDurationInMillis: currentGame.sessionDurationInSeconds * 1000, + }; +}; + +const getSteamUserGame = async (game): Promise => { + const steamGame = await steamGamesWorker.run(Number(game.objectId), { + name: "getById", + }); + const iconUrl = steamGame?.clientIcon + ? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon) + : null; + + return { + ...game, + ...convertSteamGameToCatalogueEntry(steamGame), + iconUrl, + }; +}; + registerEvent("getUser", getUser); diff --git a/src/main/events/user/unblock-user.ts b/src/main/events/user/unblock-user.ts new file mode 100644 index 00000000..c604a0b5 --- /dev/null +++ b/src/main/events/user/unblock-user.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const unblockUser = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +) => { + await HydraApi.post(`/users/${userId}/unblock`); +}; + +registerEvent("unblockUser", unblockUser); diff --git a/src/main/helpers/download-source.ts b/src/main/helpers/download-source.ts index 012a4d24..c216212a 100644 --- a/src/main/helpers/download-source.ts +++ b/src/main/helpers/download-source.ts @@ -17,7 +17,8 @@ export const insertDownloadsFromSource = async ( const repacks: QueryDeepPartialEntity[] = downloads.map( (download) => ({ title: download.title, - magnet: download.uris[0], + uris: JSON.stringify(download.uris), + magnet: download.uris[0]!, fileSize: download.fileSize, repacker: downloadSource.name, uploadDate: download.uploadDate, diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 902b927d..b0ff391f 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { JSDOM } from "jsdom"; import UserAgent from "user-agents"; export const getSteamAppAsset = ( @@ -48,13 +49,19 @@ export const sleep = (ms: number) => export const requestWebPage = async (url: string) => { const userAgent = new UserAgent(); - return axios + const data = await axios .get(url, { headers: { "User-Agent": userAgent.toString(), }, }) .then((response) => response.data); + + const { window } = new JSDOM(data); + return window.document; }; +export const isPortableVersion = () => + process.env.PORTABLE_EXECUTABLE_FILE != null; + export * from "./download-source"; diff --git a/src/main/index.ts b/src/main/index.ts index 9ff74bf6..3c5cc254 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,8 +7,9 @@ import url from "node:url"; import { electronApp, optimizer } from "@electron-toolkit/utils"; import { logger, PythonInstance, WindowManager } from "@main/services"; import { dataSource } from "@main/data-source"; -import * as resources from "@locales"; +import resources from "@locales"; import { userPreferencesRepository } from "@main/repository"; +import { knexClient, migrationConfig } from "./knex-client"; const { autoUpdater } = updater; @@ -20,8 +21,6 @@ autoUpdater.setFeedURL({ autoUpdater.logger = logger; -logger.log("Init Hydra"); - const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) app.quit(); @@ -54,6 +53,18 @@ if (process.defaultApp) { app.setAsDefaultProtocolClient(PROTOCOL); } +const runMigrations = async () => { + await knexClient.migrate.list(migrationConfig).then((result) => { + logger.log( + "Migrations to run:", + result[1].map((migration) => migration.name) + ); + }); + + await knexClient.migrate.latest(migrationConfig); + await knexClient.destroy(); +}; + // 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. @@ -65,8 +76,15 @@ 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 dataSource.runMigrations(); await import("./main"); @@ -88,10 +106,15 @@ app.on("browser-window-created", (_, window) => { const handleDeepLinkPath = (uri?: string) => { if (!uri) return; - const url = new URL(uri); - if (url.host === "install-source") { - WindowManager.redirect(`settings${url.search}`); + try { + const url = new URL(uri); + + if (url.host === "install-source") { + WindowManager.redirect(`settings${url.search}`); + } + } catch (error) { + logger.error("Error handling deep link", uri, error); } }; @@ -123,7 +146,6 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { /* Disconnects libtorrent */ PythonInstance.kill(); - logger.log("Quit Hydra"); }); app.on("activate", () => { diff --git a/src/main/knex-client.ts b/src/main/knex-client.ts new file mode 100644 index 00000000..031760f6 --- /dev/null +++ b/src/main/knex-client.ts @@ -0,0 +1,29 @@ +import knex, { Knex } from "knex"; +import { databasePath } from "./constants"; +import { Hydra2_0_3 } from "./migrations/20240830143811_Hydra_2_0_3"; +import { RepackUris } from "./migrations/20240830143906_RepackUris"; + +export type HydraMigration = Knex.Migration & { name: string }; + +class MigrationSource implements Knex.MigrationSource { + getMigrations(): Promise { + return Promise.resolve([Hydra2_0_3, RepackUris]); + } + getMigrationName(migration: HydraMigration): string { + return migration.name; + } + getMigration(migration: HydraMigration): Promise { + return Promise.resolve(migration); + } +} + +export const knexClient = knex({ + client: "better-sqlite3", + connection: { + filename: databasePath, + }, +}); + +export const migrationConfig: Knex.MigratorConfig = { + migrationSource: new MigrationSource(), +}; diff --git a/src/main/knexfile.ts b/src/main/knexfile.ts new file mode 100644 index 00000000..df7972a9 --- /dev/null +++ b/src/main/knexfile.ts @@ -0,0 +1,10 @@ +const config = { + development: { + migrations: { + extension: "ts", + stub: "migrations/migration.stub", + }, + }, +}; + +export default config; diff --git a/src/main/main.ts b/src/main/main.ts index fbabc56c..af594e20 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -22,8 +22,9 @@ const loadState = async (userPreferences: UserPreferences | null) => { import("./events"); - if (userPreferences?.realDebridApiToken) + if (userPreferences?.realDebridApiToken) { RealDebridClient.authorize(userPreferences?.realDebridApiToken); + } HydraApi.setupApi().then(() => { uploadGamesBatch(); diff --git a/src/main/migrations/1715900413313-fix_repack_uploadDate.ts b/src/main/migrations/1715900413313-fix_repack_uploadDate.ts deleted file mode 100644 index e9d0a6c2..00000000 --- a/src/main/migrations/1715900413313-fix_repack_uploadDate.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class FixRepackUploadDate1715900413313 implements MigrationInterface { - public async up(_: QueryRunner): Promise { - return; - } - - public async down(_: QueryRunner): Promise { - return; - } -} diff --git a/src/main/migrations/1716776027208-alter_lastTimePlayed_to_datime.ts b/src/main/migrations/1716776027208-alter_lastTimePlayed_to_datime.ts deleted file mode 100644 index 6a562915..00000000 --- a/src/main/migrations/1716776027208-alter_lastTimePlayed_to_datime.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Game } from "@main/entity"; -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class AlterLastTimePlayedToDatime1716776027208 - implements MigrationInterface -{ - public async up(queryRunner: QueryRunner): Promise { - // 2024-05-27 02:08:17 - // Mon, 27 May 2024 02:08:17 GMT - const updateLastTimePlayedValues = ` - UPDATE game SET lastTimePlayed = (SELECT - SUBSTR(lastTimePlayed, 13, 4) || '-' || -- Year - CASE SUBSTR(lastTimePlayed, 9, 3) - WHEN 'Jan' THEN '01' - WHEN 'Feb' THEN '02' - WHEN 'Mar' THEN '03' - WHEN 'Apr' THEN '04' - WHEN 'May' THEN '05' - WHEN 'Jun' THEN '06' - WHEN 'Jul' THEN '07' - WHEN 'Aug' THEN '08' - WHEN 'Sep' THEN '09' - WHEN 'Oct' THEN '10' - WHEN 'Nov' THEN '11' - WHEN 'Dec' THEN '12' - END || '-' || -- Month - SUBSTR(lastTimePlayed, 6, 2) || ' ' || -- Day - SUBSTR(lastTimePlayed, 18, 8) -- hh:mm:ss; - FROM game) - WHERE lastTimePlayed IS NOT NULL; - `; - - await queryRunner.query(updateLastTimePlayedValues); - } - - public async down(queryRunner: QueryRunner): Promise { - const queryBuilder = queryRunner.manager.createQueryBuilder(Game, "game"); - - const result = await queryBuilder.getMany(); - - for (const game of result) { - if (!game.lastTimePlayed) continue; - await queryRunner.query( - `UPDATE game set lastTimePlayed = ? WHERE id = ?;`, - [game.lastTimePlayed.toUTCString(), game.id] - ); - } - } -} diff --git a/src/main/migrations/20240830143811_Hydra_2_0_3.ts b/src/main/migrations/20240830143811_Hydra_2_0_3.ts new file mode 100644 index 00000000..6013f714 --- /dev/null +++ b/src/main/migrations/20240830143811_Hydra_2_0_3.ts @@ -0,0 +1,171 @@ +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 new file mode 100644 index 00000000..0785d50d --- /dev/null +++ b/src/main/migrations/20240830143906_RepackUris.ts @@ -0,0 +1,58 @@ +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.createTable("temporary_repack", (table) => { + const timestamp = new Date().getTime(); + 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.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"); + table.text("uris").notNullable().defaultTo("[]"); + }); + await knex.raw( + `INSERT INTO "temporary_repack"("id", "title", "magnet", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId") SELECT "id", "title", "magnet", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId" FROM "repack"` + ); + await knex.schema.dropTable("repack"); + await knex.schema.renameTable("temporary_repack", "repack"); + }, + + down: async (knex: Knex) => { + await knex.schema.renameTable("repack", "temporary_repack"); + await knex.schema.createTable("repack", (table) => { + table.increments("id").primary(); + table.text("title").notNullable().unique(); + table.text("magnet").notNullable().unique(); + 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.raw( + `INSERT INTO "repack"("id", "title", "magnet", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId") SELECT "id", "title", "magnet", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId" FROM "temporary_repack"` + ); + await knex.schema.dropTable("temporary_repack"); + }, +}; diff --git a/src/main/migrations/index.ts b/src/main/migrations/index.ts deleted file mode 100644 index c0c96e45..00000000 --- a/src/main/migrations/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { FixRepackUploadDate1715900413313 } from "./1715900413313-fix_repack_uploadDate"; -import { AlterLastTimePlayedToDatime1716776027208 } from "./1716776027208-alter_lastTimePlayed_to_datime"; - -export default [ - FixRepackUploadDate1715900413313, - AlterLastTimePlayedToDatime1716776027208, -]; diff --git a/src/main/migrations/migration.stub b/src/main/migrations/migration.stub new file mode 100644 index 00000000..9cb0cbab --- /dev/null +++ b/src/main/migrations/migration.stub @@ -0,0 +1,11 @@ +import type { HydraMigration } from "@main/knex-client"; +import type { Knex } from "knex"; + +export const MigrationName: HydraMigration = { + name: "MigrationName", + up: async (knex: Knex) => { + await knex.schema.createTable("table_name", (table) => {}); + }, + + down: async (knex: Knex) => {}, +}; diff --git a/src/main/services/aria2c.ts b/src/main/services/aria2c.ts deleted file mode 100644 index b1b1da76..00000000 --- a/src/main/services/aria2c.ts +++ /dev/null @@ -1,20 +0,0 @@ -import path from "node:path"; -import { spawn } from "node:child_process"; -import { app } from "electron"; - -export const startAria2 = () => { - const binaryPath = app.isPackaged - ? path.join(process.resourcesPath, "aria2", "aria2c") - : path.join(__dirname, "..", "..", "aria2", "aria2c"); - - return spawn( - binaryPath, - [ - "--enable-rpc", - "--rpc-listen-all", - "--file-allocation=none", - "--allow-overwrite=true", - ], - { stdio: "inherit", windowsHide: true } - ); -}; diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 31f28992..d4733a32 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -6,6 +6,8 @@ import { downloadQueueRepository, gameRepository } from "@main/repository"; import { publishDownloadCompleteNotification } from "../notifications"; import { RealDebridDownloader } from "./real-debrid-downloader"; import type { DownloadProgress } from "@types"; +import { GofileApi, QiwiApi } from "../hosters"; +import { GenericHttpDownloader } from "./generic-http-downloader"; export class DownloadManager { private static currentDownloader: Downloader | null = null; @@ -13,10 +15,12 @@ export class DownloadManager { public static async watchDownloads() { let status: DownloadProgress | null = null; - if (this.currentDownloader === Downloader.RealDebrid) { + if (this.currentDownloader === Downloader.Torrent) { + status = await PythonInstance.getStatus(); + } else if (this.currentDownloader === Downloader.RealDebrid) { status = await RealDebridDownloader.getStatus(); } else { - status = await PythonInstance.getStatus(); + status = await GenericHttpDownloader.getStatus(); } if (status) { @@ -62,10 +66,12 @@ export class DownloadManager { } static async pauseDownload() { - if (this.currentDownloader === Downloader.RealDebrid) { + if (this.currentDownloader === Downloader.Torrent) { + await PythonInstance.pauseDownload(); + } else if (this.currentDownloader === Downloader.RealDebrid) { await RealDebridDownloader.pauseDownload(); } else { - await PythonInstance.pauseDownload(); + await GenericHttpDownloader.pauseDownload(); } WindowManager.mainWindow?.setProgressBar(-1); @@ -73,20 +79,16 @@ export class DownloadManager { } static async resumeDownload(game: Game) { - if (game.downloader === Downloader.RealDebrid) { - RealDebridDownloader.startDownload(game); - this.currentDownloader = Downloader.RealDebrid; - } else { - PythonInstance.startDownload(game); - this.currentDownloader = Downloader.Torrent; - } + return this.startDownload(game); } static async cancelDownload(gameId: number) { - if (this.currentDownloader === Downloader.RealDebrid) { + if (this.currentDownloader === Downloader.Torrent) { + PythonInstance.cancelDownload(gameId); + } else if (this.currentDownloader === Downloader.RealDebrid) { RealDebridDownloader.cancelDownload(gameId); } else { - PythonInstance.cancelDownload(gameId); + GenericHttpDownloader.cancelDownload(gameId); } WindowManager.mainWindow?.setProgressBar(-1); @@ -94,12 +96,40 @@ export class DownloadManager { } static async startDownload(game: Game) { - if (game.downloader === Downloader.RealDebrid) { - RealDebridDownloader.startDownload(game); - this.currentDownloader = Downloader.RealDebrid; - } else { - PythonInstance.startDownload(game); - this.currentDownloader = Downloader.Torrent; + switch (game.downloader) { + case Downloader.Gofile: { + const id = game!.uri!.split("/").pop(); + + const token = await GofileApi.authorize(); + const downloadLink = await GofileApi.getDownloadLink(id!); + + GenericHttpDownloader.startDownload(game, downloadLink, { + Cookie: `accountToken=${token}`, + }); + break; + } + case Downloader.PixelDrain: { + const id = game!.uri!.split("/").pop(); + + await GenericHttpDownloader.startDownload( + game, + `https://pixeldrain.com/api/file/${id}?download` + ); + break; + } + case Downloader.Qiwi: { + const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!); + + await GenericHttpDownloader.startDownload(game, downloadUrl); + break; + } + case Downloader.Torrent: + PythonInstance.startDownload(game); + break; + case Downloader.RealDebrid: + RealDebridDownloader.startDownload(game); } + + this.currentDownloader = game.downloader; } } diff --git a/src/main/services/download/generic-http-downloader.ts b/src/main/services/download/generic-http-downloader.ts new file mode 100644 index 00000000..055c8561 --- /dev/null +++ b/src/main/services/download/generic-http-downloader.ts @@ -0,0 +1,109 @@ +import { Game } from "@main/entity"; +import { gameRepository } from "@main/repository"; +import { calculateETA } from "./helpers"; +import { DownloadProgress } from "@types"; +import { HttpDownload } from "./http-download"; + +export class GenericHttpDownloader { + public static downloads = new Map(); + public static downloadingGame: Game | null = null; + + public static async getStatus() { + if (this.downloadingGame) { + const download = this.downloads.get(this.downloadingGame.id)!; + const status = download.getStatus(); + + if (status) { + const progress = + Number(status.completedLength) / Number(status.totalLength); + + await gameRepository.update( + { id: this.downloadingGame!.id }, + { + bytesDownloaded: Number(status.completedLength), + fileSize: Number(status.totalLength), + progress, + status: "active", + folderName: status.folderName, + } + ); + + const result = { + numPeers: 0, + numSeeds: 0, + downloadSpeed: status.downloadSpeed, + timeRemaining: calculateETA( + status.totalLength, + status.completedLength, + status.downloadSpeed + ), + isDownloadingMetadata: false, + isCheckingFiles: false, + progress, + gameId: this.downloadingGame!.id, + } as DownloadProgress; + + if (progress === 1) { + this.downloads.delete(this.downloadingGame.id); + this.downloadingGame = null; + } + + return result; + } + } + + return null; + } + + static async pauseDownload() { + if (this.downloadingGame) { + const httpDownload = this.downloads.get(this.downloadingGame!.id!); + + if (httpDownload) { + await httpDownload.pauseDownload(); + } + + this.downloadingGame = null; + } + } + + static async startDownload( + game: Game, + downloadUrl: string, + headers?: Record + ) { + this.downloadingGame = game; + + if (this.downloads.has(game.id)) { + await this.resumeDownload(game.id!); + return; + } + + const httpDownload = new HttpDownload( + game.downloadPath!, + downloadUrl, + headers + ); + + httpDownload.startDownload(); + + this.downloads.set(game.id!, httpDownload); + } + + static async cancelDownload(gameId: number) { + const httpDownload = this.downloads.get(gameId); + + if (httpDownload) { + await httpDownload.cancelDownload(); + this.downloads.delete(gameId); + } + } + + static async resumeDownload(gameId: number) { + const httpDownload = this.downloads.get(gameId); + + if (httpDownload) { + await httpDownload.resumeDownload(); + } + } +} diff --git a/src/main/services/download/http-download.ts b/src/main/services/download/http-download.ts index 4553a6cb..4f6c31a9 100644 --- a/src/main/services/download/http-download.ts +++ b/src/main/services/download/http-download.ts @@ -1,68 +1,54 @@ -import type { ChildProcess } from "node:child_process"; -import { logger } from "../logger"; -import { sleep } from "@main/helpers"; -import { startAria2 } from "../aria2c"; -import Aria2 from "aria2"; +import { WindowManager } from "../window-manager"; +import path from "node:path"; export class HttpDownload { - private static connected = false; - private static aria2c: ChildProcess | null = null; + private downloadItem: Electron.DownloadItem; - private static aria2 = new Aria2({}); + constructor( + private downloadPath: string, + private downloadUrl: string, + private headers?: Record + ) {} - private static async connect() { - this.aria2c = startAria2(); - - let retries = 0; - - while (retries < 4 && !this.connected) { - try { - await this.aria2.open(); - logger.log("Connected to aria2"); - - this.connected = true; - } catch (err) { - await sleep(100); - logger.log("Failed to connect to aria2, retrying..."); - retries++; - } - } - } - - public static getStatus(gid: string) { - if (this.connected) { - return this.aria2.call("tellStatus", gid); - } - - return null; - } - - public static disconnect() { - if (this.aria2c) { - this.aria2c.kill(); - this.connected = false; - } - } - - static async cancelDownload(gid: string) { - await this.aria2.call("forceRemove", gid); - } - - static async pauseDownload(gid: string) { - await this.aria2.call("forcePause", gid); - } - - static async resumeDownload(gid: string) { - await this.aria2.call("unpause", gid); - } - - static async startDownload(downloadPath: string, downloadUrl: string) { - if (!this.connected) await this.connect(); - - const options = { - dir: downloadPath, + public getStatus() { + return { + completedLength: this.downloadItem.getReceivedBytes(), + totalLength: this.downloadItem.getTotalBytes(), + downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(), + folderName: this.downloadItem.getFilename(), }; + } - return this.aria2.call("addUri", [downloadUrl], options); + async cancelDownload() { + this.downloadItem.cancel(); + } + + async pauseDownload() { + this.downloadItem.pause(); + } + + async resumeDownload() { + this.downloadItem.resume(); + } + + async startDownload() { + return new Promise((resolve) => { + const options = this.headers ? { headers: this.headers } : {}; + WindowManager.mainWindow?.webContents.downloadURL( + this.downloadUrl, + options + ); + + WindowManager.mainWindow?.webContents.session.once( + "will-download", + (_event, item, _webContents) => { + this.downloadItem = item; + + item.setSavePath(path.join(this.downloadPath, item.getFilename())); + + resolve(null); + } + ); + }); } } diff --git a/src/main/services/download/python-instance.ts b/src/main/services/download/python-instance.ts index c534e41d..37ec17db 100644 --- a/src/main/services/download/python-instance.ts +++ b/src/main/services/download/python-instance.ts @@ -19,6 +19,7 @@ import { LibtorrentPayload, ProcessPayload, } from "./types"; +import { pythonInstanceLogger as logger } from "../logger"; export class PythonInstance { private static pythonProcess: cp.ChildProcess | null = null; @@ -32,11 +33,13 @@ export class PythonInstance { }); public static spawn(args?: StartDownloadPayload) { + logger.log("spawning python process with args:", args); this.pythonProcess = startRPCClient(args); } public static kill() { if (this.pythonProcess) { + logger.log("killing python process"); this.pythonProcess.kill(); this.pythonProcess = null; this.downloadingGameId = -1; @@ -45,6 +48,7 @@ export class PythonInstance { public static killTorrent() { if (this.pythonProcess) { + logger.log("killing torrent in python process"); this.rpc.post("/action", { action: "kill-torrent" }); this.downloadingGameId = -1; } @@ -138,12 +142,14 @@ export class PythonInstance { save_path: game.downloadPath!, }); } else { - await this.rpc.post("/action", { - action: "start", - game_id: game.id, - magnet: game.uri, - save_path: game.downloadPath, - } as StartDownloadPayload); + await this.rpc + .post("/action", { + action: "start", + game_id: game.id, + magnet: game.uri, + save_path: game.downloadPath, + } as StartDownloadPayload) + .catch(this.handleRpcError); } this.downloadingGameId = game.id; @@ -159,4 +165,14 @@ export class PythonInstance { this.downloadingGameId = -1; } + + private static async handleRpcError(_error: unknown) { + await this.rpc.get("/healthcheck").catch(() => { + logger.error( + "RPC healthcheck failed. Killing process and starting again" + ); + this.kill(); + this.spawn(); + }); + } } diff --git a/src/main/services/download/real-debrid-downloader.ts b/src/main/services/download/real-debrid-downloader.ts index 8ead0067..2818644a 100644 --- a/src/main/services/download/real-debrid-downloader.ts +++ b/src/main/services/download/real-debrid-downloader.ts @@ -1,162 +1,72 @@ import { Game } from "@main/entity"; import { RealDebridClient } from "../real-debrid"; -import { gameRepository } from "@main/repository"; -import { calculateETA } from "./helpers"; -import { DownloadProgress } from "@types"; import { HttpDownload } from "./http-download"; +import { GenericHttpDownloader } from "./generic-http-downloader"; -export class RealDebridDownloader { - private static downloads = new Map(); - private static downloadingGame: Game | null = null; - +export class RealDebridDownloader extends GenericHttpDownloader { private static realDebridTorrentId: string | null = null; private static async getRealDebridDownloadUrl() { if (this.realDebridTorrentId) { - const torrentInfo = await RealDebridClient.getTorrentInfo( + let torrentInfo = await RealDebridClient.getTorrentInfo( this.realDebridTorrentId ); - const { status, links } = torrentInfo; - - if (status === "waiting_files_selection") { + if (torrentInfo.status === "waiting_files_selection") { await RealDebridClient.selectAllFiles(this.realDebridTorrentId); - return null; + + torrentInfo = await RealDebridClient.getTorrentInfo( + this.realDebridTorrentId + ); } + const { links, status } = torrentInfo; + if (status === "downloaded") { const [link] = links; + const { download } = await RealDebridClient.unrestrictLink(link); return decodeURIComponent(download); } + + return null; } - return null; - } - - public static async getStatus() { - if (this.downloadingGame) { - const gid = this.downloads.get(this.downloadingGame.id)!; - const status = await HttpDownload.getStatus(gid); - - if (status) { - const progress = - Number(status.completedLength) / Number(status.totalLength); - - await gameRepository.update( - { id: this.downloadingGame!.id }, - { - bytesDownloaded: Number(status.completedLength), - fileSize: Number(status.totalLength), - progress, - status: "active", - } - ); - - const result = { - numPeers: 0, - numSeeds: 0, - downloadSpeed: Number(status.downloadSpeed), - timeRemaining: calculateETA( - Number(status.totalLength), - Number(status.completedLength), - Number(status.downloadSpeed) - ), - isDownloadingMetadata: false, - isCheckingFiles: false, - progress, - gameId: this.downloadingGame!.id, - } as DownloadProgress; - - if (progress === 1) { - this.downloads.delete(this.downloadingGame.id); - this.realDebridTorrentId = null; - this.downloadingGame = null; - } - - return result; - } - } - - if (this.realDebridTorrentId && this.downloadingGame) { - const torrentInfo = await RealDebridClient.getTorrentInfo( - this.realDebridTorrentId + if (this.downloadingGame?.uri) { + const { download } = await RealDebridClient.unrestrictLink( + this.downloadingGame?.uri ); - const { status } = torrentInfo; - - if (status === "downloaded") { - this.startDownload(this.downloadingGame); - } - - const progress = torrentInfo.progress / 100; - const totalDownloaded = progress * torrentInfo.bytes; - - return { - numPeers: 0, - numSeeds: torrentInfo.seeders, - downloadSpeed: torrentInfo.speed, - timeRemaining: calculateETA( - torrentInfo.bytes, - totalDownloaded, - torrentInfo.speed - ), - isDownloadingMetadata: status === "magnet_conversion", - } as DownloadProgress; + return decodeURIComponent(download); } return null; } - static async pauseDownload() { - const gid = this.downloads.get(this.downloadingGame!.id!); - if (gid) { - await HttpDownload.pauseDownload(gid); - } - - this.realDebridTorrentId = null; - this.downloadingGame = null; - } - static async startDownload(game: Game) { - this.downloadingGame = game; - if (this.downloads.has(game.id)) { await this.resumeDownload(game.id!); - + this.downloadingGame = game; return; } - this.realDebridTorrentId = await RealDebridClient.getTorrentId(game!.uri!); + if (game.uri?.startsWith("magnet:")) { + this.realDebridTorrentId = await RealDebridClient.getTorrentId( + game!.uri! + ); + } + + this.downloadingGame = game; const downloadUrl = await this.getRealDebridDownloadUrl(); if (downloadUrl) { this.realDebridTorrentId = null; - const gid = await HttpDownload.startDownload( - game.downloadPath!, - downloadUrl - ); + const httpDownload = new HttpDownload(game.downloadPath!, downloadUrl); + httpDownload.startDownload(); - this.downloads.set(game.id!, gid); - } - } - - static async cancelDownload(gameId: number) { - const gid = this.downloads.get(gameId); - - if (gid) { - await HttpDownload.cancelDownload(gid); - this.downloads.delete(gameId); - } - } - - static async resumeDownload(gameId: number) { - const gid = this.downloads.get(gameId); - - if (gid) { - await HttpDownload.resumeDownload(gid); + this.downloads.set(game.id!, httpDownload); } } } diff --git a/src/main/services/download/torrent-client.ts b/src/main/services/download/torrent-client.ts index 93d20b7f..2a16acad 100644 --- a/src/main/services/download/torrent-client.ts +++ b/src/main/services/download/torrent-client.ts @@ -4,6 +4,8 @@ import crypto from "node:crypto"; import fs from "node:fs"; import { app, dialog } from "electron"; import type { StartDownloadPayload } from "./types"; +import { Readable } from "node:stream"; +import { pythonInstanceLogger as logger } from "../logger"; const binaryNameByPlatform: Partial> = { darwin: "hydra-download-manager", @@ -15,6 +17,13 @@ export const BITTORRENT_PORT = "5881"; export const RPC_PORT = "8084"; export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex"); +const logStderr = (readable: Readable | null) => { + if (!readable) return; + + readable.setEncoding("utf-8"); + readable.on("data", logger.log); +}; + export const startTorrentClient = (args?: StartDownloadPayload) => { const commonArgs = [ BITTORRENT_PORT, @@ -40,10 +49,14 @@ export const startTorrentClient = (args?: StartDownloadPayload) => { app.quit(); } - return cp.spawn(binaryPath, commonArgs, { - stdio: "inherit", + const childProcess = cp.spawn(binaryPath, commonArgs, { windowsHide: true, + stdio: ["inherit", "inherit"], }); + + logStderr(childProcess.stderr); + + return childProcess; } else { const scriptPath = path.join( __dirname, @@ -53,8 +66,12 @@ export const startTorrentClient = (args?: StartDownloadPayload) => { "main.py" ); - return cp.spawn("python3", [scriptPath, ...commonArgs], { - stdio: "inherit", + const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], { + stdio: ["inherit", "inherit"], }); + + logStderr(childProcess.stderr); + + return childProcess; } }; diff --git a/src/main/services/hosters/gofile.ts b/src/main/services/hosters/gofile.ts new file mode 100644 index 00000000..2c23556f --- /dev/null +++ b/src/main/services/hosters/gofile.ts @@ -0,0 +1,63 @@ +import axios from "axios"; + +export interface GofileAccountsReponse { + id: string; + token: string; +} + +export interface GofileContentChild { + id: string; + link: string; +} + +export interface GofileContentsResponse { + id: string; + type: string; + children: Record; +} + +export const WT = "4fd6sg89d7s6"; + +export class GofileApi { + private static token: string; + + public static async authorize() { + const response = await axios.post<{ + status: string; + data: GofileAccountsReponse; + }>("https://api.gofile.io/accounts"); + + if (response.data.status === "ok") { + this.token = response.data.data.token; + return this.token; + } + + throw new Error("Failed to authorize"); + } + + public static async getDownloadLink(id: string) { + const searchParams = new URLSearchParams({ + wt: WT, + }); + + const response = await axios.get<{ + status: string; + data: GofileContentsResponse; + }>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, { + headers: { + Authorization: `Bearer ${this.token}`, + }, + }); + + if (response.data.status === "ok") { + if (response.data.data.type !== "folder") { + throw new Error("Only folders are supported"); + } + + const [firstChild] = Object.values(response.data.data.children); + return firstChild.link; + } + + throw new Error("Failed to get download link"); + } +} diff --git a/src/main/services/hosters/index.ts b/src/main/services/hosters/index.ts new file mode 100644 index 00000000..4c5b1803 --- /dev/null +++ b/src/main/services/hosters/index.ts @@ -0,0 +1,2 @@ +export * from "./gofile"; +export * from "./qiwi"; diff --git a/src/main/services/hosters/qiwi.ts b/src/main/services/hosters/qiwi.ts new file mode 100644 index 00000000..e18b011c --- /dev/null +++ b/src/main/services/hosters/qiwi.ts @@ -0,0 +1,15 @@ +import { requestWebPage } from "@main/helpers"; + +export class QiwiApi { + public static async getDownloadUrl(url: string) { + const document = await requestWebPage(url); + const fileName = document.querySelector("h1")?.textContent; + + const slug = url.split("/").pop(); + const extension = fileName?.split(".").pop(); + + const downloadUrl = `https://spyderrock.com/${slug}.${extension}`; + + return downloadUrl; + } +} diff --git a/src/main/services/how-long-to-beat.ts b/src/main/services/how-long-to-beat.ts index 39b938c5..c7164d09 100644 --- a/src/main/services/how-long-to-beat.ts +++ b/src/main/services/how-long-to-beat.ts @@ -1,8 +1,8 @@ import axios from "axios"; -import { JSDOM } from "jsdom"; import { requestWebPage } from "@main/helpers"; import { HowLongToBeatCategory } from "@types"; import { formatName } from "@shared"; +import { logger } from "./logger"; export interface HowLongToBeatResult { game_id: number; @@ -14,22 +14,27 @@ export interface HowLongToBeatSearchResponse { } export const searchHowLongToBeat = async (gameName: string) => { - const response = await axios.post( - "https://howlongtobeat.com/api/search", - { - searchType: "games", - searchTerms: formatName(gameName).split(" "), - searchPage: 1, - size: 100, - }, - { - headers: { - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - Referer: "https://howlongtobeat.com/", + const response = await axios + .post( + "https://howlongtobeat.com/api/search", + { + searchType: "games", + searchTerms: formatName(gameName).split(" "), + searchPage: 1, + size: 100, }, - } - ); + { + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + Referer: "https://howlongtobeat.com/", + }, + } + ) + .catch((error) => { + logger.error("Error searching HowLongToBeat:", error?.response?.status); + return { data: { data: [] } }; + }); return response.data as HowLongToBeatSearchResponse; }; @@ -52,10 +57,7 @@ const parseListItems = ($lis: Element[]) => { export const getHowLongToBeatGame = async ( id: string ): Promise => { - const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`); - - const { window } = new JSDOM(response); - const { document } = window; + const document = await requestWebPage(`https://howlongtobeat.com/game/${id}`); const $ul = document.querySelector(".shadow_shadow ul"); if (!$ul) return []; diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 97517b5b..120d27ac 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -64,6 +64,14 @@ export class HydraApi { } } + static handleSignOut() { + this.userAuth = { + authToken: "", + refreshToken: "", + expirationTimestamp: 0, + }; + } + static async setupApi() { this.instance = axios.create({ baseURL: import.meta.env.MAIN_VITE_API_URL, @@ -72,7 +80,7 @@ export class HydraApi { this.instance.interceptors.request.use( (request) => { logger.log(" ---- REQUEST -----"); - logger.log(request.method, request.url, request.data); + logger.log(request.method, request.url, request.params, request.data); return request; }, (error) => { @@ -196,52 +204,52 @@ export class HydraApi { throw err; }; - static async get(url: string) { + static async get(url: string, params?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .get(url, this.getAxiosConfig()) + .get(url, { params, ...this.getAxiosConfig() }) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async post(url: string, data?: any) { + static async post(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .post(url, data, this.getAxiosConfig()) + .post(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async put(url: string, data?: any) { + static async put(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .put(url, data, this.getAxiosConfig()) + .put(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async patch(url: string, data?: any) { + static async patch(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .patch(url, data, this.getAxiosConfig()) + .patch(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async delete(url: string) { + static async delete(url: string) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .delete(url, this.getAxiosConfig()) + .delete(url, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts index b66a1897..6699788c 100644 --- a/src/main/services/library-sync/create-game.ts +++ b/src/main/services/library-sync/create-game.ts @@ -3,7 +3,7 @@ import { HydraApi } from "../hydra-api"; import { gameRepository } from "@main/repository"; export const createGame = async (game: Game) => { - HydraApi.post(`/games`, { + HydraApi.post(`/profile/games`, { objectId: game.objectID, playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), shop: game.shop, 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 2a6b5bb5..2b3f51b3 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -4,7 +4,7 @@ import { steamGamesWorker } from "@main/workers"; import { getSteamAppAsset } from "@main/helpers"; export const mergeWithRemoteGames = async () => { - return HydraApi.get("/games") + return HydraApi.get("/profile/games") .then(async (response) => { for (const game of response) { const localGame = await gameRepository.findOne({ diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts index 39206a12..5cfc4103 100644 --- a/src/main/services/library-sync/update-game-playtime.ts +++ b/src/main/services/library-sync/update-game-playtime.ts @@ -6,7 +6,7 @@ export const updateGamePlaytime = async ( deltaInMillis: number, lastTimePlayed: Date ) => { - HydraApi.put(`/games/${game.remoteId}`, { + HydraApi.put(`/profile/games/${game.remoteId}`, { playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000), lastTimePlayed, }).catch(() => {}); diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index 88f02375..22dc595e 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -14,7 +14,7 @@ export const uploadGamesBatch = async () => { for (const chunk of gamesChunks) { await HydraApi.post( - "/games/batch", + "/profile/games/batch", chunk.map((game) => { return { objectId: game.objectID, diff --git a/src/main/services/logger.ts b/src/main/services/logger.ts index 8da27a9e..1eb7060b 100644 --- a/src/main/services/logger.ts +++ b/src/main/services/logger.ts @@ -6,6 +6,10 @@ log.transports.file.resolvePathFn = ( _: log.PathVariables, message?: log.LogMessage | undefined ) => { + if (message?.scope === "python-instance") { + return path.join(logsPath, "pythoninstance.txt"); + } + if (message?.level === "error") { return path.join(logsPath, "error.txt"); } @@ -23,4 +27,5 @@ log.errorHandler.startCatching({ log.initialize(); +export const pythonInstanceLogger = log.scope("python-instance"); export const logger = log.scope("main"); diff --git a/src/main/services/main-loop.ts b/src/main/services/main-loop.ts index ca72707f..f2ec51ba 100644 --- a/src/main/services/main-loop.ts +++ b/src/main/services/main-loop.ts @@ -10,6 +10,6 @@ export const startMainLoop = async () => { DownloadManager.watchDownloads(), ]); - await sleep(500); + await sleep(1000); } }; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 0f7efa62..080f1efc 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -4,12 +4,16 @@ import { WindowManager } from "./window-manager"; import { createGame, updateGamePlaytime } from "./library-sync"; import { GameRunning } from "@types"; import { PythonInstance } from "./download"; +import { Game } from "@main/entity"; export const gamesPlaytime = new Map< number, - { lastTick: number; firstTick: number } + { lastTick: number; firstTick: number; lastSyncTick: number } >(); +const TICKS_TO_UPDATE_API = 120; +let currentTick = 1; + export const watchProcesses = async () => { const games = await gameRepository.find({ where: { @@ -30,48 +34,17 @@ export const watchProcesses = async () => { if (gameProcess) { if (gamesPlaytime.has(game.id)) { - const gamePlaytime = gamesPlaytime.get(game.id)!; - - const zero = gamePlaytime.lastTick; - const delta = performance.now() - zero; - - await gameRepository.update(game.id, { - playTimeInMilliseconds: game.playTimeInMilliseconds + delta, - lastTimePlayed: new Date(), - }); - - gamesPlaytime.set(game.id, { - ...gamePlaytime, - lastTick: performance.now(), - }); + onTickGame(game); } else { - if (game.remoteId) { - updateGamePlaytime(game, 0, new Date()); - } else { - createGame({ ...game, lastTimePlayed: new Date() }); - } - - gamesPlaytime.set(game.id, { - lastTick: performance.now(), - firstTick: performance.now(), - }); + onOpenGame(game); } } else if (gamesPlaytime.has(game.id)) { - const gamePlaytime = gamesPlaytime.get(game.id)!; - gamesPlaytime.delete(game.id); - - if (game.remoteId) { - updateGamePlaytime( - game, - performance.now() - gamePlaytime.firstTick, - game.lastTimePlayed! - ); - } else { - createGame(game); - } + onCloseGame(game); } } + currentTick++; + if (WindowManager.mainWindow) { const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => { return { @@ -86,3 +59,68 @@ export const watchProcesses = async () => { ); } }; + +function onOpenGame(game: Game) { + const now = performance.now(); + + gamesPlaytime.set(game.id, { + lastTick: now, + firstTick: now, + lastSyncTick: now, + }); + + if (game.remoteId) { + updateGamePlaytime(game, 0, new Date()); + } else { + createGame({ ...game, lastTimePlayed: new Date() }); + } +} + +function onTickGame(game: Game) { + const now = performance.now(); + const gamePlaytime = gamesPlaytime.get(game.id)!; + + const delta = now - gamePlaytime.lastTick; + + gameRepository.update(game.id, { + playTimeInMilliseconds: game.playTimeInMilliseconds + delta, + lastTimePlayed: new Date(), + }); + + gamesPlaytime.set(game.id, { + ...gamePlaytime, + lastTick: now, + }); + + if (currentTick % TICKS_TO_UPDATE_API === 0) { + if (game.remoteId) { + updateGamePlaytime( + game, + now - gamePlaytime.lastSyncTick, + game.lastTimePlayed! + ); + } else { + createGame(game); + } + + gamesPlaytime.set(game.id, { + ...gamePlaytime, + lastSyncTick: now, + }); + } +} + +const onCloseGame = (game: Game) => { + const gamePlaytime = gamesPlaytime.get(game.id)!; + gamesPlaytime.delete(game.id); + + if (game.remoteId) { + updateGamePlaytime( + game, + performance.now() - gamePlaytime.firstTick, + game.lastTimePlayed! + ); + } else { + createGame(game); + } +}; diff --git a/src/main/services/real-debrid.ts b/src/main/services/real-debrid.ts index 2e0debe6..26ba4c79 100644 --- a/src/main/services/real-debrid.ts +++ b/src/main/services/real-debrid.ts @@ -46,7 +46,7 @@ export class RealDebridClient { static async selectAllFiles(id: string) { const searchParams = new URLSearchParams({ files: "all" }); - await this.instance.post( + return this.instance.post( `/torrents/selectFiles/${id}`, searchParams.toString() ); diff --git a/src/main/services/repacks-manager.ts b/src/main/services/repacks-manager.ts index 02821127..93157d6c 100644 --- a/src/main/services/repacks-manager.ts +++ b/src/main/services/repacks-manager.ts @@ -8,11 +8,25 @@ export class RepacksManager { private static repacksIndex = new flexSearch.Index(); public static async updateRepacks() { - this.repacks = await repackRepository.find({ - order: { - createdAt: "DESC", - }, - }); + this.repacks = await repackRepository + .find({ + order: { + createdAt: "DESC", + }, + }) + .then((repacks) => + repacks.map((repack) => { + const uris: string[] = []; + const magnet = repack?.magnet; + + if (magnet) uris.push(magnet); + + return { + ...repack, + uris: [...uris, ...JSON.parse(repack.uris)], + }; + }) + ); for (let i = 0; i < this.repacks.length; i++) { this.repacksIndex.remove(i); diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 201b13ad..025e7219 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -158,7 +158,7 @@ export class WindowManager { const recentlyPlayedGames: Array = games.map(({ title, executablePath }) => ({ - label: title, + label: title.length > 15 ? `${title.slice(0, 15)}…` : title, type: "normal", click: async () => { if (!executablePath) return; diff --git a/src/preload/index.ts b/src/preload/index.ts index 91722606..087d573a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import type { StartGameDownloadPayload, GameRunning, FriendRequestAction, + UpdateProfileProps, } from "@types"; contextBridge.exposeInMainWorld("electron", { @@ -135,8 +136,10 @@ contextBridge.exposeInMainWorld("electron", { /* Profile */ getMe: () => ipcRenderer.invoke("getMe"), - updateProfile: (displayName: string, newProfileImagePath: string | null) => - ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), + undoFriendship: (userId: string) => + ipcRenderer.invoke("undoFriendship", userId), + updateProfile: (updateProfile: UpdateProfileProps) => + ipcRenderer.invoke("updateProfile", updateProfile), getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), updateFriendRequest: (userId: string, action: FriendRequestAction) => ipcRenderer.invoke("updateFriendRequest", userId, action), @@ -145,6 +148,12 @@ contextBridge.exposeInMainWorld("electron", { /* User */ getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), + blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId), + unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), + getUserFriends: (userId: string, take: number, skip: number) => + ipcRenderer.invoke("getUserFriends", userId, take, skip), + getUserBlocks: (take: number, skip: number) => + ipcRenderer.invoke("getUserBlocks", take, skip), /* Auth */ signOut: () => ipcRenderer.invoke("signOut"), diff --git a/src/renderer/index.html b/src/renderer/index.html index 52276268..d7abf3ad 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Hydra diff --git a/src/renderer/src/app.css.ts b/src/renderer/src/app.css.ts index a5f9394b..c829021a 100644 --- a/src/renderer/src/app.css.ts +++ b/src/renderer/src/app.css.ts @@ -26,7 +26,7 @@ globalStyle("html, body, #root, main", { globalStyle("body", { overflow: "hidden", userSelect: "none", - fontFamily: "'Fira Mono', monospace", + fontFamily: "Noto Sans, sans-serif", fontSize: vars.size.body, background: vars.color.background, color: vars.color.body, diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 24c7bed6..2b9ac187 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -42,11 +42,12 @@ export function App() { const { isFriendsModalVisible, friendRequetsModalTab, - updateFriendRequests, + friendModalUserId, + fetchFriendRequests, hideFriendsModal, } = useUserDetails(); - const { fetchUserDetails, updateUserDetails, clearUserDetails } = + const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } = useUserDetails(); const dispatch = useAppDispatch(); @@ -104,20 +105,26 @@ export function App() { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); - updateFriendRequests(); + fetchFriendRequests(); } }); - }, [fetchUserDetails, updateUserDetails, dispatch]); + }, [fetchUserDetails, fetchFriendRequests, updateUserDetails, dispatch]); const onSignIn = useCallback(() => { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); - updateFriendRequests(); + fetchFriendRequests(); showSuccessToast(t("successfully_signed_in")); } }); - }, [fetchUserDetails, t, showSuccessToast, updateUserDetails]); + }, [ + fetchUserDetails, + fetchFriendRequests, + t, + showSuccessToast, + updateUserDetails, + ]); useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesRunning) => { @@ -218,11 +225,14 @@ export function App() { onClose={handleToastClose} /> - + {userDetails && ( + + )}
diff --git a/src/renderer/src/components/header/header.css.ts b/src/renderer/src/components/header/header.css.ts index 0e82aaef..12855986 100644 --- a/src/renderer/src/components/header/header.css.ts +++ b/src/renderer/src/components/header/header.css.ts @@ -104,6 +104,7 @@ export const section = style({ alignItems: "center", gap: `${SPACING_UNIT * 2}px`, height: "100%", + overflow: "hidden", }); export const backButton = recipe({ @@ -136,11 +137,15 @@ export const backButton = recipe({ export const title = recipe({ base: { transition: "all ease 0.2s", + overflow: "hidden", + textOverflow: "ellipsis", + width: "100%", }, variants: { hasBackButton: { true: { transform: "translateX(28px)", + width: "calc(100% - 28px)", }, }, }, diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index d3315098..c37e4b44 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -72,7 +72,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) { isWindows: window.electron.platform === "win32", })} > -
+
- {userDetails && friendRequests.length > 0 && !gameRunning && ( -
- -
+ {showPendingRequests && ( + )} ); diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index 6186bb85..63368c88 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -5,4 +5,7 @@ export const VERSION_CODENAME = "Leviticus"; export const DOWNLOADER_NAME = { [Downloader.RealDebrid]: "Real-Debrid", [Downloader.Torrent]: "Torrent", + [Downloader.Gofile]: "Gofile", + [Downloader.PixelDrain]: "PixelDrain", + [Downloader.Qiwi]: "Qiwi", }; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index bb89f84e..29e4dcbb 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -16,6 +16,8 @@ import type { UserProfile, FriendRequest, FriendRequestAction, + UserFriends, + UserBlocks, } from "@types"; import type { DiskSpace } from "check-disk-space"; @@ -127,13 +129,19 @@ declare global { /* User */ getUser: (userId: string) => Promise; + blockUser: (userId: string) => Promise; + unblockUser: (userId: string) => Promise; + getUserFriends: ( + userId: string, + take: number, + skip: number + ) => Promise; + getUserBlocks: (take: number, skip: number) => Promise; /* Profile */ getMe: () => Promise; - updateProfile: ( - displayName: string, - newProfileImagePath: string | null - ) => Promise; + undoFriendship: (userId: string) => Promise; + updateProfile: (updateProfile: UpdateProfileProps) => Promise; getFriendRequests: () => Promise; updateFriendRequest: ( userId: string, diff --git a/src/renderer/src/features/user-details-slice.ts b/src/renderer/src/features/user-details-slice.ts index 0df88695..d559de09 100644 --- a/src/renderer/src/features/user-details-slice.ts +++ b/src/renderer/src/features/user-details-slice.ts @@ -8,6 +8,7 @@ export interface UserDetailsState { friendRequests: FriendRequest[]; isFriendsModalVisible: boolean; friendRequetsModalTab: UserFriendModalTab | null; + friendModalUserId: string; } const initialState: UserDetailsState = { @@ -16,6 +17,7 @@ const initialState: UserDetailsState = { friendRequests: [], isFriendsModalVisible: false, friendRequetsModalTab: null, + friendModalUserId: "", }; export const userDetailsSlice = createSlice({ @@ -33,10 +35,11 @@ export const userDetailsSlice = createSlice({ }, setFriendsModalVisible: ( state, - action: PayloadAction + action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }> ) => { state.isFriendsModalVisible = true; - state.friendRequetsModalTab = action.payload; + state.friendRequetsModalTab = action.payload.initialTab; + state.friendModalUserId = action.payload.userId; }, setFriendsModalHidden: (state) => { state.isFriendsModalVisible = false; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index d37612d4..182aef25 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -1,6 +1,7 @@ import type { GameShop } from "@types"; import Color from "color"; +import { average } from "color.js"; export const steamUrlBuilder = { library: (objectID: string) => @@ -45,3 +46,14 @@ export const buildGameDetailsPath = ( export const darkenColor = (color: string, amount: number, alpha: number = 1) => new Color(color).darken(amount).alpha(alpha).toString(); + +export const profileBackgroundFromProfileImage = async ( + profileImageUrl: string +) => { + const output = await average(profileImageUrl, { + amount: 1, + format: "hex", + }); + + return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`; +}; diff --git a/src/renderer/src/hooks/use-download.ts b/src/renderer/src/hooks/use-download.ts index f58a8765..07c885cf 100644 --- a/src/renderer/src/hooks/use-download.ts +++ b/src/renderer/src/hooks/use-download.ts @@ -22,9 +22,10 @@ export function useDownload() { ); const dispatch = useAppDispatch(); - const startDownload = (payload: StartGameDownloadPayload) => { + const startDownload = async (payload: StartGameDownloadPayload) => { dispatch(clearDownload()); - window.electron.startGameDownload(payload).then((game) => { + + return window.electron.startGameDownload(payload).then((game) => { updateLibrary(); return game; diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index a0da950a..0cf2a381 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -1,6 +1,4 @@ import { useCallback } from "react"; -import { average } from "color.js"; - import { useAppDispatch, useAppSelector } from "./redux"; import { setProfileBackground, @@ -9,9 +7,10 @@ import { setFriendsModalVisible, setFriendsModalHidden, } from "@renderer/features"; -import { darkenColor } from "@renderer/helpers"; -import { FriendRequestAction, UserDetails } from "@types"; +import { profileBackgroundFromProfileImage } from "@renderer/helpers"; +import { FriendRequestAction, UpdateProfileProps, UserDetails } from "@types"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; +import { logger } from "@renderer/logger"; export function useUserDetails() { const dispatch = useAppDispatch(); @@ -21,6 +20,7 @@ export function useUserDetails() { profileBackground, friendRequests, isFriendsModalVisible, + friendModalUserId, friendRequetsModalTab, } = useAppSelector((state) => state.userDetails); @@ -42,12 +42,12 @@ export function useUserDetails() { dispatch(setUserDetails(userDetails)); if (userDetails.profileImageUrl) { - const output = await average(userDetails.profileImageUrl, { - amount: 1, - format: "hex", + const profileBackground = await profileBackgroundFromProfileImage( + userDetails.profileImageUrl + ).catch((err) => { + logger.error("profileBackgroundFromProfileImage", err); + return `#151515B3`; }); - - const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`; dispatch(setProfileBackground(profileBackground)); window.localStorage.setItem( @@ -78,28 +78,28 @@ export function useUserDetails() { }, [clearUserDetails]); const patchUser = useCallback( - async (displayName: string, imageProfileUrl: string | null) => { - const response = await window.electron.updateProfile( - displayName, - imageProfileUrl - ); - + async (props: UpdateProfileProps) => { + const response = await window.electron.updateProfile(props); return updateUserDetails(response); }, [updateUserDetails] ); - const updateFriendRequests = useCallback(async () => { - const friendRequests = await window.electron.getFriendRequests(); - dispatch(setFriendRequests(friendRequests)); + const fetchFriendRequests = useCallback(() => { + return window.electron + .getFriendRequests() + .then((friendRequests) => { + dispatch(setFriendRequests(friendRequests)); + }) + .catch(() => {}); }, [dispatch]); const showFriendsModal = useCallback( - (tab: UserFriendModalTab) => { - dispatch(setFriendsModalVisible(tab)); - updateFriendRequests(); + (initialTab: UserFriendModalTab, userId: string) => { + dispatch(setFriendsModalVisible({ initialTab, userId })); + fetchFriendRequests(); }, - [dispatch] + [dispatch, fetchFriendRequests] ); const hideFriendsModal = useCallback(() => { @@ -110,26 +110,39 @@ export function useUserDetails() { async (userId: string) => { return window.electron .sendFriendRequest(userId) - .then(() => updateFriendRequests()); + .then(() => fetchFriendRequests()); }, - [updateFriendRequests] + [fetchFriendRequests] ); const updateFriendRequestState = useCallback( async (userId: string, action: FriendRequestAction) => { return window.electron .updateFriendRequest(userId, action) - .then(() => updateFriendRequests()); + .then(() => fetchFriendRequests()); }, - [updateFriendRequests] + [fetchFriendRequests] ); + const undoFriendship = (userId: string) => { + return window.electron.undoFriendship(userId); + }; + + const blockUser = (userId: string) => { + return window.electron.blockUser(userId); + }; + + const unblockUser = (userId: string) => { + return window.electron.unblockUser(userId); + }; + return { userDetails, profileBackground, friendRequests, friendRequetsModalTab, isFriendsModalVisible, + friendModalUserId, showFriendsModal, hideFriendsModal, fetchUserDetails, @@ -138,7 +151,10 @@ export function useUserDetails() { updateUserDetails, patchUser, sendFriendRequest, - updateFriendRequests, + fetchFriendRequests, updateFriendRequestState, + blockUser, + unblockUser, + undoFriendship, }; } diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index f87d66bf..2377bf7c 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -8,12 +8,10 @@ import { HashRouter, Route, Routes } from "react-router-dom"; import * as Sentry from "@sentry/electron/renderer"; -import "@fontsource/fira-mono/400.css"; -import "@fontsource/fira-mono/500.css"; -import "@fontsource/fira-mono/700.css"; -import "@fontsource/fira-sans/400.css"; -import "@fontsource/fira-sans/500.css"; -import "@fontsource/fira-sans/700.css"; +import "@fontsource/noto-sans/400.css"; +import "@fontsource/noto-sans/500.css"; +import "@fontsource/noto-sans/700.css"; + import "react-loading-skeleton/dist/skeleton.css"; import { App } from "./app"; @@ -28,7 +26,7 @@ import { import { store } from "./store"; -import * as resources from "@locales"; +import resources from "@locales"; import { User } from "./pages/user/user"; Sentry.init({}); diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index 531bc526..5a9c228c 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -132,9 +132,7 @@ export function Downloads() {

{t("no_downloads_title")}

-

- {t("no_downloads_description")} -

+

{t("no_downloads_description")}

)} diff --git a/src/renderer/src/pages/game-details/description-header/description-header.tsx b/src/renderer/src/pages/game-details/description-header/description-header.tsx index e4272534..cd73c52a 100644 --- a/src/renderer/src/pages/game-details/description-header/description-header.tsx +++ b/src/renderer/src/pages/game-details/description-header/description-header.tsx @@ -19,7 +19,10 @@ export function DescriptionHeader() { date: shopDetails?.release_date.date, })}

-

{t("publisher", { publisher: shopDetails.publishers[0] })}

+ + {Array.isArray(shopDetails.publishers) && ( +

{t("publisher", { publisher: shopDetails.publishers[0] })}

+ )}
); diff --git a/src/renderer/src/pages/game-details/game-details.css.ts b/src/renderer/src/pages/game-details/game-details.css.ts index f0bbfd2e..3dc0ec94 100644 --- a/src/renderer/src/pages/game-details/game-details.css.ts +++ b/src/renderer/src/pages/game-details/game-details.css.ts @@ -101,7 +101,6 @@ export const descriptionContent = style({ export const description = style({ userSelect: "text", lineHeight: "22px", - fontFamily: "'Fira Sans', sans-serif", fontSize: "16px", padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`, "@media": { diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index 5f32965a..5ac9673f 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -23,7 +23,7 @@ import { } from "@renderer/context"; import { useDownload } from "@renderer/hooks"; import { GameOptionsModal, RepacksModal } from "./modals"; -import { Downloader } from "@shared"; +import { Downloader, getDownloadersForUri } from "@shared"; export function GameDetails() { const [randomGame, setRandomGame] = useState(null); @@ -70,6 +70,9 @@ export function GameDetails() { } }; + const selectRepackUri = (repack: GameRepack, downloader: Downloader) => + repack.uris.find((uri) => getDownloadersForUri(uri).includes(downloader))!; + return ( @@ -96,6 +99,7 @@ export function GameDetails() { downloader, shop: shop as GameShop, downloadPath, + uri: selectRepackUri(repack, downloader), }); await updateGame(); diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.css.ts b/src/renderer/src/pages/game-details/hero/hero-panel.css.ts index e10d55a5..c379c1c3 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.css.ts +++ b/src/renderer/src/pages/game-details/hero/hero-panel.css.ts @@ -9,6 +9,7 @@ export const panel = recipe({ height: "72px", minHeight: "72px", padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`, + backgroundColor: vars.color.darkBackground, display: "flex", alignItems: "center", justifyContent: "space-between", diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts b/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts index d5655d94..5450378c 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts @@ -20,13 +20,16 @@ export const hintText = style({ }); export const downloaders = style({ - display: "flex", + display: "grid", gap: `${SPACING_UNIT}px`, + gridTemplateColumns: "repeat(2, 1fr)", }); export const downloaderOption = style({ - flex: "1", position: "relative", + ":only-child": { + gridColumn: "1 / -1", + }, }); export const downloaderIcon = style({ diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index ef4ba040..3450af24 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -1,11 +1,11 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { DiskSpace } from "check-disk-space"; import * as styles from "./download-settings-modal.css"; import { Button, Link, Modal, TextField } from "@renderer/components"; import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; -import { Downloader, formatBytes } from "@shared"; +import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; import type { GameRepack } from "@types"; import { SPACING_UNIT } from "@renderer/theme.css"; @@ -23,8 +23,6 @@ export interface DownloadSettingsModalProps { repack: GameRepack | null; } -const downloaders = [Downloader.Torrent, Downloader.RealDebrid]; - export function DownloadSettingsModal({ visible, onClose, @@ -36,9 +34,8 @@ export function DownloadSettingsModal({ const [diskFreeSpace, setDiskFreeSpace] = useState(null); const [selectedPath, setSelectedPath] = useState(""); const [downloadStarting, setDownloadStarting] = useState(false); - const [selectedDownloader, setSelectedDownloader] = useState( - Downloader.Torrent - ); + const [selectedDownloader, setSelectedDownloader] = + useState(null); const userPreferences = useAppSelector( (state) => state.userPreferences.value @@ -50,6 +47,10 @@ export function DownloadSettingsModal({ } }, [visible, selectedPath]); + const downloaders = useMemo(() => { + return getDownloadersForUris(repack?.uris ?? []); + }, [repack?.uris]); + useEffect(() => { if (userPreferences?.downloadsPath) { setSelectedPath(userPreferences.downloadsPath); @@ -59,9 +60,27 @@ export function DownloadSettingsModal({ .then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath)); } - if (userPreferences?.realDebridApiToken) - setSelectedDownloader(Downloader.RealDebrid); - }, [userPreferences?.downloadsPath, userPreferences?.realDebridApiToken]); + const filteredDownloaders = downloaders.filter((downloader) => { + if (downloader === Downloader.RealDebrid) + return userPreferences?.realDebridApiToken; + return true; + }); + + /* Gives preference to Real Debrid */ + const selectedDownloader = filteredDownloaders.includes( + Downloader.RealDebrid + ) + ? Downloader.RealDebrid + : filteredDownloaders[0]; + + setSelectedDownloader( + selectedDownloader === undefined ? null : selectedDownloader + ); + }, [ + userPreferences?.downloadsPath, + downloaders, + userPreferences?.realDebridApiToken, + ]); const getDiskFreeSpace = (path: string) => { window.electron.getDiskFreeSpace(path).then((result) => { @@ -85,7 +104,7 @@ export function DownloadSettingsModal({ if (repack) { setDownloadStarting(true); - startDownload(repack, selectedDownloader, selectedPath).finally(() => { + startDownload(repack, selectedDownloader!, selectedPath).finally(() => { setDownloadStarting(false); onClose(); }); @@ -167,7 +186,10 @@ export function DownloadSettingsModal({

- diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.css.ts b/src/renderer/src/pages/game-details/modals/game-options-modal.css.ts index 8bf0ae7f..f844a686 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.css.ts +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.css.ts @@ -15,7 +15,6 @@ export const gameOptionHeader = style({ }); export const gameOptionHeaderDescription = style({ - fontFamily: "'Fira Sans', sans-serif", fontWeight: "400", }); 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 d2d8f5d9..0d1b9c1d 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -44,8 +44,10 @@ export function RepacksModal({ }, [repacks]); const getInfoHash = useCallback(async () => { - const torrent = await parseTorrent(game?.uri ?? ""); - if (torrent.infoHash) setInfoHash(torrent.infoHash); + if (game?.uri?.startsWith("magnet:")) { + const torrent = await parseTorrent(game?.uri ?? ""); + if (torrent.infoHash) setInfoHash(torrent.infoHash); + } }, [game]); useEffect(() => { @@ -74,6 +76,13 @@ export function RepacksModal({ ); }; + const checkIfLastDownloadedOption = (repack: GameRepack) => { + if (infoHash) return repack.uris.some((uri) => uri.includes(infoHash)); + if (!game?.uri) return false; + + return repack.uris.some((uri) => uri.includes(game?.uri ?? "")); + }; + return ( <> {filteredRepacks.map((repack) => { - const isLastDownloadedOption = - infoHash !== null && - repack.magnet.toLowerCase().includes(infoHash); + const isLastDownloadedOption = checkIfLastDownloadedOption(repack); return ( + ); + } + + if (type === "RECEIVED") { + return ( + <> + + + + ); + } + + if (type === "ACCEPTED") { + return ( + + ); + } + + if (type === "BLOCKED") { + return ( + + ); + } + + return null; + }; + + if (type === "BLOCKED") { + return ( +
+
+
+ {profileImageUrl ? ( + {displayName} + ) : ( + + )} +
+
+

{displayName}

+
+
+ +
+ {getRequestActions()} +
+
+ ); + } + + return ( +
+ + +
+ {getRequestActions()} +
+
+ ); +}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx index bf4879b2..b6e6aaea 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx @@ -4,7 +4,7 @@ import { SPACING_UNIT } from "@renderer/theme.css"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { UserFriendRequest } from "./user-friend-request"; +import { UserFriendItem } from "./user-friend-item"; export interface UserFriendModalAddFriendProps { closeModal: () => void; @@ -23,7 +23,7 @@ export const UserFriendModalAddFriend = ({ const { sendFriendRequest, updateFriendRequestState, friendRequests } = useUserDetails(); - const { showErrorToast } = useToast(); + const { showSuccessToast, showErrorToast } = useToast(); const handleClickAddFriend = () => { setIsAddingFriend(true); @@ -40,37 +40,37 @@ export const UserFriendModalAddFriend = ({ }); }; - const resetAndClose = () => { - setFriendCode(""); - closeModal(); - }; - const handleClickRequest = (userId: string) => { - resetAndClose(); + closeModal(); navigate(`/user/${userId}`); }; const handleClickSeeProfile = () => { - resetAndClose(); - // TODO: add validation for this input? - navigate(`/user/${friendCode}`); + closeModal(); + if (friendCode.length === 8) { + navigate(`/user/${friendCode}`); + } }; - const handleClickCancelFriendRequest = (userId: string) => { + const handleCancelFriendRequest = (userId: string) => { updateFriendRequestState(userId, "CANCEL").catch(() => { - showErrorToast("Falha ao cancelar convite"); + showErrorToast(t("try_again")); }); }; - const handleClickAcceptFriendRequest = (userId: string) => { - updateFriendRequestState(userId, "ACCEPTED").catch(() => { - showErrorToast("Falha ao aceitar convite"); - }); + const handleAcceptFriendRequest = (userId: string) => { + updateFriendRequestState(userId, "ACCEPTED") + .then(() => { + showSuccessToast(t("request_accepted")); + }) + .catch(() => { + showErrorToast(t("try_again")); + }); }; - const handleClickRefuseFriendRequest = (userId: string) => { + const handleRefuseFriendRequest = (userId: string) => { updateFriendRequestState(userId, "REFUSED").catch(() => { - showErrorToast("Falha ao recusar convite"); + showErrorToast(t("try_again")); }); }; @@ -118,19 +118,20 @@ export const UserFriendModalAddFriend = ({ gap: `${SPACING_UNIT * 2}px`, }} > -

Pendentes

+

{t("pending")}

+ {friendRequests.length === 0 &&

{t("no_pending_invites")}

} {friendRequests.map((request) => { return ( - ); })} diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx new file mode 100644 index 00000000..8ef96baf --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx @@ -0,0 +1,135 @@ +import { SPACING_UNIT, vars } from "@renderer/theme.css"; +import { UserFriend } from "@types"; +import { useEffect, useRef, useState } from "react"; +import { UserFriendItem } from "./user-friend-item"; +import { useNavigate } from "react-router-dom"; +import { useToast, useUserDetails } from "@renderer/hooks"; +import { useTranslation } from "react-i18next"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; + +export interface UserFriendModalListProps { + userId: string; + closeModal: () => void; +} + +const pageSize = 12; + +export const UserFriendModalList = ({ + userId, + closeModal, +}: UserFriendModalListProps) => { + const { t } = useTranslation("user_profile"); + const { showErrorToast } = useToast(); + const navigate = useNavigate(); + + const [page, setPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [maxPage, setMaxPage] = useState(0); + const [friends, setFriends] = useState([]); + const listContainer = useRef(null); + + const { userDetails, undoFriendship } = useUserDetails(); + const isMe = userDetails?.id == userId; + + const loadNextPage = () => { + if (page > maxPage) return; + setIsLoading(true); + window.electron + .getUserFriends(userId, pageSize, page * pageSize) + .then((newPage) => { + if (page === 0) { + setMaxPage(newPage.totalFriends / pageSize); + } + + setFriends([...friends, ...newPage.friends]); + setPage(page + 1); + }) + .catch(() => {}) + .finally(() => setIsLoading(false)); + }; + + const handleScroll = () => { + const scrollTop = listContainer.current?.scrollTop || 0; + const scrollHeight = listContainer.current?.scrollHeight || 0; + const clientHeight = listContainer.current?.clientHeight || 0; + const maxScrollTop = scrollHeight - clientHeight; + + if (scrollTop < maxScrollTop * 0.9 || isLoading) { + return; + } + + loadNextPage(); + }; + + useEffect(() => { + const container = listContainer.current; + container?.addEventListener("scroll", handleScroll); + return () => container?.removeEventListener("scroll", handleScroll); + }, [isLoading]); + + const reloadList = () => { + setPage(0); + setMaxPage(0); + setFriends([]); + loadNextPage(); + }; + + useEffect(() => { + reloadList(); + }, [userId]); + + const handleClickFriend = (userId: string) => { + closeModal(); + navigate(`/user/${userId}`); + }; + + const handleUndoFriendship = (userId: string) => { + undoFriendship(userId) + .then(() => { + reloadList(); + }) + .catch(() => { + showErrorToast(t("try_again")); + }); + }; + + return ( + +
+ {!isLoading && friends.length === 0 &&

{t("no_friends_added")}

} + {friends.map((friend) => { + return ( + + ); + })} + {isLoading && ( + + )} +
+
+ ); +}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.css.ts b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.css.ts index 0d6e8643..41ab4156 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.css.ts +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.css.ts @@ -1,17 +1,6 @@ import { SPACING_UNIT, vars } from "../../../theme.css"; import { style } from "@vanilla-extract/css"; -export const profileContentBox = style({ - display: "flex", - gap: `${SPACING_UNIT * 3}px`, - alignItems: "center", - borderRadius: "4px", - border: `solid 1px ${vars.color.border}`, - width: "100%", - boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)", - transition: "all ease 0.3s", -}); - export const friendAvatarContainer = style({ width: "35px", minWidth: "35px", @@ -42,8 +31,14 @@ export const profileAvatar = style({ }); export const friendListContainer = style({ + display: "flex", + gap: `${SPACING_UNIT * 3}px`, + alignItems: "center", + borderRadius: "4px", + border: `solid 1px ${vars.color.border}`, width: "100%", height: "54px", + minHeight: "54px", transition: "all ease 0.2s", position: "relative", ":hover": { @@ -90,3 +85,15 @@ export const cancelRequestButton = style({ color: vars.color.danger, }, }); + +export const friendCodeButton = style({ + color: vars.color.body, + cursor: "pointer", + display: "flex", + gap: `${SPACING_UNIT / 2}px`, + alignItems: "center", + transition: "all ease 0.2s", + ":hover": { + color: vars.color.muted, + }, +}); diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx index 88cf4a6c..26c63abc 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx @@ -1,25 +1,31 @@ import { Button, Modal } from "@renderer/components"; import { SPACING_UNIT } from "@renderer/theme.css"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend"; +import { useToast, useUserDetails } from "@renderer/hooks"; +import { UserFriendModalList } from "./user-friend-modal-list"; +import { CopyIcon } from "@primer/octicons-react"; +import * as styles from "./user-friend-modal.css"; export enum UserFriendModalTab { FriendsList, AddFriend, } -export interface UserAddFriendsModalProps { +export interface UserFriendsModalProps { visible: boolean; onClose: () => void; initialTab: UserFriendModalTab | null; + userId: string; } export const UserFriendModal = ({ visible, onClose, initialTab, -}: UserAddFriendsModalProps) => { + userId, +}: UserFriendsModalProps) => { const { t } = useTranslation("user_profile"); const tabs = [t("friends_list"), t("add_friends")]; @@ -28,6 +34,11 @@ export const UserFriendModal = ({ initialTab || UserFriendModalTab.FriendsList ); + const { showSuccessToast } = useToast(); + + const { userDetails } = useUserDetails(); + const isMe = userDetails?.id == userId; + useEffect(() => { if (initialTab != null) { setCurrentTab(initialTab); @@ -36,7 +47,7 @@ export const UserFriendModal = ({ const renderTab = () => { if (currentTab == UserFriendModalTab.FriendsList) { - return <>; + return ; } if (currentTab == UserFriendModalTab.AddFriend) { @@ -46,6 +57,11 @@ export const UserFriendModal = ({ return <>; }; + const copyToClipboard = useCallback(() => { + navigator.clipboard.writeText(userDetails!.id); + showSuccessToast(t("friend_code_copied")); + }, [userDetails, showSuccessToast, t]); + return (
-
- {tabs.map((tab, index) => { - return ( - - ); - })} -
-

{tabs[currentTab]}

+

{userDetails.id}

+ + +
+
+ {tabs.map((tab, index) => { + return ( + + ); + })} +
+ + )} {renderTab()}
diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-request.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-request.tsx deleted file mode 100644 index 022807d5..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-request.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { - CheckCircleIcon, - PersonIcon, - XCircleIcon, -} from "@primer/octicons-react"; -import * as styles from "./user-friend-modal.css"; -import cn from "classnames"; -import { SPACING_UNIT } from "@renderer/theme.css"; - -export interface UserFriendRequestProps { - userId: string; - profileImageUrl: string | null; - displayName: string; - isRequestSent: boolean; - onClickCancelRequest: (userId: string) => void; - onClickAcceptRequest: (userId: string) => void; - onClickRefuseRequest: (userId: string) => void; - onClickRequest: (userId: string) => void; -} - -export const UserFriendRequest = ({ - userId, - profileImageUrl, - displayName, - isRequestSent, - onClickCancelRequest, - onClickAcceptRequest, - onClickRefuseRequest, - onClickRequest, -}: UserFriendRequestProps) => { - return ( -
- - -
- {isRequestSent ? ( - - ) : ( - <> - - - - )} -
-
- ); -}; diff --git a/src/renderer/src/pages/user/user-block-modal.tsx b/src/renderer/src/pages/user/user-block-modal.tsx new file mode 100644 index 00000000..311eb060 --- /dev/null +++ b/src/renderer/src/pages/user/user-block-modal.tsx @@ -0,0 +1,42 @@ +import { Button, Modal } from "@renderer/components"; +import * as styles from "./user.css"; +import { useTranslation } from "react-i18next"; + +export interface UserBlockModalProps { + visible: boolean; + displayName: string; + onConfirm: () => void; + onClose: () => void; +} + +export const UserBlockModal = ({ + visible, + displayName, + onConfirm, + onClose, +}: UserBlockModalProps) => { + const { t } = useTranslation("user_profile"); + + return ( + <> + +
+

{t("user_block_modal_text", { displayName })}

+
+ + + +
+
+
+ + ); +}; diff --git a/src/renderer/src/pages/user/user-confirm-undo-friendship-modal.tsx b/src/renderer/src/pages/user/user-confirm-undo-friendship-modal.tsx new file mode 100644 index 00000000..cfdb5d06 --- /dev/null +++ b/src/renderer/src/pages/user/user-confirm-undo-friendship-modal.tsx @@ -0,0 +1,40 @@ +import { Button, Modal } from "@renderer/components"; +import * as styles from "./user.css"; +import { useTranslation } from "react-i18next"; + +export interface UserConfirmUndoFriendshipModalProps { + visible: boolean; + displayName: string; + onConfirm: () => void; + onClose: () => void; +} + +export function UserConfirmUndoFriendshipModal({ + visible, + displayName, + onConfirm, + onClose, +}: UserConfirmUndoFriendshipModalProps) { + const { t } = useTranslation("user_profile"); + + return ( + +
+

{t("undo_friendship_modal_text", { displayName })}

+
+ + + +
+
+
+ ); +} diff --git a/src/renderer/src/pages/user/user-content.tsx b/src/renderer/src/pages/user/user-content.tsx index e70335d8..f4a46ccd 100644 --- a/src/renderer/src/pages/user/user-content.tsx +++ b/src/renderer/src/pages/user/user-content.tsx @@ -1,4 +1,9 @@ -import { UserGame, UserProfile } from "@types"; +import { + FriendRequestAction, + GameRunning, + UserGame, + UserProfile, +} from "@types"; import cn from "classnames"; import * as styles from "./user.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css"; @@ -12,12 +17,24 @@ import { useUserDetails, } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; -import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers"; -import { PersonIcon, PlusIcon, TelescopeIcon } from "@primer/octicons-react"; +import { + buildGameDetailsPath, + profileBackgroundFromProfileImage, + steamUrlBuilder, +} from "@renderer/helpers"; +import { + CheckCircleIcon, + PersonIcon, + PlusIcon, + TelescopeIcon, + XCircleIcon, +} from "@primer/octicons-react"; import { Button, Link } from "@renderer/components"; -import { UserEditProfileModal } from "./user-edit-modal"; -import { UserSignOutModal } from "./user-signout-modal"; +import { UserProfileSettingsModal } from "./user-profile-settings-modal"; +import { UserSignOutModal } from "./user-sign-out-modal"; import { UserFriendModalTab } from "../shared-modals/user-friend-modal"; +import { UserBlockModal } from "./user-block-modal"; +import { UserConfirmUndoFriendshipModal } from "./user-confirm-undo-friendship-modal"; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; @@ -26,23 +43,34 @@ export interface ProfileContentProps { updateUserProfile: () => Promise; } +type FriendAction = FriendRequestAction | ("BLOCK" | "UNDO" | "SEND"); + export function UserContent({ userProfile, updateUserProfile, }: ProfileContentProps) { const { t, i18n } = useTranslation("user_profile"); - const { userDetails, profileBackground, signOut, - updateFriendRequests, + sendFriendRequest, + fetchFriendRequests, showFriendsModal, + updateFriendRequestState, + undoFriendship, + blockUser, } = useUserDetails(); - const { showSuccessToast } = useToast(); + const { showSuccessToast, showErrorToast } = useToast(); - const [showEditProfileModal, setShowEditProfileModal] = useState(false); + const [profileContentBoxBackground, setProfileContentBoxBackground] = + useState(); + const [showProfileSettingsModal, setShowProfileSettingsModal] = + useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false); + const [showUserBlockModal, setShowUserBlockModal] = useState(false); + const [showUndoFriendshipModal, setShowUndoFriendshipModal] = useState(false); + const [currentGame, setCurrentGame] = useState(null); const { gameRunning } = useAppSelector((state) => state.gameRunning); @@ -75,7 +103,7 @@ export function UserContent({ }; const handleEditProfile = () => { - setShowEditProfileModal(true); + setShowProfileSettingsModal(true); }; const handleOnClickFriend = (userId: string) => { @@ -93,20 +121,156 @@ export function UserContent({ const isMe = userDetails?.id == userProfile.id; useEffect(() => { - if (isMe) updateFriendRequests(); - }, [isMe]); + if (isMe && gameRunning) { + setCurrentGame(gameRunning); + return; + } - const profileContentBoxBackground = useMemo(() => { - if (profileBackground) return profileBackground; - /* TODO: Render background colors for other users */ - return undefined; - }, [profileBackground]); + setCurrentGame(userProfile.currentGame); + }, [gameRunning, isMe, userProfile.currentGame]); + + useEffect(() => { + if (isMe) fetchFriendRequests(); + }, [isMe, fetchFriendRequests]); + + useEffect(() => { + if (isMe && profileBackground) { + setProfileContentBoxBackground(profileBackground); + } + + if (userProfile.profileImageUrl) { + profileBackgroundFromProfileImage(userProfile.profileImageUrl).then( + (profileBackground) => { + setProfileContentBoxBackground(profileBackground); + } + ); + } + }, [profileBackground, isMe, userProfile.profileImageUrl]); + + const handleFriendAction = (userId: string, action: FriendAction) => { + try { + if (action === "UNDO") { + undoFriendship(userId).then(updateUserProfile); + return; + } + + if (action === "BLOCK") { + blockUser(userId).then(() => { + setShowUserBlockModal(false); + showSuccessToast(t("user_blocked_successfully")); + navigate(-1); + }); + + return; + } + + if (action === "SEND") { + sendFriendRequest(userProfile.id).then(updateUserProfile); + return; + } + + updateFriendRequestState(userId, action).then(updateUserProfile); + } catch (err) { + showErrorToast(t("try_again")); + } + }; + + const showFriends = isMe || userProfile.totalFriends > 0; + const showProfileContent = + isMe || + userProfile.profileVisibility === "PUBLIC" || + (userProfile.relation?.status === "ACCEPTED" && + userProfile.profileVisibility === "FRIENDS"); + + const getProfileActions = () => { + if (isMe) { + return ( + <> + + + + + ); + } + + if (userProfile.relation == null) { + return ( + <> + + + + + ); + } + + if (userProfile.relation.status === "ACCEPTED") { + return ( + <> + + + ); + } + + if (userProfile.relation.BId === userProfile.id) { + return ( + + ); + } + + return ( + <> + + + + ); + }; return ( <> - setShowEditProfileModal(false)} + setShowProfileSettingsModal(false)} updateUserProfile={updateUserProfile} userProfile={userProfile} /> @@ -117,6 +281,20 @@ export function UserContent({ onConfirm={handleConfirmSignout} /> + setShowUserBlockModal(false)} + onConfirm={() => handleFriendAction(userProfile.id, "BLOCK")} + displayName={userProfile.displayName} + /> + + setShowUndoFriendshipModal(false)} + onConfirm={() => handleFriendAction(userProfile.id, "UNDO")} + displayName={userProfile.displayName} + /> +
- {gameRunning && isMe && ( + {currentGame && ( {gameRunning.title} )} @@ -154,8 +332,10 @@ export function UserContent({
-

{userProfile.displayName}

- {isMe && gameRunning && ( +

+ {userProfile.displayName} +

+ {currentGame && (
- - {gameRunning.title} + + {currentGame.title}
{t("playing_for", { amount: formatDiffInMillis( - gameRunning.sessionDurationInMillis, + currentGame.sessionDurationInMillis, new Date() ), })} @@ -187,154 +367,89 @@ export function UserContent({ )}
- {isMe && ( +
-
- <> - - - - -
+ {getProfileActions()}
- )} +
-
-
-

{t("activity")}

- - {!userProfile.recentGames.length ? ( -
-
- -
-

{t("no_recent_activity_title")}

- {isMe && ( -

- {t("no_recent_activity_description")} -

- )} -
- ) : ( -
- {userProfile.recentGames.map((game) => ( - - ))} -
- )} -
- -
+ {showProfileContent && ( +
-
-

{t("library")}

+

{t("activity")}

+ {!userProfile.recentGames.length ? ( +
+
+ +
+

{t("no_recent_activity_title")}

+ {isMe &&

{t("no_recent_activity_description")}

} +
+ ) : (
-

- {userProfile.libraryGames.length} -

-
- {t("total_play_time", { amount: formatPlayTime() })} -
- {userProfile.libraryGames.map((game) => ( - - ))} -
+
+

{game.title}

+ + {t("last_time_played", { + period: formatDistance( + game.lastTimePlayed!, + new Date(), + { + addSuffix: true, + } + ), + })} + +
+ + ))} +
+ )}
- {(isMe || - (userProfile.friends && userProfile.friends.length > 0)) && ( -
- - +
+ + {t("total_play_time", { amount: formatPlayTime() })} +
- {userProfile.friends.map((friend) => { - return ( - - ); - })} - - {isMe && ( - - )} + {game.iconUrl ? ( + {game.title} + ) : ( + + )} + + ))}
- )} + + {showFriends && ( +
+ + +
+ {userProfile.friends.map((friend) => { + return ( + + ); + })} + + {isMe && ( + + )} +
+
+ )} +
- + )} ); } diff --git a/src/renderer/src/pages/user/user-edit-modal.tsx b/src/renderer/src/pages/user/user-edit-modal.tsx deleted file mode 100644 index a22650ee..00000000 --- a/src/renderer/src/pages/user/user-edit-modal.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { Button, Modal, TextField } from "@renderer/components"; -import { UserProfile } from "@types"; -import * as styles from "./user.css"; -import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react"; -import { SPACING_UNIT } from "@renderer/theme.css"; -import { useEffect, useMemo, useState } from "react"; -import { useToast, useUserDetails } from "@renderer/hooks"; -import { useTranslation } from "react-i18next"; - -export interface UserEditProfileModalProps { - userProfile: UserProfile; - visible: boolean; - onClose: () => void; - updateUserProfile: () => Promise; -} - -export const UserEditProfileModal = ({ - userProfile, - visible, - onClose, - updateUserProfile, -}: UserEditProfileModalProps) => { - const { t } = useTranslation("user_profile"); - - const [displayName, setDisplayName] = useState(""); - const [newImagePath, setNewImagePath] = useState(null); - const [isSaving, setIsSaving] = useState(false); - - const { patchUser } = useUserDetails(); - - const { showSuccessToast, showErrorToast } = useToast(); - - useEffect(() => { - setDisplayName(userProfile.displayName); - }, [userProfile.displayName]); - - const handleChangeProfileAvatar = async () => { - const { filePaths } = await window.electron.showOpenDialog({ - properties: ["openFile"], - filters: [ - { - name: "Image", - extensions: ["jpg", "jpeg", "png", "webp"], - }, - ], - }); - - if (filePaths && filePaths.length > 0) { - const path = filePaths[0]; - - setNewImagePath(path); - } - }; - - const handleSaveProfile: React.FormEventHandler = async ( - event - ) => { - event.preventDefault(); - setIsSaving(true); - - patchUser(displayName, newImagePath) - .then(async () => { - await updateUserProfile(); - showSuccessToast(t("saved_successfully")); - cleanFormAndClose(); - }) - .catch(() => { - showErrorToast(t("try_again")); - }) - .finally(() => { - setIsSaving(false); - }); - }; - - const resetModal = () => { - setDisplayName(userProfile.displayName); - setNewImagePath(null); - }; - - const cleanFormAndClose = () => { - resetModal(); - onClose(); - }; - - const avatarUrl = useMemo(() => { - if (newImagePath) return `local:${newImagePath}`; - if (userProfile.profileImageUrl) return userProfile.profileImageUrl; - return null; - }, [newImagePath, userProfile.profileImageUrl]); - - return ( - <> - -
- - - setDisplayName(e.target.value)} - /> - - -
- - ); -}; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/index.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/index.tsx new file mode 100644 index 00000000..896d3684 --- /dev/null +++ b/src/renderer/src/pages/user/user-profile-settings-modal/index.tsx @@ -0,0 +1 @@ +export * from "./user-profile-settings-modal"; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/user-block-list.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/user-block-list.tsx new file mode 100644 index 00000000..0790b725 --- /dev/null +++ b/src/renderer/src/pages/user/user-profile-settings-modal/user-block-list.tsx @@ -0,0 +1,118 @@ +import { SPACING_UNIT, vars } from "@renderer/theme.css"; +import { UserFriend } from "@types"; +import { useEffect, useRef, useState } from "react"; +import { useToast, useUserDetails } from "@renderer/hooks"; +import { useTranslation } from "react-i18next"; +import { UserFriendItem } from "@renderer/pages/shared-modals/user-friend-modal/user-friend-item"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; + +const pageSize = 12; + +export const UserEditProfileBlockList = () => { + const { t } = useTranslation("user_profile"); + const { showErrorToast } = useToast(); + + const [page, setPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [maxPage, setMaxPage] = useState(0); + const [blocks, setBlocks] = useState([]); + const listContainer = useRef(null); + + const { unblockUser } = useUserDetails(); + + const loadNextPage = () => { + if (page > maxPage) return; + setIsLoading(true); + window.electron + .getUserBlocks(pageSize, page * pageSize) + .then((newPage) => { + if (page === 0) { + setMaxPage(newPage.totalBlocks / pageSize); + } + + setBlocks([...blocks, ...newPage.blocks]); + setPage(page + 1); + }) + .catch(() => {}) + .finally(() => setIsLoading(false)); + }; + + const handleScroll = () => { + const scrollTop = listContainer.current?.scrollTop || 0; + const scrollHeight = listContainer.current?.scrollHeight || 0; + const clientHeight = listContainer.current?.clientHeight || 0; + const maxScrollTop = scrollHeight - clientHeight; + + if (scrollTop < maxScrollTop * 0.9 || isLoading) { + return; + } + + loadNextPage(); + }; + + useEffect(() => { + const container = listContainer.current; + container?.addEventListener("scroll", handleScroll); + return () => container?.removeEventListener("scroll", handleScroll); + }, [isLoading]); + + const reloadList = () => { + setPage(0); + setMaxPage(0); + setBlocks([]); + loadNextPage(); + }; + + useEffect(() => { + reloadList(); + }, []); + + const handleUnblock = (userId: string) => { + unblockUser(userId) + .then(() => { + reloadList(); + }) + .catch(() => { + showErrorToast(t("try_again")); + }); + }; + + return ( + +
+ {!isLoading && blocks.length === 0 &&

{t("no_blocked_users")}

} + {blocks.map((friend) => { + return ( + + ); + })} + {isLoading && ( + + )} +
+
+ ); +}; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/user-edit-profile.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/user-edit-profile.tsx new file mode 100644 index 00000000..6ecdb8a1 --- /dev/null +++ b/src/renderer/src/pages/user/user-profile-settings-modal/user-edit-profile.tsx @@ -0,0 +1,150 @@ +import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react"; +import { Button, SelectField, TextField } from "@renderer/components"; +import { useToast, useUserDetails } from "@renderer/hooks"; +import { UserProfile } from "@types"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as styles from "../user.css"; +import { SPACING_UNIT } from "@renderer/theme.css"; + +export interface UserEditProfileProps { + userProfile: UserProfile; + updateUserProfile: () => Promise; +} + +export const UserEditProfile = ({ + userProfile, + updateUserProfile, +}: UserEditProfileProps) => { + const { t } = useTranslation("user_profile"); + + const [form, setForm] = useState({ + displayName: userProfile.displayName, + profileVisibility: userProfile.profileVisibility, + imageProfileUrl: null as string | null, + }); + const [isSaving, setIsSaving] = useState(false); + + const { patchUser } = useUserDetails(); + + const { showSuccessToast, showErrorToast } = useToast(); + + const [profileVisibilityOptions, setProfileVisibilityOptions] = useState< + { value: string; label: string }[] + >([]); + + useEffect(() => { + setProfileVisibilityOptions([ + { value: "PUBLIC", label: t("public") }, + { value: "FRIENDS", label: t("friends_only") }, + { value: "PRIVATE", label: t("private") }, + ]); + }, [t]); + + const handleChangeProfileAvatar = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: "Image", + extensions: ["jpg", "jpeg", "png", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + const path = filePaths[0]; + + setForm({ ...form, imageProfileUrl: path }); + } + }; + + const handleProfileVisibilityChange = (event) => { + setForm({ + ...form, + profileVisibility: event.target.value, + }); + }; + + const handleSaveProfile: React.FormEventHandler = async ( + event + ) => { + event.preventDefault(); + setIsSaving(true); + + patchUser(form) + .then(async () => { + await updateUserProfile(); + showSuccessToast(t("saved_successfully")); + }) + .catch(() => { + showErrorToast(t("try_again")); + }) + .finally(() => { + setIsSaving(false); + }); + }; + + const avatarUrl = useMemo(() => { + if (form.imageProfileUrl) return `local:${form.imageProfileUrl}`; + if (userProfile.profileImageUrl) return userProfile.profileImageUrl; + return null; + }, [form, userProfile]); + + return ( +
+ + + setForm({ ...form, displayName: e.target.value })} + /> + + ({ + key: visiblity.value, + value: visiblity.value, + label: visiblity.label, + }))} + /> + + + + ); +}; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/user-profile-settings-modal.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/user-profile-settings-modal.tsx new file mode 100644 index 00000000..d71b1bd7 --- /dev/null +++ b/src/renderer/src/pages/user/user-profile-settings-modal/user-profile-settings-modal.tsx @@ -0,0 +1,73 @@ +import { Button, Modal } from "@renderer/components"; +import { UserProfile } from "@types"; +import { SPACING_UNIT } from "@renderer/theme.css"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { UserEditProfile } from "./user-edit-profile"; +import { UserEditProfileBlockList } from "./user-block-list"; + +export interface UserProfileSettingsModalProps { + userProfile: UserProfile; + visible: boolean; + onClose: () => void; + updateUserProfile: () => Promise; +} + +export const UserProfileSettingsModal = ({ + userProfile, + visible, + onClose, + updateUserProfile, +}: UserProfileSettingsModalProps) => { + const { t } = useTranslation("user_profile"); + + const tabs = [t("edit_profile"), t("blocked_users")]; + + const [currentTabIndex, setCurrentTabIndex] = useState(0); + + const renderTab = () => { + if (currentTabIndex == 0) { + return ( + + ); + } + + if (currentTabIndex == 1) { + return ; + } + + return <>; + }; + + return ( + <> + +
+
+ {tabs.map((tab, index) => { + return ( + + ); + })} +
+ {renderTab()} +
+
+ + ); +}; diff --git a/src/renderer/src/pages/user/user-signout-modal.tsx b/src/renderer/src/pages/user/user-sign-out-modal.tsx similarity index 84% rename from src/renderer/src/pages/user/user-signout-modal.tsx rename to src/renderer/src/pages/user/user-sign-out-modal.tsx index a91e8c9d..afc5561b 100644 --- a/src/renderer/src/pages/user/user-signout-modal.tsx +++ b/src/renderer/src/pages/user/user-sign-out-modal.tsx @@ -2,7 +2,7 @@ import { Button, Modal } from "@renderer/components"; import * as styles from "./user.css"; import { useTranslation } from "react-i18next"; -export interface UserEditProfileModalProps { +export interface UserSignOutModalProps { visible: boolean; onConfirm: () => void; onClose: () => void; @@ -12,7 +12,7 @@ export const UserSignOutModal = ({ visible, onConfirm, onClose, -}: UserEditProfileModalProps) => { +}: UserSignOutModalProps) => { const { t } = useTranslation("user_profile"); return ( @@ -23,7 +23,7 @@ export const UserSignOutModal = ({ onClose={onClose} >
-

{t("sign_out_modal_text")}

+

{t("sign_out_modal_text")}