Merge branch 'main' into main

This commit is contained in:
Zamitto 2024-09-08 22:22:39 -03:00 committed by GitHub
commit 1b9f763012
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
136 changed files with 3774 additions and 1502 deletions

View File

@ -1,3 +1,4 @@
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
MAIN_VITE_API_URL=API_URL MAIN_VITE_API_URL=API_URL
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN
SENTRY_AUTH_TOKEN=

View File

@ -2,3 +2,4 @@ node_modules
dist dist
out out
.gitignore .gitignore
migration.stub

View File

@ -27,7 +27,7 @@ body:
label: Expected behavior label: Expected behavior
description: A clear and concise description of what you expected to happen. description: A clear and concise description of what you expected to happen.
validations: validations:
required: true required: false
- type: textarea - type: textarea
id: screenshots id: screenshots
attributes: attributes:
@ -56,3 +56,12 @@ body:
description: Please provide any additional information and context about your problem. description: Please provide any additional information and context about your problem.
validations: validations:
required: false 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

View File

@ -40,8 +40,6 @@ jobs:
sudo apt-get install -y libarchive-tools sudo apt-get install -y libarchive-tools
yarn build:linux yarn build:linux
env: env:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
@ -51,8 +49,6 @@ jobs:
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
run: yarn build:win run: yarn build:win
env: env:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}

View File

@ -42,8 +42,6 @@ jobs:
sudo apt-get install -y libarchive-tools sudo apt-get install -y libarchive-tools
yarn build:linux yarn build:linux
env: env:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
@ -53,8 +51,6 @@ jobs:
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
run: yarn build:win run: yarn build:win
env: env:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}

1
.gitignore vendored
View File

@ -1,7 +1,6 @@
.vscode .vscode
node_modules node_modules
hydra-download-manager/ hydra-download-manager/
aria2/
fastlist.exe fastlist.exe
__pycache__ __pycache__
dist dist

68
SECURITY.md Normal file
View File

@ -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.

View File

@ -3,7 +3,6 @@ productName: Hydra
directories: directories:
buildResources: build buildResources: build
extraResources: extraResources:
- aria2
- hydra-download-manager - hydra-download-manager
- seeds - seeds
- from: node_modules/create-desktop-shortcuts/src/windows.vbs - from: node_modules/create-desktop-shortcuts/src/windows.vbs

View File

@ -23,29 +23,27 @@
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build", "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:unpack": "npm run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win", "build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac", "build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux", "build:linux": "electron-vite build && electron-builder --linux",
"prepare": "husky", "prepare": "husky",
"typeorm:migration-create": "yarn typeorm migration:create" "knex:migrate:make": "knex --knexfile src/main/knexfile.ts migrate:make --esm"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@fontsource/fira-mono": "^5.0.13", "@fontsource/noto-sans": "^5.0.22",
"@fontsource/fira-sans": "^5.0.20",
"@primer/octicons-react": "^19.9.0", "@primer/octicons-react": "^19.9.0",
"@reduxjs/toolkit": "^2.2.3", "@reduxjs/toolkit": "^2.2.3",
"@sentry/electron": "^5.1.0", "@sentry/electron": "^5.1.0",
"@vanilla-extract/css": "^1.14.2", "@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/dynamic": "^2.1.1", "@vanilla-extract/dynamic": "^2.1.1",
"@vanilla-extract/recipes": "^0.5.2", "@vanilla-extract/recipes": "^0.5.2",
"aria2": "^4.1.2",
"auto-launch": "^5.0.6", "auto-launch": "^5.0.6",
"axios": "^1.6.8", "axios": "^1.7.7",
"better-sqlite3": "^9.5.0", "better-sqlite3": "^11.2.1",
"check-disk-space": "^3.4.0", "check-disk-space": "^3.4.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"color": "^4.2.3", "color": "^4.2.3",
@ -60,9 +58,9 @@
"i18next": "^23.11.2", "i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1", "i18next-browser-languagedetector": "^7.2.1",
"icojs": "^0.19.3", "icojs": "^0.19.3",
"iso-639-1": "3.1.2",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lottie-react": "^2.4.0", "lottie-react": "^2.4.0",
"parse-torrent": "^11.0.16", "parse-torrent": "^11.0.16",
@ -97,7 +95,7 @@
"@types/user-agents": "^1.0.4", "@types/user-agents": "^1.0.4",
"@vanilla-extract/vite-plugin": "^4.0.7", "@vanilla-extract/vite-plugin": "^4.0.7",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"electron": "^30.0.9", "electron": "^30.3.0",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"electron-vite": "^2.0.0", "electron-vite": "^2.0.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",
@ -108,6 +106,7 @@
"prettier": "^3.2.4", "prettier": "^3.2.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.0.12", "vite": "^5.0.12",
"vite-plugin-svgr": "^4.2.0" "vite-plugin-svgr": "^4.2.0"

View File

@ -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();

View File

@ -1,4 +1,5 @@
{ {
"language_name": "اَلْعَرَبِيَّةُ",
"home": { "home": {
"featured": "مميّز", "featured": "مميّز",
"trending": "شائع", "trending": "شائع",

View File

@ -1,4 +1,5 @@
{ {
"language_name": "беларуская мова",
"home": { "home": {
"featured": "Рэкамэндаванае", "featured": "Рэкамэндаванае",
"trending": "Актуальнае", "trending": "Актуальнае",

View File

@ -1,4 +1,8 @@
{ {
"language_name": "Català",
"app": {
"successfully_signed_in": "Has entrat correctament"
},
"home": { "home": {
"featured": "Destacats", "featured": "Destacats",
"trending": "Populars", "trending": "Populars",
@ -14,7 +18,10 @@
"paused": "{{title}} (Pausat)", "paused": "{{title}} (Pausat)",
"downloading": "{{title}} ({{percentage}} - S'està baixant…)", "downloading": "{{title}} ({{percentage}} - S'està baixant…)",
"filter": "Filtra la biblioteca", "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": { "header": {
"search": "Cerca jocs", "search": "Cerca jocs",
@ -29,7 +36,9 @@
"bottom_panel": { "bottom_panel": {
"no_downloads_in_progress": "Cap baixada en curs", "no_downloads_in_progress": "Cap baixada en curs",
"downloading_metadata": "S'estan baixant les metadades de: {{title}}…", "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": { "catalogue": {
"next_page": "Pàgina següent", "next_page": "Pàgina següent",
@ -47,12 +56,14 @@
"cancel": "Cancel·la", "cancel": "Cancel·la",
"remove": "Elimina", "remove": "Elimina",
"space_left_on_disk": "{{space}} lliures al disc", "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…", "downloading_metadata": "S'estan baixant les metadades…",
"filter": "Filtra els reempaquetats", "filter": "Filtra els reempaquetats",
"requirements": "Requisits del sistema", "requirements": "Requisits del sistema",
"minimum": "Mínims", "minimum": "Mínims",
"recommended": "Recomanats", "recommended": "Recomanats",
"paused": "Paused",
"release_date": "Publicat el {{date}}", "release_date": "Publicat el {{date}}",
"publisher": "Publicat per {{publisher}}", "publisher": "Publicat per {{publisher}}",
"hours": "hores", "hours": "hores",
@ -81,7 +92,29 @@
"previous_screenshot": "Captura anterior", "previous_screenshot": "Captura anterior",
"next_screenshot": "Captura següent", "next_screenshot": "Captura següent",
"screenshot": "Captura {{number}}", "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": { "activation": {
"title": "Activa l'Hydra", "title": "Activa l'Hydra",
@ -98,6 +131,7 @@
"paused": "Pausada", "paused": "Pausada",
"verifying": "S'està verificant…", "verifying": "S'està verificant…",
"completed": "Completada", "completed": "Completada",
"removed": "No descarregat",
"cancel": "Cancel·la", "cancel": "Cancel·la",
"filter": "Filtra els jocs baixats", "filter": "Filtra els jocs baixats",
"remove": "Elimina", "remove": "Elimina",
@ -106,7 +140,14 @@
"delete": "Elimina l'instal·lador", "delete": "Elimina l'instal·lador",
"delete_modal_title": "N'estàs segur?", "delete_modal_title": "N'estàs segur?",
"delete_modal_description": "S'eliminaran de l'ordinador tots els fitxers d'instal·lació", "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": { "settings": {
"downloads_path": "Ruta de baixades", "downloads_path": "Ruta de baixades",
@ -119,16 +160,49 @@
"launch_with_system": "Inicia l'Hydra quan s'iniciï el sistema", "launch_with_system": "Inicia l'Hydra quan s'iniciï el sistema",
"general": "General", "general": "General",
"behavior": "Comportament", "behavior": "Comportament",
"download_sources": "Fonts de descàrrega",
"language": "Idioma",
"real_debrid_api_token": "Testimoni API",
"enable_real_debrid": "Activa el Real Debrid", "enable_real_debrid": "Activa el Real Debrid",
"real_debrid_description": "Real-Debrid és un programa de descàrrega sense restriccions que us permet descarregar fitxers a l'instant i al màxim de la vostra velocitat d'Internet.",
"real_debrid_invalid_token": "Invalida el testimoni de l'API",
"real_debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí</0>.", "real_debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí</0>.",
"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": { "notifications": {
"download_complete": "La baixada ha finalitzat", "download_complete": "La baixada ha finalitzat",
"game_ready_to_install": "{{title}} ja es pot instal·lar", "game_ready_to_install": "{{title}} ja es pot instal·lar",
"repack_list_updated": "S'ha actualitzat la llista de reempaquetats", "repack_list_updated": "S'ha actualitzat la llista de reempaquetats",
"repack_count_one": "S'ha afegit {{count}} reempaquetat", "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": { "system_tray": {
"open": "Obre l'Hydra", "open": "Obre l'Hydra",
@ -144,5 +218,39 @@
}, },
"modal": { "modal": {
"close": "Botó de tancar" "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"
} }
} }

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Dansk",
"home": { "home": {
"featured": "Anbefalet", "featured": "Anbefalet",
"trending": "Trender", "trending": "Trender",

View File

@ -1,4 +1,5 @@
{ {
"language_name": "English",
"app": { "app": {
"successfully_signed_in": "Successfully signed in" "successfully_signed_in": "Successfully signed in"
}, },
@ -174,12 +175,9 @@
"validate_download_source": "Validate", "validate_download_source": "Validate",
"remove_download_source": "Remove", "remove_download_source": "Remove",
"add_download_source": "Add source", "add_download_source": "Add source",
"download_count_zero": "No downloads in list", "download_count_zero": "No download options",
"download_count_one": "{{countFormatted}} download in list", "download_count_one": "{{countFormatted}} download option",
"download_count_other": "{{countFormatted}} downloads in list", "download_count_other": "{{countFormatted}} download options",
"download_options_zero": "No download available",
"download_options_one": "{{countFormatted}} download available",
"download_options_other": "{{countFormatted}} downloads available",
"download_source_url": "Download source URL", "download_source_url": "Download source URL",
"add_download_source_description": "Insert the URL containing the .json file", "add_download_source_description": "Insert the URL containing the .json file",
"download_source_up_to_date": "Up-to-date", "download_source_up_to_date": "Up-to-date",
@ -250,6 +248,30 @@
"friend_request_sent": "Friend request sent", "friend_request_sent": "Friend request sent",
"friends": "Friends", "friends": "Friends",
"friends_list": "Friends list", "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}}"
} }
} }

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Español",
"app": { "app": {
"successfully_signed_in": "Sesión iniciada correctamente" "successfully_signed_in": "Sesión iniciada correctamente"
}, },
@ -177,9 +178,6 @@
"download_count_zero": "No hay descargas en la lista", "download_count_zero": "No hay descargas en la lista",
"download_count_one": "{{countFormatted}} descarga en la lista", "download_count_one": "{{countFormatted}} descarga en la lista",
"download_count_other": "{{countFormatted}} descargas 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", "download_source_url": "Descargar URL de origen",
"add_download_source_description": "Introduce la URL con el archivo .json", "add_download_source_description": "Introduce la URL con el archivo .json",
"download_source_up_to_date": "Al día", "download_source_up_to_date": "Al día",
@ -241,6 +239,38 @@
"successfully_signed_out": "Sesión cerrada exitosamente", "successfully_signed_out": "Sesión cerrada exitosamente",
"sign_out": "Cerrar sesión", "sign_out": "Cerrar sesión",
"playing_for": "Jugando por {{amount}}", "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"
} }
} }

View File

@ -1,4 +1,5 @@
{ {
"language_name": "فارسی",
"home": { "home": {
"featured": "پیشنهادی", "featured": "پیشنهادی",
"trending": "پرطرفدار", "trending": "پرطرفدار",

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Français",
"home": { "home": {
"featured": "En vedette", "featured": "En vedette",
"trending": "Tendance", "trending": "Tendance",

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Magyar",
"home": { "home": {
"featured": "Featured", "featured": "Featured",
"trending": "Népszerű", "trending": "Népszerű",

View File

@ -1,134 +1,256 @@
{ {
"language_name": "Bahasa Indonesia",
"app": {
"successfully_signed_in": "Berhasil masuk"
},
"home": { "home": {
"featured": "Unggulan", "featured": "Unggulan",
"trending": "Trending", "trending": "Sedang Tren",
"surprise_me": "Kejutkan Saya", "surprise_me": "Kejutkan saya",
"no_results": "Tidak ada hasil" "no_results": "Tidak ada hasil ditemukan"
}, },
"sidebar": { "sidebar": {
"catalogue": "Katalog", "catalogue": "Katalog",
"downloads": "Unduhan", "downloads": "Unduhan",
"settings": "Pengaturan", "settings": "Pengaturan",
"my_library": "Koleksi saya", "my_library": "Perpustakaan saya",
"downloading_metadata": "{{title}} (Mengunduh metadata…)", "downloading_metadata": "{{title}} (Mengunduh metadata…)",
"paused": "{{title}} (Terhenti)", "paused": "{{title}} (Dijeda)",
"downloading": "{{title}} ({{percentage}} - Mengunduh…)", "downloading": "{{title}} ({{percentage}} - Mengunduh…)",
"filter": "Filter koleksi", "filter": "Filter perpustakaan",
"home": "Beranda" "home": "Beranda",
"queued": "{{title}} (Antrian)",
"game_has_no_executable": "Game tidak punya file eksekusi yang dipilih",
"sign_in": "Masuk"
}, },
"header": { "header": {
"search": "Pencarian", "search": "Cari game",
"home": "Beranda", "home": "Beranda",
"catalogue": "Katalog", "catalogue": "Katalog",
"downloads": "Unduhan", "downloads": "Unduhan",
"search_results": "Hasil pencarian", "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": { "bottom_panel": {
"no_downloads_in_progress": "Tidak ada unduhan berjalan", "no_downloads_in_progress": "Tidak ada unduhan yang sedang berjalan",
"downloading_metadata": "Mengunduh metadata {{title}}...", "downloading_metadata": "Mengunduh metadata {{title}}…",
"downloading": "Mengunduh {{title}}… ({{percentage}} selesai) - Perkiraan {{eta}} - {{speed}}" "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": { "catalogue": {
"next_page": "Halaman berikutnya", "next_page": "Halaman Berikutnya",
"previous_page": "Halaman sebelumnya" "previous_page": "Halaman Sebelumnya"
}, },
"game_details": { "game_details": {
"open_download_options": "Buka opsi unduhan", "open_download_options": "Buka opsi unduhan",
"download_options_zero": "Tidak ada opsi unduhan", "download_options_zero": "Tidak ada opsi unduhan",
"download_options_one": "{{count}} opsi unduhan", "download_options_one": "{{count}} opsi unduhan",
"download_options_other": "{{count}} opsi unduhan", "download_options_other": "{{count}} opsi unduhan",
"updated_at": "Diperbarui {{updated_at}}", "updated_at": "Diperbarui pada {{updated_at}}",
"install": "Install", "install": "Instal",
"resume": "Lanjutkan", "resume": "Lanjutkan",
"pause": "Hentikan sementara", "pause": "Jeda",
"cancel": "Batalkan", "cancel": "Batal",
"remove": "Hapus", "remove": "Hapus",
"space_left_on_disk": "{{space}} tersisa pada disk", "space_left_on_disk": "{{space}} tersisa di disk",
"eta": "Perkiraan {{eta}}", "eta": "Estimasi {{eta}}",
"calculating_eta": "Menghitung waktu yang tersisa…",
"downloading_metadata": "Mengunduh metadata…", "downloading_metadata": "Mengunduh metadata…",
"filter": "Saring repacks", "filter": "Filter repack",
"requirements": "Keperluan sistem", "requirements": "Persyaratan sistem",
"minimum": "Minimum", "minimum": "Minimum",
"recommended": "Rekomendasi", "recommended": "Dianjurkan",
"paused": "Dijeda",
"release_date": "Dirilis pada {{date}}", "release_date": "Dirilis pada {{date}}",
"publisher": "Dipublikasikan oleh {{publisher}}", "publisher": "Diterbitkan oleh {{publisher}}",
"hours": "jam", "hours": "jam",
"minutes": "menit", "minutes": "menit",
"amount_hours": "{{amount}} jam", "amount_hours": "{{amount}} jam",
"amount_minutes": "{{amount}} menit", "amount_minutes": "{{amount}} menit",
"accuracy": "{{accuracy}}% akurasi", "accuracy": "{{accuracy}}% akurasi",
"add_to_library": "Tambahkan ke koleksi", "add_to_library": "Tambah ke perpustakaan",
"remove_from_library": "Hapus dari koleksi", "remove_from_library": "Hapus dari perpustakaan",
"no_downloads": "Tidak ada unduhan tersedia", "no_downloads": "Tidak ada yang bisa diunduh",
"play_time": "Dimainkan selama {{amount}}", "play_time": "Dimainkan selama {{amount}}",
"last_time_played": "Terakhir dimainkan {{period}}", "last_time_played": "Terakhir dimainkan {{period}}",
"not_played_yet": "Kamu belum memainkan {{title}}", "not_played_yet": "Kamu belum memainkan {{title}}",
"next_suggestion": "Rekomendasi berikutnya", "next_suggestion": "Saran berikutnya",
"play": "Mainkan", "play": "Main",
"deleting": "Menghapus installer…", "deleting": "Menghapus installer…",
"close": "Tutup", "close": "Tutup",
"playing_now": "Memainkan sekarang", "playing_now": "Sedang dimainkan",
"change": "Ubah", "change": "Ubah",
"repacks_modal_description": "Pilih repack yang kamu ingin unduh", "repacks_modal_description": "Pilih repack yang ingin kamu unduh",
"select_folder_hint": "Untuk merubah folder bawaan, akses melalui", "select_folder_hint": "Untuk ganti folder default, buka <0>Pengaturan</0>",
"download_now": "Unduh sekarang" "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": { "activation": {
"title": "Aktivasi Hydra", "title": "Aktifkan Hydra",
"installation_id": "ID instalasi:", "installation_id": "ID Instalasi:",
"enter_activation_code": "Masukkan kode aktivasi", "enter_activation_code": "Masukkan kode aktivasi kamu",
"message": "Jika kamu tidak tau dimana bertanya untuk ini, maka kamu tidak seharusnya memiliki ini.", "message": "Kalau tidak tahu harus tanya ke siapa, berarti kamu tidak perlu ini.",
"activate": "Aktifkan", "activate": "Aktifkan",
"loading": "Memuat…" "loading": "Memuat…"
}, },
"downloads": { "downloads": {
"resume": "Lanjutkan", "resume": "Lanjutkan",
"pause": "Hentikan sementara", "pause": "Jeda",
"eta": "Perkiraan {{eta}}", "eta": "Estimasi {{eta}}",
"paused": "Terhenti sementara", "paused": "Dijeda",
"verifying": "Memeriksa…", "verifying": "Verifikasi…",
"completed": "Selesai", "completed": "Selesai",
"cancel": "Batalkan", "removed": "Tidak diunduh",
"filter": "Saring game yang diunduh", "cancel": "Batal",
"filter": "Filter game yang diunduh",
"remove": "Hapus", "remove": "Hapus",
"downloading_metadata": "Mengunduh metadata…", "downloading_metadata": "Mengunduh metadata…",
"deleting": "Menghapus file instalasi…", "deleting": "Menghapus installer…",
"delete": "Hapus file instalasi", "delete": "Hapus installer",
"delete_modal_title": "Kamu yakin?", "delete_modal_title": "Apa kamu yakin?",
"delete_modal_description": "Proses ini akan menghapus semua file instalasi dari komputer kamu", "delete_modal_description": "Ini akan menghapus semua file instalasi dari komputer kamu",
"install": "Install" "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": { "settings": {
"downloads_path": "Lokasi unduhan", "downloads_path": "Path unduhan",
"change": "Perbarui", "change": "Ganti",
"notifications": "Pengingat", "notifications": "Notifikasi",
"enable_download_notifications": "Saat unduhan selesai", "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", "behavior": "Perilaku",
"quit_app_instead_hiding": "Tutup aplikasi alih-alih menyembunyikan aplikasi", "download_sources": "Sumber unduhan",
"launch_with_system": "Jalankan saat memulai sistem" "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</0>",
"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": { "notifications": {
"download_complete": "Unduhan selesai", "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_list_updated": "Daftar repack diperbarui",
"repack_count_one": "{{count}} repack ditambahkan", "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": { "system_tray": {
"open": "Buka Hydra", "open": "Buka Hydra",
"quit": "Tutup" "quit": "Keluar"
}, },
"game_card": { "game_card": {
"no_downloads": "Tidak ada unduhan tersedia" "no_downloads": "Tidak ada unduhan yang tersedia"
}, },
"binary_not_found_modal": { "binary_not_found_modal": {
"title": "Program tidak terinstal", "title": "Program tidak terpasang",
"description": "Wine atau Lutris exe tidak ditemukan pada sistem kamu", "description": "Executable Wine atau Lutris tidak ditemukan di sistem kamu",
"instructions": "Periksa cara instalasi yang benar pada Linux distro-mu agar game dapat dimainkan dengan benar" "instructions": "Cek cara instalasi yang benar di distro Linux kamu agar game bisa jalan normal"
}, },
"modal": { "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"
} }
} }

View File

@ -1,21 +1,47 @@
export { default as en } from "./en/translation.json"; import en from "./en/translation.json";
export { default as pt } from "./pt/translation.json"; import ptPT from "./pt-PT/translation.json";
export { default as es } from "./es/translation.json"; import ptBR from "./pt-BR/translation.json";
export { default as nl } from "./nl/translation.json"; import es from "./es/translation.json";
export { default as fr } from "./fr/translation.json"; import nl from "./nl/translation.json";
export { default as hu } from "./hu/translation.json"; import fr from "./fr/translation.json";
export { default as it } from "./it/translation.json"; import hu from "./hu/translation.json";
export { default as pl } from "./pl/translation.json"; import it from "./it/translation.json";
export { default as ru } from "./ru/translation.json"; import pl from "./pl/translation.json";
export { default as tr } from "./tr/translation.json"; import ru from "./ru/translation.json";
export { default as be } from "./be/translation.json"; import tr from "./tr/translation.json";
export { default as uk } from "./uk/translation.json"; import be from "./be/translation.json";
export { default as zh } from "./zh/translation.json"; import uk from "./uk/translation.json";
export { default as id } from "./id/translation.json"; import zh from "./zh/translation.json";
export { default as ko } from "./ko/translation.json"; import id from "./id/translation.json";
export { default as da } from "./da/translation.json"; import ko from "./ko/translation.json";
export { default as ar } from "./ar/translation.json"; import da from "./da/translation.json";
export { default as fa } from "./fa/translation.json"; import ar from "./ar/translation.json";
export { default as ro } from "./ro/translation.json"; import fa from "./fa/translation.json";
export { default as ca } from "./ca/translation.json"; import ro from "./ro/translation.json";
export { default as kk } from "./kk/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,
};

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Italiano",
"home": { "home": {
"featured": "In primo piano", "featured": "In primo piano",
"trending": "Di tendenza", "trending": "Di tendenza",

View File

@ -1,4 +1,5 @@
{ {
"language_name": "қазақ тілі",
"app": { "app": {
"successfully_signed_in": "Сәтті кіру" "successfully_signed_in": "Сәтті кіру"
}, },

View File

@ -1,4 +1,5 @@
{ {
"language_name": "한국어",
"home": { "home": {
"featured": "추천", "featured": "추천",
"trending": "인기", "trending": "인기",

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Nederlands",
"home": { "home": {
"featured": "Uitgelicht", "featured": "Uitgelicht",
"trending": "Trending", "trending": "Trending",

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Polski",
"home": { "home": {
"featured": "Wyróżnione", "featured": "Wyróżnione",
"trending": "Trendujące", "trending": "Trendujące",

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Português (Brasil)",
"app": { "app": {
"successfully_signed_in": "Autenticado com sucesso" "successfully_signed_in": "Autenticado com sucesso"
}, },
@ -166,7 +167,7 @@
"real_debrid_linked_message": "Conta \"{{username}}\" vinculada", "real_debrid_linked_message": "Conta \"{{username}}\" vinculada",
"save_changes": "Salvar mudanças", "save_changes": "Salvar mudanças",
"changes_saved": "Ajustes salvos com sucesso", "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", "validate_download_source": "Validar",
"remove_download_source": "Remover", "remove_download_source": "Remover",
"add_download_source": "Adicionar fonte", "add_download_source": "Adicionar fonte",
@ -250,6 +251,30 @@
"add": "Adicionar", "add": "Adicionar",
"sending": "Enviando", "sending": "Enviando",
"friends_list": "Lista de amigos", "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}}"
} }
} }

View File

@ -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</0>",
"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</0>",
"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"
}
}

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Română",
"home": { "home": {
"featured": "Recomandate", "featured": "Recomandate",
"trending": "Populare", "trending": "Populare",

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Русский",
"app": { "app": {
"successfully_signed_in": "Успешный вход" "successfully_signed_in": "Успешный вход"
}, },
@ -177,9 +178,6 @@
"download_count_zero": "В списке нет загрузок", "download_count_zero": "В списке нет загрузок",
"download_count_one": "{{countFormatted}} загрузка в списке", "download_count_one": "{{countFormatted}} загрузка в списке",
"download_count_other": "{{countFormatted}} загрузок в списке", "download_count_other": "{{countFormatted}} загрузок в списке",
"download_options_zero": "Нет доступных загрузок",
"download_options_one": "{{countFormatted}} вариант загрузки доступен",
"download_options_other": "{{countFormatted}} вариантов загрузки доступно",
"download_source_url": "Ссылка на источник", "download_source_url": "Ссылка на источник",
"add_download_source_description": "Вставьте ссылку на .json-файл", "add_download_source_description": "Вставьте ссылку на .json-файл",
"download_source_up_to_date": "Обновлён", "download_source_up_to_date": "Обновлён",
@ -241,6 +239,38 @@
"successfully_signed_out": "Успешный выход из аккаунта", "successfully_signed_out": "Успешный выход из аккаунта",
"sign_out": "Выйти", "sign_out": "Выйти",
"playing_for": "Сыграно {{amount}}", "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": "Код друга скопирован"
} }
} }

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Türkçe",
"home": { "home": {
"featured": "Öne çıkan", "featured": "Öne çıkan",
"trending": "Popüler", "trending": "Popüler",

View File

@ -1,4 +1,5 @@
{ {
"language_name": "Українська",
"app": { "app": {
"successfully_signed_in": "Успішний вхід в систему" "successfully_signed_in": "Успішний вхід в систему"
}, },

View File

@ -1,4 +1,5 @@
{ {
"language_name": "中文",
"app": { "app": {
"successfully_signed_in": "已成功登录" "successfully_signed_in": "已成功登录"
}, },

View File

@ -6,32 +6,22 @@ import {
GameShopCache, GameShopCache,
Repack, Repack,
UserPreferences, UserPreferences,
UserAuth,
} from "@main/entity"; } from "@main/entity";
import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions";
import { databasePath } from "./constants"; import { databasePath } from "./constants";
import migrations from "./migrations";
import { UserAuth } from "./entity/user-auth";
export const createDataSource = ( export const dataSource = new DataSource({
options: Partial<BetterSqlite3ConnectionOptions> type: "better-sqlite3",
) => entities: [
new DataSource({ Game,
type: "better-sqlite3", Repack,
entities: [ UserPreferences,
Game, GameShopCache,
Repack, DownloadSource,
UserPreferences, DownloadQueue,
GameShopCache, UserAuth,
DownloadSource, ],
DownloadQueue, synchronize: false,
UserAuth, database: databasePath,
],
synchronize: true,
database: databasePath,
...options,
});
export const dataSource = createDataSource({
migrations,
}); });

View File

@ -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<void>;
call(
method: "addUri",
uris: string[],
options: { dir: string }
): Promise<string>;
call(
method: "tellStatus",
gid: string,
keys?: string[]
): Promise<StatusResponse>;
call(method: "pause", gid: string): Promise<string>;
call(method: "forcePause", gid: string): Promise<string>;
call(method: "unpause", gid: string): Promise<string>;
call(method: "remove", gid: string): Promise<string>;
call(method: "forceRemove", gid: string): Promise<string>;
call(method: "pauseAll"): Promise<string>;
call(method: "forcePauseAll"): Promise<string>;
listNotifications: () => [
"onDownloadStart",
"onDownloadPause",
"onDownloadStop",
"onDownloadComplete",
"onDownloadError",
"onBtDownloadComplete",
];
on: (event: string, callback: (params: any) => void) => void;
}
}

View File

@ -16,15 +16,12 @@ export class Repack {
@Column("text", { unique: true }) @Column("text", { unique: true })
title: string; title: string;
/**
* @deprecated Use uris instead
*/
@Column("text", { unique: true }) @Column("text", { unique: true })
magnet: string; magnet: string;
/**
* @deprecated
*/
@Column("int", { nullable: true })
page: number;
@Column("text") @Column("text")
repacker: string; repacker: string;
@ -37,6 +34,9 @@ export class Repack {
@ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" }) @ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" })
downloadSource: DownloadSource; downloadSource: DownloadSource;
@Column("text", { default: "[]" })
uris: string;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View File

@ -26,6 +26,8 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
/* Disconnects libtorrent */ /* Disconnects libtorrent */
PythonInstance.killTorrent(); PythonInstance.killTorrent();
HydraApi.handleSignOut();
await Promise.all([ await Promise.all([
databaseOperations, databaseOperations,
HydraApi.post("/auth/logout").catch(() => {}), HydraApi.post("/auth/logout").catch(() => {}),

View File

@ -1,16 +1,11 @@
import { downloadSourceRepository } from "@main/repository"; import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
return downloadSourceRepository downloadSourceRepository.find({
.createQueryBuilder("downloadSource") order: {
.leftJoin("downloadSource.repacks", "repacks") createdAt: "DESC",
.orderBy("downloadSource.createdAt", "DESC") },
.loadRelationCountAndMap( });
"downloadSource.repackCount",
"downloadSource.repacks"
)
.getMany();
};
registerEvent("getDownloadSources", getDownloadSources); registerEvent("getDownloadSources", getDownloadSources);

View File

@ -43,16 +43,19 @@ import "./auth/sign-out";
import "./auth/open-auth-window"; import "./auth/open-auth-window";
import "./auth/get-session-hash"; import "./auth/get-session-hash";
import "./user/get-user"; 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-friend-requests";
import "./profile/get-me"; import "./profile/get-me";
import "./profile/undo-friendship";
import "./profile/update-friend-request"; import "./profile/update-friend-request";
import "./profile/update-profile"; import "./profile/update-profile";
import "./profile/send-friend-request"; import "./profile/send-friend-request";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion()); ipcMain.handle("getVersion", () => app.getVersion());
ipcMain.handle( ipcMain.handle("isPortableVersion", () => isPortableVersion());
"isPortableVersion",
() => process.env.PORTABLE_EXECUTABLE_FILE != null
);
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath); ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);

View File

@ -20,7 +20,7 @@ const removeRemoveGameFromLibrary = async (gameId: number) => {
const game = await gameRepository.findOne({ where: { id: gameId } }); const game = await gameRepository.findOne({ where: { id: gameId } });
if (game?.remoteId) { if (game?.remoteId) {
HydraApi.delete(`/games/${game.remoteId}`).catch(() => {}); HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
} }
}; };

View File

@ -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);

View File

@ -4,33 +4,22 @@ import axios from "axios";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileTypeFromFile } from "file-type"; import { fileTypeFromFile } from "file-type";
import { UserProfile } from "@types"; import { UpdateProfileProps, UserProfile } from "@types";
const patchUserProfile = async ( const patchUserProfile = async (updateProfile: UpdateProfileProps) => {
displayName: string, return HydraApi.patch("/profile", updateProfile);
profileImageUrl?: string
) => {
if (profileImageUrl) {
return HydraApi.patch("/profile", {
displayName,
profileImageUrl,
});
} else {
return HydraApi.patch("/profile", {
displayName,
});
}
}; };
const updateProfile = async ( const updateProfile = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
displayName: string, updateProfile: UpdateProfileProps
newProfileImagePath: string | null
): Promise<UserProfile> => { ): Promise<UserProfile> => {
if (!newProfileImagePath) { if (!updateProfile.profileImageUrl) {
return patchUserProfile(displayName); return patchUserProfile(updateProfile);
} }
const newProfileImagePath = updateProfile.profileImageUrl;
const stats = fs.statSync(newProfileImagePath); const stats = fs.statSync(newProfileImagePath);
const fileBuffer = fs.readFileSync(newProfileImagePath); const fileBuffer = fs.readFileSync(newProfileImagePath);
const fileSizeInBytes = stats.size; const fileSizeInBytes = stats.size;
@ -53,7 +42,7 @@ const updateProfile = async (
}) })
.catch(() => undefined); .catch(() => undefined);
return patchUserProfile(displayName, profileImageUrl); return patchUserProfile({ ...updateProfile, profileImageUrl });
}; };
registerEvent("updateProfile", updateProfile); registerEvent("updateProfile", updateProfile);

View File

@ -18,7 +18,8 @@ const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
payload: StartGameDownloadPayload 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([ const [game, repack] = await Promise.all([
gameRepository.findOne({ gameRepository.findOne({
@ -54,7 +55,7 @@ const startGameDownload = async (
bytesDownloaded: 0, bytesDownloaded: 0,
downloadPath, downloadPath,
downloader, downloader,
uri: repack.magnet, uri,
isDeleted: false, isDeleted: false,
} }
); );
@ -76,7 +77,7 @@ const startGameDownload = async (
shop, shop,
status: "active", status: "active",
downloadPath, downloadPath,
uri: repack.magnet, uri,
}) })
.then((result) => { .then((result) => {
if (iconUrl) { if (iconUrl) {
@ -100,6 +101,7 @@ const startGameDownload = async (
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
await DownloadManager.cancelDownload(updatedGame!.id);
await DownloadManager.startDownload(updatedGame!); await DownloadManager.startDownload(updatedGame!);
}; };

View File

@ -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);

View File

@ -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<UserBlocks> => {
return HydraApi.get(`/profile/blocks`, { take, skip });
};
registerEvent("getUserBlocks", getUserBlocks);

View File

@ -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<UserFriends> => {
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);

View File

@ -1,55 +1,76 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { UserProfile } from "@types"; import { GameRunning, UserGame, UserProfile } from "@types";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { getSteamAppAsset } from "@main/helpers"; import { getSteamAppAsset } from "@main/helpers";
import { getUserFriends } from "./get-user-friends";
const getUser = async ( const getUser = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
userId: string userId: string
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
try { 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( const recentGames = await Promise.all(
profile.recentGames.map(async (game) => { profile.recentGames.map(async (game) => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), { return getSteamUserGame(game);
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
return {
...game,
...convertSteamGameToCatalogueEntry(steamGame),
iconUrl,
};
}) })
); );
const libraryGames = await Promise.all( const libraryGames = await Promise.all(
profile.libraryGames.map(async (game) => { profile.libraryGames.map(async (game) => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), { return getSteamUserGame(game);
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
return {
...game,
...convertSteamGameToCatalogueEntry(steamGame),
iconUrl,
};
}) })
); );
return { ...profile, libraryGames, recentGames }; const currentGame = await getGameRunning(profile.currentGame);
return {
...profile,
libraryGames,
recentGames,
friends: friends.friends,
totalFriends: friends.totalFriends,
currentGame,
};
} catch (err) { } catch (err) {
return null; return null;
} }
}; };
const getGameRunning = async (currentGame): Promise<GameRunning | null> => {
if (!currentGame) {
return null;
}
const gameRunning = await getSteamUserGame(currentGame);
return {
...gameRunning,
sessionDurationInMillis: currentGame.sessionDurationInSeconds * 1000,
};
};
const getSteamUserGame = async (game): Promise<UserGame> => {
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); registerEvent("getUser", getUser);

View File

@ -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);

View File

@ -17,7 +17,8 @@ export const insertDownloadsFromSource = async (
const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map( const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map(
(download) => ({ (download) => ({
title: download.title, title: download.title,
magnet: download.uris[0], uris: JSON.stringify(download.uris),
magnet: download.uris[0]!,
fileSize: download.fileSize, fileSize: download.fileSize,
repacker: downloadSource.name, repacker: downloadSource.name,
uploadDate: download.uploadDate, uploadDate: download.uploadDate,

View File

@ -1,4 +1,5 @@
import axios from "axios"; import axios from "axios";
import { JSDOM } from "jsdom";
import UserAgent from "user-agents"; import UserAgent from "user-agents";
export const getSteamAppAsset = ( export const getSteamAppAsset = (
@ -48,13 +49,19 @@ export const sleep = (ms: number) =>
export const requestWebPage = async (url: string) => { export const requestWebPage = async (url: string) => {
const userAgent = new UserAgent(); const userAgent = new UserAgent();
return axios const data = await axios
.get(url, { .get(url, {
headers: { headers: {
"User-Agent": userAgent.toString(), "User-Agent": userAgent.toString(),
}, },
}) })
.then((response) => response.data); .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"; export * from "./download-source";

View File

@ -7,8 +7,9 @@ import url from "node:url";
import { electronApp, optimizer } from "@electron-toolkit/utils"; import { electronApp, optimizer } from "@electron-toolkit/utils";
import { logger, PythonInstance, WindowManager } from "@main/services"; import { logger, PythonInstance, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source"; import { dataSource } from "@main/data-source";
import * as resources from "@locales"; import resources from "@locales";
import { userPreferencesRepository } from "@main/repository"; import { userPreferencesRepository } from "@main/repository";
import { knexClient, migrationConfig } from "./knex-client";
const { autoUpdater } = updater; const { autoUpdater } = updater;
@ -20,8 +21,6 @@ autoUpdater.setFeedURL({
autoUpdater.logger = logger; autoUpdater.logger = logger;
logger.log("Init Hydra");
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit(); if (!gotTheLock) app.quit();
@ -54,6 +53,18 @@ if (process.defaultApp) {
app.setAsDefaultProtocolClient(PROTOCOL); 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 // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // 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()); 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.initialize();
await dataSource.runMigrations();
await import("./main"); await import("./main");
@ -88,10 +106,15 @@ app.on("browser-window-created", (_, window) => {
const handleDeepLinkPath = (uri?: string) => { const handleDeepLinkPath = (uri?: string) => {
if (!uri) return; if (!uri) return;
const url = new URL(uri);
if (url.host === "install-source") { try {
WindowManager.redirect(`settings${url.search}`); 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", () => { app.on("before-quit", () => {
/* Disconnects libtorrent */ /* Disconnects libtorrent */
PythonInstance.kill(); PythonInstance.kill();
logger.log("Quit Hydra");
}); });
app.on("activate", () => { app.on("activate", () => {

29
src/main/knex-client.ts Normal file
View File

@ -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<HydraMigration> {
getMigrations(): Promise<HydraMigration[]> {
return Promise.resolve([Hydra2_0_3, RepackUris]);
}
getMigrationName(migration: HydraMigration): string {
return migration.name;
}
getMigration(migration: HydraMigration): Promise<Knex.Migration> {
return Promise.resolve(migration);
}
}
export const knexClient = knex({
client: "better-sqlite3",
connection: {
filename: databasePath,
},
});
export const migrationConfig: Knex.MigratorConfig = {
migrationSource: new MigrationSource(),
};

10
src/main/knexfile.ts Normal file
View File

@ -0,0 +1,10 @@
const config = {
development: {
migrations: {
extension: "ts",
stub: "migrations/migration.stub",
},
},
};
export default config;

View File

@ -22,8 +22,9 @@ const loadState = async (userPreferences: UserPreferences | null) => {
import("./events"); import("./events");
if (userPreferences?.realDebridApiToken) if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(userPreferences?.realDebridApiToken); RealDebridClient.authorize(userPreferences?.realDebridApiToken);
}
HydraApi.setupApi().then(() => { HydraApi.setupApi().then(() => {
uploadGamesBatch(); uploadGamesBatch();

View File

@ -1,11 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class FixRepackUploadDate1715900413313 implements MigrationInterface {
public async up(_: QueryRunner): Promise<void> {
return;
}
public async down(_: QueryRunner): Promise<void> {
return;
}
}

View File

@ -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<void> {
// 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<void> {
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]
);
}
}
}

View File

@ -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");
},
};

View File

@ -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");
},
};

View File

@ -1,7 +0,0 @@
import { FixRepackUploadDate1715900413313 } from "./1715900413313-fix_repack_uploadDate";
import { AlterLastTimePlayedToDatime1716776027208 } from "./1716776027208-alter_lastTimePlayed_to_datime";
export default [
FixRepackUploadDate1715900413313,
AlterLastTimePlayedToDatime1716776027208,
];

View File

@ -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) => {},
};

View File

@ -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 }
);
};

View File

@ -6,6 +6,8 @@ import { downloadQueueRepository, gameRepository } from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications"; import { publishDownloadCompleteNotification } from "../notifications";
import { RealDebridDownloader } from "./real-debrid-downloader"; import { RealDebridDownloader } from "./real-debrid-downloader";
import type { DownloadProgress } from "@types"; import type { DownloadProgress } from "@types";
import { GofileApi, QiwiApi } from "../hosters";
import { GenericHttpDownloader } from "./generic-http-downloader";
export class DownloadManager { export class DownloadManager {
private static currentDownloader: Downloader | null = null; private static currentDownloader: Downloader | null = null;
@ -13,10 +15,12 @@ export class DownloadManager {
public static async watchDownloads() { public static async watchDownloads() {
let status: DownloadProgress | null = null; 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(); status = await RealDebridDownloader.getStatus();
} else { } else {
status = await PythonInstance.getStatus(); status = await GenericHttpDownloader.getStatus();
} }
if (status) { if (status) {
@ -62,10 +66,12 @@ export class DownloadManager {
} }
static async pauseDownload() { 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(); await RealDebridDownloader.pauseDownload();
} else { } else {
await PythonInstance.pauseDownload(); await GenericHttpDownloader.pauseDownload();
} }
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@ -73,20 +79,16 @@ export class DownloadManager {
} }
static async resumeDownload(game: Game) { static async resumeDownload(game: Game) {
if (game.downloader === Downloader.RealDebrid) { return this.startDownload(game);
RealDebridDownloader.startDownload(game);
this.currentDownloader = Downloader.RealDebrid;
} else {
PythonInstance.startDownload(game);
this.currentDownloader = Downloader.Torrent;
}
} }
static async cancelDownload(gameId: number) { 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); RealDebridDownloader.cancelDownload(gameId);
} else { } else {
PythonInstance.cancelDownload(gameId); GenericHttpDownloader.cancelDownload(gameId);
} }
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@ -94,12 +96,40 @@ export class DownloadManager {
} }
static async startDownload(game: Game) { static async startDownload(game: Game) {
if (game.downloader === Downloader.RealDebrid) { switch (game.downloader) {
RealDebridDownloader.startDownload(game); case Downloader.Gofile: {
this.currentDownloader = Downloader.RealDebrid; const id = game!.uri!.split("/").pop();
} else {
PythonInstance.startDownload(game); const token = await GofileApi.authorize();
this.currentDownloader = Downloader.Torrent; 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;
} }
} }

View File

@ -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<number, HttpDownload>();
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<string, string>
) {
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();
}
}
}

View File

@ -1,68 +1,54 @@
import type { ChildProcess } from "node:child_process"; import { WindowManager } from "../window-manager";
import { logger } from "../logger"; import path from "node:path";
import { sleep } from "@main/helpers";
import { startAria2 } from "../aria2c";
import Aria2 from "aria2";
export class HttpDownload { export class HttpDownload {
private static connected = false; private downloadItem: Electron.DownloadItem;
private static aria2c: ChildProcess | null = null;
private static aria2 = new Aria2({}); constructor(
private downloadPath: string,
private downloadUrl: string,
private headers?: Record<string, string>
) {}
private static async connect() { public getStatus() {
this.aria2c = startAria2(); return {
completedLength: this.downloadItem.getReceivedBytes(),
let retries = 0; totalLength: this.downloadItem.getTotalBytes(),
downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(),
while (retries < 4 && !this.connected) { folderName: this.downloadItem.getFilename(),
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,
}; };
}
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);
}
);
});
} }
} }

View File

@ -19,6 +19,7 @@ import {
LibtorrentPayload, LibtorrentPayload,
ProcessPayload, ProcessPayload,
} from "./types"; } from "./types";
import { pythonInstanceLogger as logger } from "../logger";
export class PythonInstance { export class PythonInstance {
private static pythonProcess: cp.ChildProcess | null = null; private static pythonProcess: cp.ChildProcess | null = null;
@ -32,11 +33,13 @@ export class PythonInstance {
}); });
public static spawn(args?: StartDownloadPayload) { public static spawn(args?: StartDownloadPayload) {
logger.log("spawning python process with args:", args);
this.pythonProcess = startRPCClient(args); this.pythonProcess = startRPCClient(args);
} }
public static kill() { public static kill() {
if (this.pythonProcess) { if (this.pythonProcess) {
logger.log("killing python process");
this.pythonProcess.kill(); this.pythonProcess.kill();
this.pythonProcess = null; this.pythonProcess = null;
this.downloadingGameId = -1; this.downloadingGameId = -1;
@ -45,6 +48,7 @@ export class PythonInstance {
public static killTorrent() { public static killTorrent() {
if (this.pythonProcess) { if (this.pythonProcess) {
logger.log("killing torrent in python process");
this.rpc.post("/action", { action: "kill-torrent" }); this.rpc.post("/action", { action: "kill-torrent" });
this.downloadingGameId = -1; this.downloadingGameId = -1;
} }
@ -138,12 +142,14 @@ export class PythonInstance {
save_path: game.downloadPath!, save_path: game.downloadPath!,
}); });
} else { } else {
await this.rpc.post("/action", { await this.rpc
action: "start", .post("/action", {
game_id: game.id, action: "start",
magnet: game.uri, game_id: game.id,
save_path: game.downloadPath, magnet: game.uri,
} as StartDownloadPayload); save_path: game.downloadPath,
} as StartDownloadPayload)
.catch(this.handleRpcError);
} }
this.downloadingGameId = game.id; this.downloadingGameId = game.id;
@ -159,4 +165,14 @@ export class PythonInstance {
this.downloadingGameId = -1; 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();
});
}
} }

View File

@ -1,162 +1,72 @@
import { Game } from "@main/entity"; import { Game } from "@main/entity";
import { RealDebridClient } from "../real-debrid"; import { RealDebridClient } from "../real-debrid";
import { gameRepository } from "@main/repository";
import { calculateETA } from "./helpers";
import { DownloadProgress } from "@types";
import { HttpDownload } from "./http-download"; import { HttpDownload } from "./http-download";
import { GenericHttpDownloader } from "./generic-http-downloader";
export class RealDebridDownloader { export class RealDebridDownloader extends GenericHttpDownloader {
private static downloads = new Map<number, string>();
private static downloadingGame: Game | null = null;
private static realDebridTorrentId: string | null = null; private static realDebridTorrentId: string | null = null;
private static async getRealDebridDownloadUrl() { private static async getRealDebridDownloadUrl() {
if (this.realDebridTorrentId) { if (this.realDebridTorrentId) {
const torrentInfo = await RealDebridClient.getTorrentInfo( let torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId this.realDebridTorrentId
); );
const { status, links } = torrentInfo; if (torrentInfo.status === "waiting_files_selection") {
if (status === "waiting_files_selection") {
await RealDebridClient.selectAllFiles(this.realDebridTorrentId); await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
return null;
torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
} }
const { links, status } = torrentInfo;
if (status === "downloaded") { if (status === "downloaded") {
const [link] = links; const [link] = links;
const { download } = await RealDebridClient.unrestrictLink(link); const { download } = await RealDebridClient.unrestrictLink(link);
return decodeURIComponent(download); return decodeURIComponent(download);
} }
return null;
} }
return null; if (this.downloadingGame?.uri) {
} const { download } = await RealDebridClient.unrestrictLink(
this.downloadingGame?.uri
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
); );
const { status } = torrentInfo; return decodeURIComponent(download);
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 null; 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) { static async startDownload(game: Game) {
this.downloadingGame = game;
if (this.downloads.has(game.id)) { if (this.downloads.has(game.id)) {
await this.resumeDownload(game.id!); await this.resumeDownload(game.id!);
this.downloadingGame = game;
return; 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(); const downloadUrl = await this.getRealDebridDownloadUrl();
if (downloadUrl) { if (downloadUrl) {
this.realDebridTorrentId = null; this.realDebridTorrentId = null;
const gid = await HttpDownload.startDownload( const httpDownload = new HttpDownload(game.downloadPath!, downloadUrl);
game.downloadPath!, httpDownload.startDownload();
downloadUrl
);
this.downloads.set(game.id!, gid); this.downloads.set(game.id!, httpDownload);
}
}
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);
} }
} }
} }

View File

@ -4,6 +4,8 @@ import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { app, dialog } from "electron"; import { app, dialog } from "electron";
import type { StartDownloadPayload } from "./types"; import type { StartDownloadPayload } from "./types";
import { Readable } from "node:stream";
import { pythonInstanceLogger as logger } from "../logger";
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = { const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: "hydra-download-manager", darwin: "hydra-download-manager",
@ -15,6 +17,13 @@ export const BITTORRENT_PORT = "5881";
export const RPC_PORT = "8084"; export const RPC_PORT = "8084";
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex"); 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) => { export const startTorrentClient = (args?: StartDownloadPayload) => {
const commonArgs = [ const commonArgs = [
BITTORRENT_PORT, BITTORRENT_PORT,
@ -40,10 +49,14 @@ export const startTorrentClient = (args?: StartDownloadPayload) => {
app.quit(); app.quit();
} }
return cp.spawn(binaryPath, commonArgs, { const childProcess = cp.spawn(binaryPath, commonArgs, {
stdio: "inherit",
windowsHide: true, windowsHide: true,
stdio: ["inherit", "inherit"],
}); });
logStderr(childProcess.stderr);
return childProcess;
} else { } else {
const scriptPath = path.join( const scriptPath = path.join(
__dirname, __dirname,
@ -53,8 +66,12 @@ export const startTorrentClient = (args?: StartDownloadPayload) => {
"main.py" "main.py"
); );
return cp.spawn("python3", [scriptPath, ...commonArgs], { const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], {
stdio: "inherit", stdio: ["inherit", "inherit"],
}); });
logStderr(childProcess.stderr);
return childProcess;
} }
}; };

View File

@ -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<string, GofileContentChild>;
}
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");
}
}

View File

@ -0,0 +1,2 @@
export * from "./gofile";
export * from "./qiwi";

View File

@ -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;
}
}

View File

@ -1,8 +1,8 @@
import axios from "axios"; import axios from "axios";
import { JSDOM } from "jsdom";
import { requestWebPage } from "@main/helpers"; import { requestWebPage } from "@main/helpers";
import { HowLongToBeatCategory } from "@types"; import { HowLongToBeatCategory } from "@types";
import { formatName } from "@shared"; import { formatName } from "@shared";
import { logger } from "./logger";
export interface HowLongToBeatResult { export interface HowLongToBeatResult {
game_id: number; game_id: number;
@ -14,22 +14,27 @@ export interface HowLongToBeatSearchResponse {
} }
export const searchHowLongToBeat = async (gameName: string) => { export const searchHowLongToBeat = async (gameName: string) => {
const response = await axios.post( const response = await axios
"https://howlongtobeat.com/api/search", .post(
{ "https://howlongtobeat.com/api/search",
searchType: "games", {
searchTerms: formatName(gameName).split(" "), searchType: "games",
searchPage: 1, searchTerms: formatName(gameName).split(" "),
size: 100, 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/",
}, },
} {
); 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; return response.data as HowLongToBeatSearchResponse;
}; };
@ -52,10 +57,7 @@ const parseListItems = ($lis: Element[]) => {
export const getHowLongToBeatGame = async ( export const getHowLongToBeatGame = async (
id: string id: string
): Promise<HowLongToBeatCategory[]> => { ): Promise<HowLongToBeatCategory[]> => {
const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`); const document = await requestWebPage(`https://howlongtobeat.com/game/${id}`);
const { window } = new JSDOM(response);
const { document } = window;
const $ul = document.querySelector(".shadow_shadow ul"); const $ul = document.querySelector(".shadow_shadow ul");
if (!$ul) return []; if (!$ul) return [];

View File

@ -64,6 +64,14 @@ export class HydraApi {
} }
} }
static handleSignOut() {
this.userAuth = {
authToken: "",
refreshToken: "",
expirationTimestamp: 0,
};
}
static async setupApi() { static async setupApi() {
this.instance = axios.create({ this.instance = axios.create({
baseURL: import.meta.env.MAIN_VITE_API_URL, baseURL: import.meta.env.MAIN_VITE_API_URL,
@ -72,7 +80,7 @@ export class HydraApi {
this.instance.interceptors.request.use( this.instance.interceptors.request.use(
(request) => { (request) => {
logger.log(" ---- REQUEST -----"); logger.log(" ---- REQUEST -----");
logger.log(request.method, request.url, request.data); logger.log(request.method, request.url, request.params, request.data);
return request; return request;
}, },
(error) => { (error) => {
@ -196,52 +204,52 @@ export class HydraApi {
throw err; throw err;
}; };
static async get(url: string) { static async get<T = any>(url: string, params?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError(); if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.get(url, this.getAxiosConfig()) .get<T>(url, { params, ...this.getAxiosConfig() })
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
static async post(url: string, data?: any) { static async post<T = any>(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError(); if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.post(url, data, this.getAxiosConfig()) .post<T>(url, data, this.getAxiosConfig())
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
static async put(url: string, data?: any) { static async put<T = any>(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError(); if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.put(url, data, this.getAxiosConfig()) .put<T>(url, data, this.getAxiosConfig())
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
static async patch(url: string, data?: any) { static async patch<T = any>(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError(); if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.patch(url, data, this.getAxiosConfig()) .patch<T>(url, data, this.getAxiosConfig())
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
static async delete(url: string) { static async delete<T = any>(url: string) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError(); if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.delete(url, this.getAxiosConfig()) .delete<T>(url, this.getAxiosConfig())
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }

View File

@ -3,7 +3,7 @@ import { HydraApi } from "../hydra-api";
import { gameRepository } from "@main/repository"; import { gameRepository } from "@main/repository";
export const createGame = async (game: Game) => { export const createGame = async (game: Game) => {
HydraApi.post(`/games`, { HydraApi.post(`/profile/games`, {
objectId: game.objectID, objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop, shop: game.shop,

View File

@ -4,7 +4,7 @@ import { steamGamesWorker } from "@main/workers";
import { getSteamAppAsset } from "@main/helpers"; import { getSteamAppAsset } from "@main/helpers";
export const mergeWithRemoteGames = async () => { export const mergeWithRemoteGames = async () => {
return HydraApi.get("/games") return HydraApi.get("/profile/games")
.then(async (response) => { .then(async (response) => {
for (const game of response) { for (const game of response) {
const localGame = await gameRepository.findOne({ const localGame = await gameRepository.findOne({

View File

@ -6,7 +6,7 @@ export const updateGamePlaytime = async (
deltaInMillis: number, deltaInMillis: number,
lastTimePlayed: Date lastTimePlayed: Date
) => { ) => {
HydraApi.put(`/games/${game.remoteId}`, { HydraApi.put(`/profile/games/${game.remoteId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000), playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed, lastTimePlayed,
}).catch(() => {}); }).catch(() => {});

View File

@ -14,7 +14,7 @@ export const uploadGamesBatch = async () => {
for (const chunk of gamesChunks) { for (const chunk of gamesChunks) {
await HydraApi.post( await HydraApi.post(
"/games/batch", "/profile/games/batch",
chunk.map((game) => { chunk.map((game) => {
return { return {
objectId: game.objectID, objectId: game.objectID,

View File

@ -6,6 +6,10 @@ log.transports.file.resolvePathFn = (
_: log.PathVariables, _: log.PathVariables,
message?: log.LogMessage | undefined message?: log.LogMessage | undefined
) => { ) => {
if (message?.scope === "python-instance") {
return path.join(logsPath, "pythoninstance.txt");
}
if (message?.level === "error") { if (message?.level === "error") {
return path.join(logsPath, "error.txt"); return path.join(logsPath, "error.txt");
} }
@ -23,4 +27,5 @@ log.errorHandler.startCatching({
log.initialize(); log.initialize();
export const pythonInstanceLogger = log.scope("python-instance");
export const logger = log.scope("main"); export const logger = log.scope("main");

View File

@ -10,6 +10,6 @@ export const startMainLoop = async () => {
DownloadManager.watchDownloads(), DownloadManager.watchDownloads(),
]); ]);
await sleep(500); await sleep(1000);
} }
}; };

View File

@ -4,12 +4,16 @@ import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync"; import { createGame, updateGamePlaytime } from "./library-sync";
import { GameRunning } from "@types"; import { GameRunning } from "@types";
import { PythonInstance } from "./download"; import { PythonInstance } from "./download";
import { Game } from "@main/entity";
export const gamesPlaytime = new Map< export const gamesPlaytime = new Map<
number, number,
{ lastTick: number; firstTick: number } { lastTick: number; firstTick: number; lastSyncTick: number }
>(); >();
const TICKS_TO_UPDATE_API = 120;
let currentTick = 1;
export const watchProcesses = async () => { export const watchProcesses = async () => {
const games = await gameRepository.find({ const games = await gameRepository.find({
where: { where: {
@ -30,48 +34,17 @@ export const watchProcesses = async () => {
if (gameProcess) { if (gameProcess) {
if (gamesPlaytime.has(game.id)) { if (gamesPlaytime.has(game.id)) {
const gamePlaytime = gamesPlaytime.get(game.id)!; onTickGame(game);
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(),
});
} else { } else {
if (game.remoteId) { onOpenGame(game);
updateGamePlaytime(game, 0, new Date());
} else {
createGame({ ...game, lastTimePlayed: new Date() });
}
gamesPlaytime.set(game.id, {
lastTick: performance.now(),
firstTick: performance.now(),
});
} }
} else if (gamesPlaytime.has(game.id)) { } else if (gamesPlaytime.has(game.id)) {
const gamePlaytime = gamesPlaytime.get(game.id)!; onCloseGame(game);
gamesPlaytime.delete(game.id);
if (game.remoteId) {
updateGamePlaytime(
game,
performance.now() - gamePlaytime.firstTick,
game.lastTimePlayed!
);
} else {
createGame(game);
}
} }
} }
currentTick++;
if (WindowManager.mainWindow) { if (WindowManager.mainWindow) {
const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => { const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => {
return { 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);
}
};

View File

@ -46,7 +46,7 @@ export class RealDebridClient {
static async selectAllFiles(id: string) { static async selectAllFiles(id: string) {
const searchParams = new URLSearchParams({ files: "all" }); const searchParams = new URLSearchParams({ files: "all" });
await this.instance.post( return this.instance.post(
`/torrents/selectFiles/${id}`, `/torrents/selectFiles/${id}`,
searchParams.toString() searchParams.toString()
); );

View File

@ -8,11 +8,25 @@ export class RepacksManager {
private static repacksIndex = new flexSearch.Index(); private static repacksIndex = new flexSearch.Index();
public static async updateRepacks() { public static async updateRepacks() {
this.repacks = await repackRepository.find({ this.repacks = await repackRepository
order: { .find({
createdAt: "DESC", 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++) { for (let i = 0; i < this.repacks.length; i++) {
this.repacksIndex.remove(i); this.repacksIndex.remove(i);

View File

@ -158,7 +158,7 @@ export class WindowManager {
const recentlyPlayedGames: Array<MenuItemConstructorOptions | MenuItem> = const recentlyPlayedGames: Array<MenuItemConstructorOptions | MenuItem> =
games.map(({ title, executablePath }) => ({ games.map(({ title, executablePath }) => ({
label: title, label: title.length > 15 ? `${title.slice(0, 15)}` : title,
type: "normal", type: "normal",
click: async () => { click: async () => {
if (!executablePath) return; if (!executablePath) return;

View File

@ -10,6 +10,7 @@ import type {
StartGameDownloadPayload, StartGameDownloadPayload,
GameRunning, GameRunning,
FriendRequestAction, FriendRequestAction,
UpdateProfileProps,
} from "@types"; } from "@types";
contextBridge.exposeInMainWorld("electron", { contextBridge.exposeInMainWorld("electron", {
@ -135,8 +136,10 @@ contextBridge.exposeInMainWorld("electron", {
/* Profile */ /* Profile */
getMe: () => ipcRenderer.invoke("getMe"), getMe: () => ipcRenderer.invoke("getMe"),
updateProfile: (displayName: string, newProfileImagePath: string | null) => undoFriendship: (userId: string) =>
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), ipcRenderer.invoke("undoFriendship", userId),
updateProfile: (updateProfile: UpdateProfileProps) =>
ipcRenderer.invoke("updateProfile", updateProfile),
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
updateFriendRequest: (userId: string, action: FriendRequestAction) => updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action), ipcRenderer.invoke("updateFriendRequest", userId, action),
@ -145,6 +148,12 @@ contextBridge.exposeInMainWorld("electron", {
/* User */ /* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), 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 */ /* Auth */
signOut: () => ipcRenderer.invoke("signOut"), signOut: () => ipcRenderer.invoke("signOut"),

View File

@ -6,7 +6,7 @@
<title>Hydra</title> <title>Hydra</title>
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://cdn.discordapp.com https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com https://video.akamai.steamstatic.com;"
/> />
</head> </head>
<body style="background-color: #1c1c1c"> <body style="background-color: #1c1c1c">

View File

@ -26,7 +26,7 @@ globalStyle("html, body, #root, main", {
globalStyle("body", { globalStyle("body", {
overflow: "hidden", overflow: "hidden",
userSelect: "none", userSelect: "none",
fontFamily: "'Fira Mono', monospace", fontFamily: "Noto Sans, sans-serif",
fontSize: vars.size.body, fontSize: vars.size.body,
background: vars.color.background, background: vars.color.background,
color: vars.color.body, color: vars.color.body,

View File

@ -42,11 +42,12 @@ export function App() {
const { const {
isFriendsModalVisible, isFriendsModalVisible,
friendRequetsModalTab, friendRequetsModalTab,
updateFriendRequests, friendModalUserId,
fetchFriendRequests,
hideFriendsModal, hideFriendsModal,
} = useUserDetails(); } = useUserDetails();
const { fetchUserDetails, updateUserDetails, clearUserDetails } = const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails(); useUserDetails();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -104,20 +105,26 @@ export function App() {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
updateFriendRequests(); fetchFriendRequests();
} }
}); });
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, fetchFriendRequests, updateUserDetails, dispatch]);
const onSignIn = useCallback(() => { const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
updateFriendRequests(); fetchFriendRequests();
showSuccessToast(t("successfully_signed_in")); showSuccessToast(t("successfully_signed_in"));
} }
}); });
}, [fetchUserDetails, t, showSuccessToast, updateUserDetails]); }, [
fetchUserDetails,
fetchFriendRequests,
t,
showSuccessToast,
updateUserDetails,
]);
useEffect(() => { useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesRunning) => { const unsubscribe = window.electron.onGamesRunning((gamesRunning) => {
@ -218,11 +225,14 @@ export function App() {
onClose={handleToastClose} onClose={handleToastClose}
/> />
<UserFriendModal {userDetails && (
visible={isFriendsModalVisible} <UserFriendModal
initialTab={friendRequetsModalTab} visible={isFriendsModalVisible}
onClose={hideFriendsModal} initialTab={friendRequetsModalTab}
/> onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<main> <main>
<Sidebar /> <Sidebar />

View File

@ -104,6 +104,7 @@ export const section = style({
alignItems: "center", alignItems: "center",
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
height: "100%", height: "100%",
overflow: "hidden",
}); });
export const backButton = recipe({ export const backButton = recipe({
@ -136,11 +137,15 @@ export const backButton = recipe({
export const title = recipe({ export const title = recipe({
base: { base: {
transition: "all ease 0.2s", transition: "all ease 0.2s",
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
}, },
variants: { variants: {
hasBackButton: { hasBackButton: {
true: { true: {
transform: "translateX(28px)", transform: "translateX(28px)",
width: "calc(100% - 28px)",
}, },
}, },
}, },

View File

@ -72,7 +72,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
isWindows: window.electron.platform === "win32", isWindows: window.electron.platform === "win32",
})} })}
> >
<section className={styles.section}> <section className={styles.section} style={{ flex: 1 }}>
<button <button
type="button" type="button"
className={styles.backButton({ className={styles.backButton({

View File

@ -45,7 +45,6 @@ export const description = style({
maxWidth: "700px", maxWidth: "700px",
color: vars.color.muted, color: vars.color.muted,
textAlign: "left", textAlign: "left",
fontFamily: "'Fira Sans', sans-serif",
lineHeight: "20px", lineHeight: "20px",
marginTop: `${SPACING_UNIT * 2}px`, marginTop: `${SPACING_UNIT * 2}px`,
}); });

View File

@ -24,6 +24,7 @@ export const modal = recipe({
animation: `${scaleFadeIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`, animation: `${scaleFadeIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
backgroundColor: vars.color.background, backgroundColor: vars.color.background,
borderRadius: "4px", borderRadius: "4px",
minWidth: "400px",
maxWidth: "600px", maxWidth: "600px",
color: vars.color.body, color: vars.color.body,
maxHeight: "100%", maxHeight: "100%",

View File

@ -7,22 +7,24 @@ export const profileContainerBackground = createVar();
export const profileContainer = style({ export const profileContainer = style({
background: profileContainerBackground, background: profileContainerBackground,
position: "relative", position: "relative",
display: "flex",
gap: `${SPACING_UNIT}px`,
cursor: "pointer", cursor: "pointer",
":hover": { ":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)", backgroundColor: "rgba(255, 255, 255, 0.15)",
}, },
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
}); });
export const profileButton = style({ export const profileButton = style({
display: "flex", display: "flex",
cursor: "pointer", cursor: "pointer",
transition: "all ease 0.1s", transition: "all ease 0.1s",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
color: vars.color.muted, color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
width: "100%", width: "100%",
zIndex: "10", overflow: "hidden",
}); });
export const profileButtonContent = style({ export const profileButtonContent = style({
@ -75,16 +77,6 @@ export const profileButtonTitle = style({
whiteSpace: "nowrap", whiteSpace: "nowrap",
}); });
export const friendRequestContainer = style({
position: "absolute",
padding: "8px",
right: `${SPACING_UNIT}px`,
display: "flex",
top: 0,
bottom: 0,
alignItems: "center",
});
export const friendRequestButton = style({ export const friendRequestButton = style({
color: vars.color.success, color: vars.color.success,
cursor: "pointer", cursor: "pointer",

View File

@ -3,10 +3,11 @@ import { PersonAddIcon, PersonIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css"; import * as styles from "./sidebar-profile.css";
import { assignInlineVars } from "@vanilla-extract/dynamic"; import { assignInlineVars } from "@vanilla-extract/dynamic";
import { useAppSelector, useUserDetails } from "@renderer/hooks"; import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { profileContainerBackground } from "./sidebar-profile.css"; import { profileContainerBackground } from "./sidebar-profile.css";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { FriendRequest } from "@types";
export function SidebarProfile() { export function SidebarProfile() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -16,6 +17,14 @@ export function SidebarProfile() {
const { userDetails, profileBackground, friendRequests, showFriendsModal } = const { userDetails, profileBackground, friendRequests, showFriendsModal } =
useUserDetails(); useUserDetails();
const [receivedRequests, setReceivedRequests] = useState<FriendRequest[]>([]);
useEffect(() => {
setReceivedRequests(
friendRequests.filter((request) => request.type === "RECEIVED")
);
}, [friendRequests]);
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
const handleButtonClick = () => { const handleButtonClick = () => {
@ -32,6 +41,9 @@ export function SidebarProfile() {
return undefined; return undefined;
}, [profileBackground]); }, [profileBackground]);
const showPendingRequests =
userDetails && receivedRequests.length > 0 && !gameRunning;
return ( return (
<div <div
className={styles.profileContainer} className={styles.profileContainer}
@ -69,7 +81,7 @@ export function SidebarProfile() {
)} )}
</div> </div>
{userDetails && gameRunning && ( {userDetails && gameRunning?.iconUrl && (
<img <img
alt={gameRunning.title} alt={gameRunning.title}
width={24} width={24}
@ -79,17 +91,17 @@ export function SidebarProfile() {
)} )}
</div> </div>
</button> </button>
{userDetails && friendRequests.length > 0 && !gameRunning && ( {showPendingRequests && (
<div className={styles.friendRequestContainer}> <button
<button type="button"
type="button" className={styles.friendRequestButton}
className={styles.friendRequestButton} onClick={() =>
onClick={() => showFriendsModal(UserFriendModalTab.AddFriend)} showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
> }
<PersonAddIcon size={24} /> >
{friendRequests.length} <PersonAddIcon size={24} />
</button> {receivedRequests.length}
</div> </button>
)} )}
</div> </div>
); );

View File

@ -5,4 +5,7 @@ export const VERSION_CODENAME = "Leviticus";
export const DOWNLOADER_NAME = { export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid", [Downloader.RealDebrid]: "Real-Debrid",
[Downloader.Torrent]: "Torrent", [Downloader.Torrent]: "Torrent",
[Downloader.Gofile]: "Gofile",
[Downloader.PixelDrain]: "PixelDrain",
[Downloader.Qiwi]: "Qiwi",
}; };

View File

@ -16,6 +16,8 @@ import type {
UserProfile, UserProfile,
FriendRequest, FriendRequest,
FriendRequestAction, FriendRequestAction,
UserFriends,
UserBlocks,
} from "@types"; } from "@types";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
@ -127,13 +129,19 @@ declare global {
/* User */ /* User */
getUser: (userId: string) => Promise<UserProfile | null>; getUser: (userId: string) => Promise<UserProfile | null>;
blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>;
getUserFriends: (
userId: string,
take: number,
skip: number
) => Promise<UserFriends>;
getUserBlocks: (take: number, skip: number) => Promise<UserBlocks>;
/* Profile */ /* Profile */
getMe: () => Promise<UserProfile | null>; getMe: () => Promise<UserProfile | null>;
updateProfile: ( undoFriendship: (userId: string) => Promise<void>;
displayName: string, updateProfile: (updateProfile: UpdateProfileProps) => Promise<UserProfile>;
newProfileImagePath: string | null
) => Promise<UserProfile>;
getFriendRequests: () => Promise<FriendRequest[]>; getFriendRequests: () => Promise<FriendRequest[]>;
updateFriendRequest: ( updateFriendRequest: (
userId: string, userId: string,

View File

@ -8,6 +8,7 @@ export interface UserDetailsState {
friendRequests: FriendRequest[]; friendRequests: FriendRequest[];
isFriendsModalVisible: boolean; isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null; friendRequetsModalTab: UserFriendModalTab | null;
friendModalUserId: string;
} }
const initialState: UserDetailsState = { const initialState: UserDetailsState = {
@ -16,6 +17,7 @@ const initialState: UserDetailsState = {
friendRequests: [], friendRequests: [],
isFriendsModalVisible: false, isFriendsModalVisible: false,
friendRequetsModalTab: null, friendRequetsModalTab: null,
friendModalUserId: "",
}; };
export const userDetailsSlice = createSlice({ export const userDetailsSlice = createSlice({
@ -33,10 +35,11 @@ export const userDetailsSlice = createSlice({
}, },
setFriendsModalVisible: ( setFriendsModalVisible: (
state, state,
action: PayloadAction<UserFriendModalTab> action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }>
) => { ) => {
state.isFriendsModalVisible = true; state.isFriendsModalVisible = true;
state.friendRequetsModalTab = action.payload; state.friendRequetsModalTab = action.payload.initialTab;
state.friendModalUserId = action.payload.userId;
}, },
setFriendsModalHidden: (state) => { setFriendsModalHidden: (state) => {
state.isFriendsModalVisible = false; state.isFriendsModalVisible = false;

View File

@ -1,6 +1,7 @@
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import Color from "color"; import Color from "color";
import { average } from "color.js";
export const steamUrlBuilder = { export const steamUrlBuilder = {
library: (objectID: string) => library: (objectID: string) =>
@ -45,3 +46,14 @@ export const buildGameDetailsPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) => export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString(); 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)})`;
};

View File

@ -22,9 +22,10 @@ export function useDownload() {
); );
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const startDownload = (payload: StartGameDownloadPayload) => { const startDownload = async (payload: StartGameDownloadPayload) => {
dispatch(clearDownload()); dispatch(clearDownload());
window.electron.startGameDownload(payload).then((game) => {
return window.electron.startGameDownload(payload).then((game) => {
updateLibrary(); updateLibrary();
return game; return game;

View File

@ -1,6 +1,4 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { average } from "color.js";
import { useAppDispatch, useAppSelector } from "./redux"; import { useAppDispatch, useAppSelector } from "./redux";
import { import {
setProfileBackground, setProfileBackground,
@ -9,9 +7,10 @@ import {
setFriendsModalVisible, setFriendsModalVisible,
setFriendsModalHidden, setFriendsModalHidden,
} from "@renderer/features"; } from "@renderer/features";
import { darkenColor } from "@renderer/helpers"; import { profileBackgroundFromProfileImage } from "@renderer/helpers";
import { FriendRequestAction, UserDetails } from "@types"; import { FriendRequestAction, UpdateProfileProps, UserDetails } from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { logger } from "@renderer/logger";
export function useUserDetails() { export function useUserDetails() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -21,6 +20,7 @@ export function useUserDetails() {
profileBackground, profileBackground,
friendRequests, friendRequests,
isFriendsModalVisible, isFriendsModalVisible,
friendModalUserId,
friendRequetsModalTab, friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails); } = useAppSelector((state) => state.userDetails);
@ -42,12 +42,12 @@ export function useUserDetails() {
dispatch(setUserDetails(userDetails)); dispatch(setUserDetails(userDetails));
if (userDetails.profileImageUrl) { if (userDetails.profileImageUrl) {
const output = await average(userDetails.profileImageUrl, { const profileBackground = await profileBackgroundFromProfileImage(
amount: 1, userDetails.profileImageUrl
format: "hex", ).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)); dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem( window.localStorage.setItem(
@ -78,28 +78,28 @@ export function useUserDetails() {
}, [clearUserDetails]); }, [clearUserDetails]);
const patchUser = useCallback( const patchUser = useCallback(
async (displayName: string, imageProfileUrl: string | null) => { async (props: UpdateProfileProps) => {
const response = await window.electron.updateProfile( const response = await window.electron.updateProfile(props);
displayName,
imageProfileUrl
);
return updateUserDetails(response); return updateUserDetails(response);
}, },
[updateUserDetails] [updateUserDetails]
); );
const updateFriendRequests = useCallback(async () => { const fetchFriendRequests = useCallback(() => {
const friendRequests = await window.electron.getFriendRequests(); return window.electron
dispatch(setFriendRequests(friendRequests)); .getFriendRequests()
.then((friendRequests) => {
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch]); }, [dispatch]);
const showFriendsModal = useCallback( const showFriendsModal = useCallback(
(tab: UserFriendModalTab) => { (initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible(tab)); dispatch(setFriendsModalVisible({ initialTab, userId }));
updateFriendRequests(); fetchFriendRequests();
}, },
[dispatch] [dispatch, fetchFriendRequests]
); );
const hideFriendsModal = useCallback(() => { const hideFriendsModal = useCallback(() => {
@ -110,26 +110,39 @@ export function useUserDetails() {
async (userId: string) => { async (userId: string) => {
return window.electron return window.electron
.sendFriendRequest(userId) .sendFriendRequest(userId)
.then(() => updateFriendRequests()); .then(() => fetchFriendRequests());
}, },
[updateFriendRequests] [fetchFriendRequests]
); );
const updateFriendRequestState = useCallback( const updateFriendRequestState = useCallback(
async (userId: string, action: FriendRequestAction) => { async (userId: string, action: FriendRequestAction) => {
return window.electron return window.electron
.updateFriendRequest(userId, action) .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 { return {
userDetails, userDetails,
profileBackground, profileBackground,
friendRequests, friendRequests,
friendRequetsModalTab, friendRequetsModalTab,
isFriendsModalVisible, isFriendsModalVisible,
friendModalUserId,
showFriendsModal, showFriendsModal,
hideFriendsModal, hideFriendsModal,
fetchUserDetails, fetchUserDetails,
@ -138,7 +151,10 @@ export function useUserDetails() {
updateUserDetails, updateUserDetails,
patchUser, patchUser,
sendFriendRequest, sendFriendRequest,
updateFriendRequests, fetchFriendRequests,
updateFriendRequestState, updateFriendRequestState,
blockUser,
unblockUser,
undoFriendship,
}; };
} }

View File

@ -8,12 +8,10 @@ import { HashRouter, Route, Routes } from "react-router-dom";
import * as Sentry from "@sentry/electron/renderer"; import * as Sentry from "@sentry/electron/renderer";
import "@fontsource/fira-mono/400.css"; import "@fontsource/noto-sans/400.css";
import "@fontsource/fira-mono/500.css"; import "@fontsource/noto-sans/500.css";
import "@fontsource/fira-mono/700.css"; import "@fontsource/noto-sans/700.css";
import "@fontsource/fira-sans/400.css";
import "@fontsource/fira-sans/500.css";
import "@fontsource/fira-sans/700.css";
import "react-loading-skeleton/dist/skeleton.css"; import "react-loading-skeleton/dist/skeleton.css";
import { App } from "./app"; import { App } from "./app";
@ -28,7 +26,7 @@ import {
import { store } from "./store"; import { store } from "./store";
import * as resources from "@locales"; import resources from "@locales";
import { User } from "./pages/user/user"; import { User } from "./pages/user/user";
Sentry.init({}); Sentry.init({});

View File

@ -132,9 +132,7 @@ export function Downloads() {
<ArrowDownIcon size={24} /> <ArrowDownIcon size={24} />
</div> </div>
<h2>{t("no_downloads_title")}</h2> <h2>{t("no_downloads_title")}</h2>
<p style={{ fontFamily: "Fira Sans" }}> <p>{t("no_downloads_description")}</p>
{t("no_downloads_description")}
</p>
</div> </div>
)} )}
</> </>

Some files were not shown because too many files have changed in this diff Show More