mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-15 04:32:13 +00:00
Merge branch 'main' into feat/fr-README
This commit is contained in:
commit
aa21f7815d
163 changed files with 3703 additions and 2872 deletions
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
|
@ -44,6 +44,7 @@ jobs:
|
||||||
name: Build-${{ matrix.os }}
|
name: Build-${{ matrix.os }}
|
||||||
path: |
|
path: |
|
||||||
dist/win-unpacked/**
|
dist/win-unpacked/**
|
||||||
|
dist/*-portable.exe
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/*.dmg
|
dist/*.dmg
|
||||||
dist/*.deb
|
dist/*.deb
|
||||||
|
|
BIN
build/icon.icns
BIN
build/icon.icns
Binary file not shown.
|
@ -5,7 +5,6 @@ directories:
|
||||||
extraResources:
|
extraResources:
|
||||||
- aria2
|
- aria2
|
||||||
- seeds
|
- seeds
|
||||||
- hydra.db
|
|
||||||
- fastlist.exe
|
- fastlist.exe
|
||||||
files:
|
files:
|
||||||
- "!**/.vscode/*"
|
- "!**/.vscode/*"
|
||||||
|
@ -19,12 +18,19 @@ asarUnpack:
|
||||||
win:
|
win:
|
||||||
executableName: Hydra
|
executableName: Hydra
|
||||||
requestedExecutionLevel: requireAdministrator
|
requestedExecutionLevel: requireAdministrator
|
||||||
|
target:
|
||||||
|
- nsis
|
||||||
|
- portable
|
||||||
nsis:
|
nsis:
|
||||||
artifactName: ${name}-${version}-setup.${ext}
|
artifactName: ${name}-${version}-setup.${ext}
|
||||||
shortcutName: ${productName}
|
shortcutName: ${productName}
|
||||||
uninstallDisplayName: ${productName}
|
uninstallDisplayName: ${productName}
|
||||||
createDesktopShortcut: always
|
createDesktopShortcut: always
|
||||||
oneClick: false
|
oneClick: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
portable:
|
||||||
|
artifactName: ${name}-${version}-portable.${ext}
|
||||||
|
requestExecutionLevel: admin
|
||||||
mac:
|
mac:
|
||||||
entitlementsInherit: build/entitlements.mac.plist
|
entitlementsInherit: build/entitlements.mac.plist
|
||||||
extendInfo:
|
extendInfo:
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
|
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
|
||||||
import svgr from "vite-plugin-svgr";
|
import svgr from "vite-plugin-svgr";
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
loadEnv(mode);
|
loadEnv(mode);
|
||||||
|
|
||||||
|
|
BIN
hydra.db
BIN
hydra.db
Binary file not shown.
11
package.json
11
package.json
|
@ -40,7 +40,6 @@
|
||||||
"@reduxjs/toolkit": "^2.2.3",
|
"@reduxjs/toolkit": "^2.2.3",
|
||||||
"@vanilla-extract/css": "^1.14.2",
|
"@vanilla-extract/css": "^1.14.2",
|
||||||
"@vanilla-extract/recipes": "^0.5.2",
|
"@vanilla-extract/recipes": "^0.5.2",
|
||||||
"iso-639-1": "3.1.2",
|
|
||||||
"aria2": "^4.1.2",
|
"aria2": "^4.1.2",
|
||||||
"auto-launch": "^5.0.6",
|
"auto-launch": "^5.0.6",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
|
@ -49,6 +48,7 @@
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
"color.js": "^1.2.0",
|
"color.js": "^1.2.0",
|
||||||
|
"create-desktop-shortcuts": "^1.11.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"electron-log": "^5.1.4",
|
"electron-log": "^5.1.4",
|
||||||
"electron-updater": "^6.1.8",
|
"electron-updater": "^6.1.8",
|
||||||
|
@ -56,10 +56,13 @@
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"i18next": "^23.11.2",
|
"i18next": "^23.11.2",
|
||||||
"i18next-browser-languagedetector": "^7.2.1",
|
"i18next-browser-languagedetector": "^7.2.1",
|
||||||
|
"icojs": "^0.19.3",
|
||||||
|
"iso-639-1": "3.1.2",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.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",
|
||||||
|
"piscina": "^4.5.1",
|
||||||
"ps-list": "^8.1.1",
|
"ps-list": "^8.1.1",
|
||||||
"react-i18next": "^14.1.0",
|
"react-i18next": "^14.1.0",
|
||||||
"react-loading-skeleton": "^3.4.0",
|
"react-loading-skeleton": "^3.4.0",
|
||||||
|
@ -67,7 +70,8 @@
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"user-agents": "^1.1.193",
|
"user-agents": "^1.1.193",
|
||||||
"yaml": "^2.4.1"
|
"yaml": "^2.4.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^19.3.0",
|
"@commitlint/cli": "^19.3.0",
|
||||||
|
@ -83,9 +87,10 @@
|
||||||
"@types/parse-torrent": "^5.8.7",
|
"@types/parse-torrent": "^5.8.7",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@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": "^28.2.0",
|
"electron": "^30.0.9",
|
||||||
"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",
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
libtorrent
|
|
||||||
cx_Freeze
|
|
||||||
cx_Logging; sys_platform == 'win32'
|
|
||||||
lief; sys_platform == 'win32'
|
|
||||||
pywin32; sys_platform == 'win32'
|
|
File diff suppressed because one or more lines are too long
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"home": {
|
"home": {
|
||||||
"featured": "Featured",
|
"featured": "Featured",
|
||||||
"recently_added": "Recently added",
|
|
||||||
"trending": "Trending",
|
"trending": "Trending",
|
||||||
"surprise_me": "Surprise me",
|
"surprise_me": "Surprise me",
|
||||||
"no_results": "No results found"
|
"no_results": "No results found"
|
||||||
|
@ -15,12 +14,9 @@
|
||||||
"paused": "{{title}} (Paused)",
|
"paused": "{{title}} (Paused)",
|
||||||
"downloading": "{{title}} ({{percentage}} - Downloading…)",
|
"downloading": "{{title}} ({{percentage}} - Downloading…)",
|
||||||
"filter": "Filter library",
|
"filter": "Filter library",
|
||||||
"follow_us": "Follow us",
|
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"discord": "Join our Discord",
|
"queued": "{{title}} (Queued)",
|
||||||
"telegram": "Join our Telegram",
|
"game_has_no_executable": "Game has no executable selected"
|
||||||
"x": "Follow on X",
|
|
||||||
"github": "Contribute on GitHub"
|
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Search games",
|
"search": "Search games",
|
||||||
|
@ -29,7 +25,8 @@
|
||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"search_results": "Search results",
|
"search_results": "Search results",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"version_available": "Version {{version}} available. Click here to restart and install."
|
"version_available_install": "Version {{version}} available. Click here to restart and install.",
|
||||||
|
"version_available_download": "Version {{version}} available. Click here to download."
|
||||||
},
|
},
|
||||||
"bottom_panel": {
|
"bottom_panel": {
|
||||||
"no_downloads_in_progress": "No downloads in progress",
|
"no_downloads_in_progress": "No downloads in progress",
|
||||||
|
@ -104,7 +101,25 @@
|
||||||
"screenshot": "Screenshot {{number}}",
|
"screenshot": "Screenshot {{number}}",
|
||||||
"open_screenshot": "Open screenshot {{number}}",
|
"open_screenshot": "Open screenshot {{number}}",
|
||||||
"download_settings": "Download settings",
|
"download_settings": "Download settings",
|
||||||
"downloader": "Downloader"
|
"downloader": "Downloader",
|
||||||
|
"select_executable": "Select",
|
||||||
|
"no_executable_selected": "No executable selected",
|
||||||
|
"open_folder": "Open folder",
|
||||||
|
"open_download_location": "See downloaded files",
|
||||||
|
"create_shortcut": "Create desktop shortcut",
|
||||||
|
"remove_files": "Remove files",
|
||||||
|
"remove_from_library_title": "Are you sure?",
|
||||||
|
"remove_from_library_description": "This will remove {{game}} from your library",
|
||||||
|
"options": "Options",
|
||||||
|
"executable_section_title": "Executable",
|
||||||
|
"executable_section_description": "Path of the file that will be executed when \"Play\" is clicked",
|
||||||
|
"downloads_secion_title": "Downloads",
|
||||||
|
"downloads_section_description": "Check out updates or other versions of this game",
|
||||||
|
"danger_zone_section_title": "Danger zone",
|
||||||
|
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
|
||||||
|
"download_in_progress": "Download in progress",
|
||||||
|
"download_paused": "Download paused",
|
||||||
|
"last_downloaded_option": "Last downloaded option"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Activate Hydra",
|
"title": "Activate Hydra",
|
||||||
|
@ -134,7 +149,13 @@
|
||||||
"remove_from_list": "Remove",
|
"remove_from_list": "Remove",
|
||||||
"delete_modal_title": "Are you sure?",
|
"delete_modal_title": "Are you sure?",
|
||||||
"delete_modal_description": "This will remove all the installation files from your computer",
|
"delete_modal_description": "This will remove all the installation files from your computer",
|
||||||
"install": "Install"
|
"install": "Install",
|
||||||
|
"download_in_progress": "In progress",
|
||||||
|
"queued_downloads": "Queued downloads",
|
||||||
|
"downloads_completed": "Completed",
|
||||||
|
"queued": "Queued",
|
||||||
|
"no_downloads_title": "Such empty",
|
||||||
|
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Downloads path",
|
"downloads_path": "Downloads path",
|
||||||
|
@ -149,6 +170,7 @@
|
||||||
"launch_with_system": "Launch Hydra on system start-up",
|
"launch_with_system": "Launch Hydra on system start-up",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"behavior": "Behavior",
|
"behavior": "Behavior",
|
||||||
|
"download_sources": "Download sources",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"real_debrid_api_token": "API Token",
|
"real_debrid_api_token": "API Token",
|
||||||
"enable_real_debrid": "Enable Real-Debrid",
|
"enable_real_debrid": "Enable Real-Debrid",
|
||||||
|
@ -158,7 +180,25 @@
|
||||||
"real_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to Real-Debrid",
|
"real_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to Real-Debrid",
|
||||||
"real_debrid_linked_message": "Account \"{{username}}\" linked",
|
"real_debrid_linked_message": "Account \"{{username}}\" linked",
|
||||||
"save_changes": "Save changes",
|
"save_changes": "Save changes",
|
||||||
"changes_saved": "Changes successfully saved"
|
"changes_saved": "Changes successfully saved",
|
||||||
|
"download_sources_description": "Hydra will fetch the download links from these sources. The source URL must be a direct link to a .json file containing the download links.",
|
||||||
|
"validate_download_source": "Validate",
|
||||||
|
"remove_download_source": "Remove",
|
||||||
|
"add_download_source": "Add source",
|
||||||
|
"download_count_zero": "No downloads in list",
|
||||||
|
"download_count_one": "{{countFormatted}} download in list",
|
||||||
|
"download_count_other": "{{countFormatted}} downloads in list",
|
||||||
|
"download_options_zero": "No download available",
|
||||||
|
"download_options_one": "{{countFormatted}} download available",
|
||||||
|
"download_options_other": "{{countFormatted}} downloads available",
|
||||||
|
"download_source_url": "Download source URL",
|
||||||
|
"add_download_source_description": "Insert the URL containing the .json file",
|
||||||
|
"download_source_up_to_date": "Up-to-date",
|
||||||
|
"download_source_errored": "Errored",
|
||||||
|
"sync_download_sources": "Sync sources",
|
||||||
|
"removed_download_source": "Download source removed",
|
||||||
|
"added_download_source": "Added download source",
|
||||||
|
"download_sources_synced": "All download sources are synced"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Download complete",
|
"download_complete": "Download complete",
|
||||||
|
@ -181,5 +221,8 @@
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"close": "Close button"
|
"close": "Close button"
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"toggle_password_visibility": "Toggle password visibility"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"home": {
|
"home": {
|
||||||
"featured": "Destacado",
|
"featured": "Destacado",
|
||||||
"recently_added": "Recién Añadidos",
|
|
||||||
"trending": "Tendencias",
|
"trending": "Tendencias",
|
||||||
"surprise_me": "¡Sorpréndeme!",
|
"surprise_me": "¡Sorpréndeme!",
|
||||||
"no_results": "No se encontraron resultados"
|
"no_results": "No se encontraron resultados"
|
||||||
|
@ -15,12 +14,9 @@
|
||||||
"paused": "{{title}} (Pausado)",
|
"paused": "{{title}} (Pausado)",
|
||||||
"downloading": "{{title}} ({{percentage}} - Descargando…)",
|
"downloading": "{{title}} ({{percentage}} - Descargando…)",
|
||||||
"filter": "Buscar en la biblioteca",
|
"filter": "Buscar en la biblioteca",
|
||||||
"follow_us": "Síguenos",
|
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"discord": "Únete a nuestro Discord",
|
"queued": "{{title}} (En Cola)",
|
||||||
"telegram": "Únete a nuestro Telegram",
|
"game_has_no_executable": "El juego no tiene un ejecutable"
|
||||||
"x": "Síguenos en X",
|
|
||||||
"github": "Contribuye en GitHub"
|
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Buscar juegos",
|
"search": "Buscar juegos",
|
||||||
|
@ -29,7 +25,8 @@
|
||||||
"downloads": "Descargas",
|
"downloads": "Descargas",
|
||||||
"search_results": "Resultados de búsqueda",
|
"search_results": "Resultados de búsqueda",
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"version_available": "Version {{version}} disponible. Haz clic aquí para reiniciar e instalar."
|
"version_available_install": "Version {{version}} disponible. Haz clic aquí para reiniciar e instalar.",
|
||||||
|
"version_available_download": "Version {{version}} disponible. Haz clic aquí para descargar."
|
||||||
},
|
},
|
||||||
"bottom_panel": {
|
"bottom_panel": {
|
||||||
"no_downloads_in_progress": "Sin descargas en progreso",
|
"no_downloads_in_progress": "Sin descargas en progreso",
|
||||||
|
@ -104,7 +101,25 @@
|
||||||
"screenshot": "Captura {{number}}",
|
"screenshot": "Captura {{number}}",
|
||||||
"open_screenshot": "Abrir captura {{number}}",
|
"open_screenshot": "Abrir captura {{number}}",
|
||||||
"download_settings": "Ajustes de descarga",
|
"download_settings": "Ajustes de descarga",
|
||||||
"downloader": "Descargador"
|
"downloader": "Descargador",
|
||||||
|
"select_executable": "Seleccionar",
|
||||||
|
"no_executable_selected": "No se seleccionó un ejecutable",
|
||||||
|
"open_folder": "Abrir carpeta",
|
||||||
|
"open_download_location": "Ver archivos descargados",
|
||||||
|
"create_shortcut": "Crear acceso directo en el escritorio",
|
||||||
|
"remove_files": "Eliminar archivos",
|
||||||
|
"remove_from_library_title": "¿Estás seguro?",
|
||||||
|
"remove_from_library_description": "Esto eliminará {{game}} de tu biblioteca",
|
||||||
|
"options": "Opciones",
|
||||||
|
"executable_section_title": "Ejecutable",
|
||||||
|
"executable_section_description": "Ruta del archivo que se ejecutará cuando se presione \"Jugar\"",
|
||||||
|
"downloads_secion_title": "Descargas",
|
||||||
|
"downloads_section_description": "Buscar actualizaciones u otras versiones de este juego",
|
||||||
|
"danger_zone_section_title": "Zona de Peligro",
|
||||||
|
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra",
|
||||||
|
"download_in_progress": "Descarga en progreso",
|
||||||
|
"download_paused": "Descarga pausada",
|
||||||
|
"last_downloaded_option": "Última opción descargada"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Activar Hydra",
|
"title": "Activar Hydra",
|
||||||
|
@ -134,7 +149,13 @@
|
||||||
"remove_from_list": "Eliminar",
|
"remove_from_list": "Eliminar",
|
||||||
"delete_modal_title": "¿Estás seguro?",
|
"delete_modal_title": "¿Estás seguro?",
|
||||||
"delete_modal_description": "Esto eliminará todos los archivos de instalación de tu computadora.",
|
"delete_modal_description": "Esto eliminará todos los archivos de instalación de tu computadora.",
|
||||||
"install": "Instalar"
|
"install": "Instalar",
|
||||||
|
"download_in_progress": "En progreso",
|
||||||
|
"queued_downloads": "Descargas en cola",
|
||||||
|
"downloads_completed": "Completado",
|
||||||
|
"queued": "En cola",
|
||||||
|
"no_downloads_title": "Esto está tan... vacío",
|
||||||
|
"no_downloads_description": "No has descargado nada con Hydra... aún, ¡pero nunca es tarde para comenzar!."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Ruta de descarga",
|
"downloads_path": "Ruta de descarga",
|
||||||
|
@ -149,6 +170,7 @@
|
||||||
"launch_with_system": "Iniciar Hydra al inicio del sistema",
|
"launch_with_system": "Iniciar Hydra al inicio del sistema",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"behavior": "Otros",
|
"behavior": "Otros",
|
||||||
|
"download_sources": "Fuentes de descarga",
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"real_debrid_api_token": "Token API",
|
"real_debrid_api_token": "Token API",
|
||||||
"enable_real_debrid": "Activar Real-Debrid",
|
"enable_real_debrid": "Activar Real-Debrid",
|
||||||
|
@ -158,7 +180,25 @@
|
||||||
"real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratuita. Por favor, suscríbete a Real-Debrid",
|
"real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratuita. Por favor, suscríbete a Real-Debrid",
|
||||||
"real_debrid_linked_message": "Cuenta \"{{username}}\" vinculada",
|
"real_debrid_linked_message": "Cuenta \"{{username}}\" vinculada",
|
||||||
"save_changes": "Guardar cambios",
|
"save_changes": "Guardar cambios",
|
||||||
"changes_saved": "Ajustes guardados exitosamente"
|
"changes_saved": "Ajustes guardados exitosamente",
|
||||||
|
"download_sources_description": "Hydra buscará los enlaces de descarga de estas fuentes. La URL de origen debe ser un enlace directo a un archivo .json que contenga los enlaces de descarga",
|
||||||
|
"validate_download_source": "Validar",
|
||||||
|
"remove_download_source": "Eliminar",
|
||||||
|
"add_download_source": "Añadir fuente de descarga",
|
||||||
|
"download_count_zero": "No hay descargas en la lista",
|
||||||
|
"download_count_one": "{{countFormatted}} descarga en la lista",
|
||||||
|
"download_count_other": "{{countFormatted}} descargas en la lista",
|
||||||
|
"download_options_zero": "No hay descargas disponibles",
|
||||||
|
"download_options_one": "{{countFormatted}} descarga disponible",
|
||||||
|
"download_options_other": "{{countFormatted}} descargas disponibles",
|
||||||
|
"download_source_url": "Descargar URL de origen",
|
||||||
|
"add_download_source_description": "Introduce la URL con el archivo .json",
|
||||||
|
"download_source_up_to_date": "Al día",
|
||||||
|
"download_source_errored": "Error",
|
||||||
|
"sync_download_sources": "Sincronizar fuentes",
|
||||||
|
"removed_download_source": "Fuente de descarga eliminada",
|
||||||
|
"added_download_source": "Fuente de descarga añadida",
|
||||||
|
"download_sources_synced": "Todas las fuentes de descarga estánn actualizadas"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Descarga completada",
|
"download_complete": "Descarga completada",
|
||||||
|
@ -181,5 +221,8 @@
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"close": "Botón de cierre"
|
"close": "Botón de cierre"
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"toggle_password_visibility": "Cambiar visibilidad de contraseña"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"home": {
|
"home": {
|
||||||
"featured": "Destaque",
|
"featured": "Destaque",
|
||||||
"recently_added": "Recém adicionados",
|
|
||||||
"trending": "Populares",
|
"trending": "Populares",
|
||||||
"surprise_me": "Surpreenda-me",
|
"surprise_me": "Surpreenda-me",
|
||||||
"no_results": "Nenhum resultado encontrado"
|
"no_results": "Nenhum resultado encontrado"
|
||||||
|
@ -17,10 +16,8 @@
|
||||||
"filter": "Filtrar biblioteca",
|
"filter": "Filtrar biblioteca",
|
||||||
"home": "Início",
|
"home": "Início",
|
||||||
"follow_us": "Acompanhe-nos",
|
"follow_us": "Acompanhe-nos",
|
||||||
"discord": "Entre no nosso Discord",
|
"queued": "{{title}} (Na fila)",
|
||||||
"telegram": "Entre no nosso Telegram",
|
"game_has_no_executable": "Jogo não possui executável selecionado"
|
||||||
"x": "Siga-nos no X",
|
|
||||||
"github": "Contribua no GitHub"
|
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Buscar jogos",
|
"search": "Buscar jogos",
|
||||||
|
@ -29,7 +26,8 @@
|
||||||
"search_results": "Resultados da busca",
|
"search_results": "Resultados da busca",
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"home": "Início",
|
"home": "Início",
|
||||||
"version_available": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar."
|
"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": {
|
"bottom_panel": {
|
||||||
"no_downloads_in_progress": "Sem downloads em andamento",
|
"no_downloads_in_progress": "Sem downloads em andamento",
|
||||||
|
@ -100,7 +98,25 @@
|
||||||
"screenshot": "Captura de tela {{number}}",
|
"screenshot": "Captura de tela {{number}}",
|
||||||
"open_screenshot": "Ver captura de tela {{number}}",
|
"open_screenshot": "Ver captura de tela {{number}}",
|
||||||
"download_settings": "Ajustes do download",
|
"download_settings": "Ajustes do download",
|
||||||
"downloader": "Downloader"
|
"downloader": "Downloader",
|
||||||
|
"select_executable": "Selecionar",
|
||||||
|
"no_executable_selected": "Nenhum executável selecionado",
|
||||||
|
"open_folder": "Abrir pasta",
|
||||||
|
"open_download_location": "Ver arquivos baixados",
|
||||||
|
"create_shortcut": "Criar atalho na área de trabalho",
|
||||||
|
"remove_files": "Remover arquivos",
|
||||||
|
"options": "Opções",
|
||||||
|
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
|
||||||
|
"remove_from_library_title": "Tem certeza?",
|
||||||
|
"executable_section_title": "Executável",
|
||||||
|
"executable_section_description": "O caminho do arquivo que será executado ao clicar em \"Jogar\"",
|
||||||
|
"downloads_secion_title": "Downloads",
|
||||||
|
"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 arquivos que foram baixados pelo Hydra",
|
||||||
|
"download_in_progress": "Download em andamento",
|
||||||
|
"download_paused": "Download pausado",
|
||||||
|
"last_downloaded_option": "Última opção baixada"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Ativação",
|
"title": "Ativação",
|
||||||
|
@ -118,7 +134,7 @@
|
||||||
"verifying": "Verificando…",
|
"verifying": "Verificando…",
|
||||||
"completed_at": "Concluído em {{date}}",
|
"completed_at": "Concluído em {{date}}",
|
||||||
"completed": "Concluído",
|
"completed": "Concluído",
|
||||||
"removed": "Não baixado",
|
"removed": "Cancelado",
|
||||||
"download_again": "Baixar novamente",
|
"download_again": "Baixar novamente",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"filter": "Filtrar jogos baixados",
|
"filter": "Filtrar jogos baixados",
|
||||||
|
@ -130,7 +146,13 @@
|
||||||
"delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador",
|
"delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador",
|
||||||
"delete_modal_title": "Tem certeza?",
|
"delete_modal_title": "Tem certeza?",
|
||||||
"deleting": "Excluindo instalador…",
|
"deleting": "Excluindo instalador…",
|
||||||
"install": "Instalar"
|
"install": "Instalar",
|
||||||
|
"download_in_progress": "Baixando agora",
|
||||||
|
"queued_downloads": "Na fila",
|
||||||
|
"downloads_completed": "Completo",
|
||||||
|
"queued": "Na fila",
|
||||||
|
"no_downloads_title": "Nada por aqui…",
|
||||||
|
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Diretório dos downloads",
|
"downloads_path": "Diretório dos downloads",
|
||||||
|
@ -145,6 +167,7 @@
|
||||||
"launch_with_system": "Iniciar o Hydra junto com o sistema",
|
"launch_with_system": "Iniciar o Hydra junto com o sistema",
|
||||||
"general": "Geral",
|
"general": "Geral",
|
||||||
"behavior": "Comportamento",
|
"behavior": "Comportamento",
|
||||||
|
"download_sources": "Fontes de download",
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"real_debrid_api_token": "Token de API",
|
"real_debrid_api_token": "Token de API",
|
||||||
"enable_real_debrid": "Habilitar Real-Debrid",
|
"enable_real_debrid": "Habilitar Real-Debrid",
|
||||||
|
@ -154,7 +177,25 @@
|
||||||
"real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine a Real-Debrid",
|
"real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine a Real-Debrid",
|
||||||
"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.",
|
||||||
|
"validate_download_source": "Validar",
|
||||||
|
"remove_download_source": "Remover",
|
||||||
|
"add_download_source": "Adicionar fonte",
|
||||||
|
"download_count_zero": "Sem downloads na lista",
|
||||||
|
"download_count_one": "{{countFormatted}} download na lista",
|
||||||
|
"download_count_other": "{{countFormatted}} downloads na lista",
|
||||||
|
"download_options_zero": "Sem downloads disponíveis",
|
||||||
|
"download_options_one": "{{countFormatted}} download disponível",
|
||||||
|
"download_options_other": "{{countFormatted}} downloads disponíveis",
|
||||||
|
"download_source_url": "URL da fonte",
|
||||||
|
"add_download_source_description": "Insira a 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"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Download concluído",
|
"download_complete": "Download concluído",
|
||||||
|
@ -181,5 +222,8 @@
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"close": "Botão de fechar"
|
"close": "Botão de fechar"
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"toggle_password_visibility": "Alternar visibilidade da senha"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"home": {
|
"home": {
|
||||||
"featured": "Рекомендованное",
|
"featured": "Рекомендованное",
|
||||||
"recently_added": "Новинки",
|
|
||||||
"trending": "В тренде",
|
"trending": "В тренде",
|
||||||
"surprise_me": "Удиви меня",
|
"surprise_me": "Удиви меня",
|
||||||
"no_results": "Ничего не найдено"
|
"no_results": "Ничего не найдено"
|
||||||
|
@ -15,12 +14,9 @@
|
||||||
"paused": "{{title}} (Приостановлено)",
|
"paused": "{{title}} (Приостановлено)",
|
||||||
"downloading": "{{title}} ({{percentage}} - Загрузка…)",
|
"downloading": "{{title}} ({{percentage}} - Загрузка…)",
|
||||||
"filter": "Фильтр библиотеки",
|
"filter": "Фильтр библиотеки",
|
||||||
"follow_us": "Подписывайтесь на нас",
|
|
||||||
"home": "Главная",
|
"home": "Главная",
|
||||||
"discord": "Присоединяйтесь к Discord",
|
"queued": "{{title}} (В очереди)",
|
||||||
"telegram": "Присоединяйтесь к Telegram",
|
"game_has_no_executable": "Файл запуска игры не выбран"
|
||||||
"x": "Подписывайтесь на X",
|
|
||||||
"github": "Внести свой вклад на GitHub"
|
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Поиск",
|
"search": "Поиск",
|
||||||
|
@ -28,12 +24,15 @@
|
||||||
"catalogue": "Каталог",
|
"catalogue": "Каталог",
|
||||||
"downloads": "Загрузки",
|
"downloads": "Загрузки",
|
||||||
"search_results": "Результаты поиска",
|
"search_results": "Результаты поиска",
|
||||||
"settings": "Настройки"
|
"settings": "Настройки",
|
||||||
|
"version_available_install": "Доступна версия {{version}}. Нажмите здесь для перезапуска и установки.",
|
||||||
|
"version_available_download": "Доступна версия {{version}}. Нажмите здесь для загрузки."
|
||||||
},
|
},
|
||||||
"bottom_panel": {
|
"bottom_panel": {
|
||||||
"no_downloads_in_progress": "Нет активных загрузок",
|
"no_downloads_in_progress": "Нет активных загрузок",
|
||||||
"downloading_metadata": "Загрузка метаданных {{title}}…",
|
"downloading_metadata": "Загрузка метаданных {{title}}…",
|
||||||
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}"
|
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}",
|
||||||
|
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…"
|
||||||
},
|
},
|
||||||
"catalogue": {
|
"catalogue": {
|
||||||
"next_page": "Следующая страница",
|
"next_page": "Следующая страница",
|
||||||
|
@ -53,6 +52,7 @@
|
||||||
"remove_from_list": "Удалить",
|
"remove_from_list": "Удалить",
|
||||||
"space_left_on_disk": "{{space}} свободно на диске",
|
"space_left_on_disk": "{{space}} свободно на диске",
|
||||||
"eta": "Окончание {{eta}}",
|
"eta": "Окончание {{eta}}",
|
||||||
|
"calculating_eta": "Подсчёт оставшегося времени…",
|
||||||
"downloading_metadata": "Загрузка метаданных…",
|
"downloading_metadata": "Загрузка метаданных…",
|
||||||
"filter": "Фильтр репаков",
|
"filter": "Фильтр репаков",
|
||||||
"requirements": "Системные требования",
|
"requirements": "Системные требования",
|
||||||
|
@ -60,6 +60,7 @@
|
||||||
"recommended": "Рекомендуемые",
|
"recommended": "Рекомендуемые",
|
||||||
"no_minimum_requirements": "Для {{title}} не указаны минимальные требования",
|
"no_minimum_requirements": "Для {{title}} не указаны минимальные требования",
|
||||||
"no_recommended_requirements": "Для {{title}} не указаны рекомендуемые требования",
|
"no_recommended_requirements": "Для {{title}} не указаны рекомендуемые требования",
|
||||||
|
"paused": "Приостановлено",
|
||||||
"release_date": "Выпущено {{date}}",
|
"release_date": "Выпущено {{date}}",
|
||||||
"publisher": "Издатель {{publisher}}",
|
"publisher": "Издатель {{publisher}}",
|
||||||
"copy_link_to_clipboard": "Копировать ссылку",
|
"copy_link_to_clipboard": "Копировать ссылку",
|
||||||
|
@ -98,7 +99,27 @@
|
||||||
"previous_screenshot": "Предыдущий скриншот",
|
"previous_screenshot": "Предыдущий скриншот",
|
||||||
"next_screenshot": "Следующий скриншот",
|
"next_screenshot": "Следующий скриншот",
|
||||||
"screenshot": "Скриншот {{number}}",
|
"screenshot": "Скриншот {{number}}",
|
||||||
"open_screenshot": "Открыть скриншот {{number}}"
|
"open_screenshot": "Открыть скриншот {{number}}",
|
||||||
|
"download_settings": "Параметры загрузки",
|
||||||
|
"downloader": "Загрузчик",
|
||||||
|
"select_executable": "Выбрать",
|
||||||
|
"no_executable_selected": "Файл не выбран",
|
||||||
|
"open_folder": "Открыть папку",
|
||||||
|
"open_download_location": "Просмотреть папку загрузок",
|
||||||
|
"create_shortcut": "Создать ярлык на рабочем столе",
|
||||||
|
"remove_files": "Удалить файлы",
|
||||||
|
"remove_from_library_title": "Вы уверены?",
|
||||||
|
"remove_from_library_description": "{{game}} будет удалена из вашей библиотеки.",
|
||||||
|
"options": "Настройки",
|
||||||
|
"executable_section_title": "Файл",
|
||||||
|
"executable_section_description": "Путь к файлу, который будет запущен при нажатии на \"Play\"",
|
||||||
|
"downloads_secion_title": "Загрузки",
|
||||||
|
"downloads_section_description": "Проверить наличие обновлений или других версий игры",
|
||||||
|
"danger_zone_section_title": "Опасная зона",
|
||||||
|
"danger_zone_section_description": "Удалить эту игру из вашей библиотеки или файлы скачанные Hydra",
|
||||||
|
"download_in_progress": "Идёт загрузка",
|
||||||
|
"download_paused": "Загрузка приостановлена",
|
||||||
|
"last_downloaded_option": "Последний вариант загрузки"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Активировать Hydra",
|
"title": "Активировать Hydra",
|
||||||
|
@ -116,8 +137,9 @@
|
||||||
"verifying": "Проверка…",
|
"verifying": "Проверка…",
|
||||||
"completed_at": "Завершено в {{date}}",
|
"completed_at": "Завершено в {{date}}",
|
||||||
"completed": "Завершено",
|
"completed": "Завершено",
|
||||||
|
"removed": "Не скачано",
|
||||||
"download_again": "Загрузить снова",
|
"download_again": "Загрузить снова",
|
||||||
"cancel": "Отменить",
|
"cancel": "Отмена",
|
||||||
"filter": "Фильтр загруженных игр",
|
"filter": "Фильтр загруженных игр",
|
||||||
"remove": "Удалить",
|
"remove": "Удалить",
|
||||||
"downloading_metadata": "Загрузка метаданных…",
|
"downloading_metadata": "Загрузка метаданных…",
|
||||||
|
@ -127,7 +149,13 @@
|
||||||
"remove_from_list": "Удалить",
|
"remove_from_list": "Удалить",
|
||||||
"delete_modal_title": "Вы уверены?",
|
"delete_modal_title": "Вы уверены?",
|
||||||
"delete_modal_description": "Это удалит все установщики с вашего компьютера",
|
"delete_modal_description": "Это удалит все установщики с вашего компьютера",
|
||||||
"install": "Установить"
|
"install": "Установить",
|
||||||
|
"download_in_progress": "В процессе",
|
||||||
|
"queued_downloads": "Загрузки в очереди",
|
||||||
|
"downloads_completed": "Завершено",
|
||||||
|
"queued": "В очереди",
|
||||||
|
"no_downloads_title": "Здесь так пусто...",
|
||||||
|
"no_downloads_description": "Вы ещё ничего не скачали через Hydra, но никогда не поздно начать."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Путь загрузок",
|
"downloads_path": "Путь загрузок",
|
||||||
|
@ -142,9 +170,35 @@
|
||||||
"launch_with_system": "Запуск Hydra вместе с системой",
|
"launch_with_system": "Запуск Hydra вместе с системой",
|
||||||
"general": "Основные",
|
"general": "Основные",
|
||||||
"behavior": "Поведение",
|
"behavior": "Поведение",
|
||||||
|
"download_sources": "Скачать исходный код",
|
||||||
|
"language": "Язык",
|
||||||
|
"real_debrid_api_token": "API Ключ",
|
||||||
"enable_real_debrid": "Включить Real-Debrid",
|
"enable_real_debrid": "Включить Real-Debrid",
|
||||||
|
"real_debrid_description": "Real-Debrid - это неограниченный загрузчик, который позволяет быстро скачивать файлы, размещенные в Интернете, или мгновенно передавать их в плеер через частную сеть, позволяющую обходить любые блокировки.",
|
||||||
|
"real_debrid_invalid_token": "Неверный API ключ",
|
||||||
"real_debrid_api_token_hint": "API ключ можно получить <0>здесь</0>",
|
"real_debrid_api_token_hint": "API ключ можно получить <0>здесь</0>",
|
||||||
"save_changes": "Сохранить изменения"
|
"real_debrid_free_account_error": "Аккаунт \"{{username}}\" - не имеет подписки. Пожалуйста, оформите подписку на Real-Debrid",
|
||||||
|
"real_debrid_linked_message": "Привязан аккаунт \"{{username}}\"",
|
||||||
|
"save_changes": "Сохранить изменения",
|
||||||
|
"changes_saved": "Изменения успешно сохранены",
|
||||||
|
"download_sources_description": "Hydra будет получать ссылки на загрузки из этих источников. URL должна содержать прямую ссылку на .json-файл с ссылками для загрузок.",
|
||||||
|
"validate_download_source": "Проверить",
|
||||||
|
"remove_download_source": "Удалить",
|
||||||
|
"add_download_source": "Добавить источник",
|
||||||
|
"download_count_zero": "В списке нет загрузок",
|
||||||
|
"download_count_one": "{{countFormatted}} загрузка в списке",
|
||||||
|
"download_count_other": "{{countFormatted}} загрузок в списке",
|
||||||
|
"download_options_zero": "Нет доступных загрузок",
|
||||||
|
"download_options_one": "{{countFormatted}} вариант загрузки доступен",
|
||||||
|
"download_options_other": "{{countFormatted}} вариантов загрузки доступно",
|
||||||
|
"download_source_url": "Ссылка на источник",
|
||||||
|
"add_download_source_description": "Вставьте ссылку на .json-файл",
|
||||||
|
"download_source_up_to_date": "Обновлён",
|
||||||
|
"download_source_errored": "Ошибка",
|
||||||
|
"sync_download_sources": "Синхронизировать источники",
|
||||||
|
"removed_download_source": "Источник загрузок удален",
|
||||||
|
"added_download_source": "Источник загрузок добавлен",
|
||||||
|
"download_sources_synced": "Все источники загрузок синхронизированы"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Загрузка завершена",
|
"download_complete": "Загрузка завершена",
|
||||||
|
@ -167,5 +221,8 @@
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"close": "Закрыть"
|
"close": "Закрыть"
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"toggle_password_visibility": "Показывать пароль"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,6 @@
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export const repackersOn1337x = [
|
|
||||||
"DODI",
|
|
||||||
"FitGirl",
|
|
||||||
"0xEMPRESS",
|
|
||||||
"KaOsKrew",
|
|
||||||
"TinyRepacks",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const repackers = [
|
|
||||||
...repackersOn1337x,
|
|
||||||
"Xatab",
|
|
||||||
"TinyRepacks",
|
|
||||||
"CPG",
|
|
||||||
"GOG",
|
|
||||||
"onlinefix",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const defaultDownloadsPath = app.getPath("downloads");
|
export const defaultDownloadsPath = app.getPath("downloads");
|
||||||
|
|
||||||
export const databasePath = path.join(
|
export const databasePath = path.join(
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
import { Game, GameShopCache, Repack, UserPreferences } from "@main/entity";
|
import {
|
||||||
|
DownloadQueue,
|
||||||
|
DownloadSource,
|
||||||
|
Game,
|
||||||
|
GameShopCache,
|
||||||
|
Repack,
|
||||||
|
UserPreferences,
|
||||||
|
} from "@main/entity";
|
||||||
import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions";
|
import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions";
|
||||||
|
|
||||||
import { databasePath } from "./constants";
|
import { databasePath } from "./constants";
|
||||||
|
@ -10,7 +17,14 @@ export const createDataSource = (
|
||||||
) =>
|
) =>
|
||||||
new DataSource({
|
new DataSource({
|
||||||
type: "better-sqlite3",
|
type: "better-sqlite3",
|
||||||
entities: [Game, Repack, UserPreferences, GameShopCache],
|
entities: [
|
||||||
|
Game,
|
||||||
|
Repack,
|
||||||
|
UserPreferences,
|
||||||
|
GameShopCache,
|
||||||
|
DownloadSource,
|
||||||
|
DownloadQueue,
|
||||||
|
],
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
database: databasePath,
|
database: databasePath,
|
||||||
...options,
|
...options,
|
||||||
|
|
25
src/main/entity/download-queue.entity.ts
Normal file
25
src/main/entity/download-queue.entity.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import type { Game } from "./game.entity";
|
||||||
|
|
||||||
|
@Entity("download_queue")
|
||||||
|
export class DownloadQueue {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@OneToOne("Game", "downloadQueue")
|
||||||
|
@JoinColumn()
|
||||||
|
game: Game;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
41
src/main/entity/download-source.entity.ts
Normal file
41
src/main/entity/download-source.entity.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from "typeorm";
|
||||||
|
import type { Repack } from "./repack.entity";
|
||||||
|
|
||||||
|
import { DownloadSourceStatus } from "@shared";
|
||||||
|
|
||||||
|
@Entity("download_source")
|
||||||
|
export class DownloadSource {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column("text", { nullable: true, unique: true })
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
@Column("text")
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column("text", { nullable: true })
|
||||||
|
etag: string | null;
|
||||||
|
|
||||||
|
@Column("int", { default: 0 })
|
||||||
|
downloadCount: number;
|
||||||
|
|
||||||
|
@Column("text", { default: DownloadSourceStatus.UpToDate })
|
||||||
|
status: DownloadSourceStatus;
|
||||||
|
|
||||||
|
@OneToMany("Repack", "downloadSource", { cascade: true })
|
||||||
|
repacks: Repack[];
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ import { Repack } from "./repack.entity";
|
||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
import type { Aria2Status } from "aria2";
|
import type { Aria2Status } from "aria2";
|
||||||
|
import type { DownloadQueue } from "./download-queue.entity";
|
||||||
|
|
||||||
@Entity("game")
|
@Entity("game")
|
||||||
export class Game {
|
export class Game {
|
||||||
|
@ -63,10 +64,19 @@ export class Game {
|
||||||
@Column("float", { default: 0 })
|
@Column("float", { default: 0 })
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
|
|
||||||
@OneToOne(() => Repack, { nullable: true })
|
@Column("text", { nullable: true })
|
||||||
|
uri: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
@OneToOne("Repack", "game", { nullable: true })
|
||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
repack: Repack;
|
repack: Repack;
|
||||||
|
|
||||||
|
@OneToOne("DownloadQueue", "game")
|
||||||
|
downloadQueue: DownloadQueue;
|
||||||
|
|
||||||
@Column("boolean", { default: false })
|
@Column("boolean", { default: false })
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
|
||||||
|
|
|
@ -2,3 +2,5 @@ export * from "./game.entity";
|
||||||
export * from "./repack.entity";
|
export * from "./repack.entity";
|
||||||
export * from "./user-preferences.entity";
|
export * from "./user-preferences.entity";
|
||||||
export * from "./game-shop-cache.entity";
|
export * from "./game-shop-cache.entity";
|
||||||
|
export * from "./download-source.entity";
|
||||||
|
export * from "./download-queue.entity";
|
||||||
|
|
|
@ -4,7 +4,9 @@ import {
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
} from "typeorm";
|
} from "typeorm";
|
||||||
|
import { DownloadSource } from "./download-source.entity";
|
||||||
|
|
||||||
@Entity("repack")
|
@Entity("repack")
|
||||||
export class Repack {
|
export class Repack {
|
||||||
|
@ -17,7 +19,10 @@ export class Repack {
|
||||||
@Column("text", { unique: true })
|
@Column("text", { unique: true })
|
||||||
magnet: string;
|
magnet: string;
|
||||||
|
|
||||||
@Column("int")
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
@Column("int", { nullable: true })
|
||||||
page: number;
|
page: number;
|
||||||
|
|
||||||
@Column("text")
|
@Column("text")
|
||||||
|
@ -29,6 +34,9 @@ export class Repack {
|
||||||
@Column("datetime")
|
@Column("datetime")
|
||||||
uploadDate: Date | string;
|
uploadDate: Date | string;
|
||||||
|
|
||||||
|
@ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" })
|
||||||
|
downloadSource: DownloadSource;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { AppUpdaterEvents } from "@types";
|
import { AppUpdaterEvent } from "@types";
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import updater, { UpdateInfo } from "electron-updater";
|
import updater, { UpdateInfo } from "electron-updater";
|
||||||
import { WindowManager } from "@main/services";
|
import { WindowManager } from "@main/services";
|
||||||
|
@ -6,12 +6,18 @@ import { app } from "electron";
|
||||||
|
|
||||||
const { autoUpdater } = updater;
|
const { autoUpdater } = updater;
|
||||||
|
|
||||||
const sendEvent = (event: AppUpdaterEvents) => {
|
const sendEvent = (event: AppUpdaterEvent) => {
|
||||||
WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event);
|
WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendEventsForDebug = false;
|
||||||
|
|
||||||
|
const isAutoInstallAvailable =
|
||||||
|
process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null;
|
||||||
|
|
||||||
const mockValuesForDebug = () => {
|
const mockValuesForDebug = () => {
|
||||||
sendEvent({ type: "update-available", info: { version: "1.3.0" } });
|
sendEvent({ type: "update-available", info: { version: "1.3.0" } });
|
||||||
|
sendEvent({ type: "update-downloaded" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
|
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||||
|
@ -24,10 +30,13 @@ const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
|
autoUpdater.autoDownload = isAutoInstallAvailable;
|
||||||
autoUpdater.checkForUpdates();
|
autoUpdater.checkForUpdates();
|
||||||
} else {
|
} else if (sendEventsForDebug) {
|
||||||
mockValuesForDebug();
|
mockValuesForDebug();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return isAutoInstallAvailable;
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("checkForUpdates", checkForUpdates);
|
registerEvent("checkForUpdates", checkForUpdates);
|
||||||
|
|
|
@ -1,95 +1,36 @@
|
||||||
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
|
import { getSteamAppAsset } from "@main/helpers";
|
||||||
import type { CatalogueCategory, CatalogueEntry, GameShop } from "@types";
|
import type { CatalogueEntry, GameShop } from "@types";
|
||||||
|
|
||||||
import { stateManager } from "@main/state-manager";
|
|
||||||
import { searchGames, searchRepacks } from "../helpers/search-games";
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { requestSteam250 } from "@main/services";
|
import { RepacksManager, requestSteam250 } from "@main/services";
|
||||||
|
import { formatName } from "@shared";
|
||||||
const repacks = stateManager.getValue("repacks");
|
|
||||||
|
|
||||||
const getStringForLookup = (index: number): string => {
|
|
||||||
const repack = repacks[index];
|
|
||||||
const formatter =
|
|
||||||
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
|
|
||||||
|
|
||||||
return formatName(formatter(repack.title));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resultSize = 12;
|
const resultSize = 12;
|
||||||
|
|
||||||
const getCatalogue = async (
|
const getCatalogue = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
|
||||||
category: CatalogueCategory
|
|
||||||
) => {
|
|
||||||
if (!repacks.length) return [];
|
|
||||||
|
|
||||||
if (category === "trending") {
|
|
||||||
return getTrendingCatalogue(resultSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getRecentlyAddedCatalogue(resultSize);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTrendingCatalogue = async (
|
|
||||||
resultSize: number
|
|
||||||
): Promise<CatalogueEntry[]> => {
|
|
||||||
const results: CatalogueEntry[] = [];
|
|
||||||
const trendingGames = await requestSteam250("/90day");
|
const trendingGames = await requestSteam250("/90day");
|
||||||
|
|
||||||
for (
|
|
||||||
let i = 0;
|
|
||||||
i < trendingGames.length && results.length < resultSize;
|
|
||||||
i++
|
|
||||||
) {
|
|
||||||
if (!trendingGames[i]) continue;
|
|
||||||
|
|
||||||
const { title, objectID } = trendingGames[i]!;
|
|
||||||
const repacks = searchRepacks(title);
|
|
||||||
|
|
||||||
if (title && repacks.length) {
|
|
||||||
const catalogueEntry = {
|
|
||||||
objectID,
|
|
||||||
title,
|
|
||||||
shop: "steam" as GameShop,
|
|
||||||
cover: getSteamAppAsset("library", objectID),
|
|
||||||
};
|
|
||||||
|
|
||||||
results.push({ ...catalogueEntry, repacks });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRecentlyAddedCatalogue = async (
|
|
||||||
resultSize: number
|
|
||||||
): Promise<CatalogueEntry[]> => {
|
|
||||||
const results: CatalogueEntry[] = [];
|
const results: CatalogueEntry[] = [];
|
||||||
|
|
||||||
for (let i = 0; results.length < resultSize; i++) {
|
for (let i = 0; i < resultSize; i++) {
|
||||||
const stringForLookup = getStringForLookup(i);
|
if (!trendingGames[i]) {
|
||||||
|
|
||||||
if (!stringForLookup) {
|
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const games = searchGames({ query: stringForLookup });
|
const { title, objectID } = trendingGames[i]!;
|
||||||
|
const repacks = RepacksManager.search({ query: formatName(title) });
|
||||||
|
|
||||||
for (const game of games) {
|
const catalogueEntry = {
|
||||||
const isAlreadyIncluded = results.some(
|
objectID,
|
||||||
(result) => result.objectID === game?.objectID
|
title,
|
||||||
);
|
shop: "steam" as GameShop,
|
||||||
|
cover: getSteamAppAsset("library", objectID),
|
||||||
|
};
|
||||||
|
|
||||||
if (!game || !game.repacks.length || isAlreadyIncluded) {
|
results.push({ ...catalogueEntry, repacks });
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push(game);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results.slice(0, resultSize);
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("getCatalogue", getCatalogue);
|
registerEvent("getCatalogue", getCatalogue);
|
||||||
|
|
|
@ -4,9 +4,9 @@ import { getSteamAppDetails } from "@main/services";
|
||||||
import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
|
import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { stateManager } from "@main/state-manager";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
|
|
||||||
const getLocalizedSteamAppDetails = (
|
const getLocalizedSteamAppDetails = async (
|
||||||
objectID: string,
|
objectID: string,
|
||||||
language: string
|
language: string
|
||||||
): Promise<ShopDetails | null> => {
|
): Promise<ShopDetails | null> => {
|
||||||
|
@ -14,20 +14,22 @@ const getLocalizedSteamAppDetails = (
|
||||||
return getSteamAppDetails(objectID, language);
|
return getSteamAppDetails(objectID, language);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getSteamAppDetails(objectID, language).then((localizedAppDetails) => {
|
return getSteamAppDetails(objectID, language).then(
|
||||||
const steamGame = stateManager
|
async (localizedAppDetails) => {
|
||||||
.getValue("steamGames")
|
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
||||||
.find((game) => game.id === Number(objectID));
|
name: "getById",
|
||||||
|
});
|
||||||
|
|
||||||
if (steamGame && localizedAppDetails) {
|
if (steamGame && localizedAppDetails) {
|
||||||
return {
|
return {
|
||||||
...localizedAppDetails,
|
...localizedAppDetails,
|
||||||
name: steamGame.name,
|
name: steamGame.name,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return null;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGameShopDetails = async (
|
const getGameShopDetails = async (
|
||||||
|
|
|
@ -1,39 +1,28 @@
|
||||||
import type { CatalogueEntry, GameShop } from "@types";
|
import type { CatalogueEntry } from "@types";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { searchRepacks } from "../helpers/search-games";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
import { stateManager } from "@main/state-manager";
|
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
|
||||||
import { getSteamAppAsset } from "@main/helpers";
|
import { RepacksManager } from "@main/services";
|
||||||
|
|
||||||
const steamGames = stateManager.getValue("steamGames");
|
|
||||||
|
|
||||||
const getGames = async (
|
const getGames = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
take = 12,
|
take = 12,
|
||||||
cursor = 0
|
cursor = 0
|
||||||
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
|
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
|
||||||
const results: CatalogueEntry[] = [];
|
const steamGames = await steamGamesWorker.run(
|
||||||
|
{ limit: take, offset: cursor },
|
||||||
|
{ name: "list" }
|
||||||
|
);
|
||||||
|
|
||||||
let i = 0 + cursor;
|
const entries = RepacksManager.findRepacksForCatalogueEntries(
|
||||||
|
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
|
||||||
|
);
|
||||||
|
|
||||||
while (results.length < take) {
|
return {
|
||||||
const game = steamGames[i];
|
results: entries,
|
||||||
const repacks = searchRepacks(game.name);
|
cursor: cursor + entries.length,
|
||||||
|
};
|
||||||
if (repacks.length) {
|
|
||||||
results.push({
|
|
||||||
objectID: String(game.id),
|
|
||||||
title: game.name,
|
|
||||||
shop: "steam" as GameShop,
|
|
||||||
cover: getSteamAppAsset("library", String(game.id)),
|
|
||||||
repacks,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { results, cursor: i };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("getGames", getGames);
|
registerEvent("getGames", getGames);
|
||||||
|
|
|
@ -3,21 +3,34 @@ import { shuffle } from "lodash-es";
|
||||||
import { getSteam250List } from "@main/services";
|
import { getSteam250List } from "@main/services";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { searchGames, searchRepacks } from "../helpers/search-games";
|
import { searchSteamGames } from "../helpers/search-games";
|
||||||
import type { Steam250Game } from "@types";
|
import type { Steam250Game } from "@types";
|
||||||
|
|
||||||
const state = { games: Array<Steam250Game>(), index: 0 };
|
const state = { games: Array<Steam250Game>(), index: 0 };
|
||||||
|
|
||||||
|
const filterGames = async (games: Steam250Game[]) => {
|
||||||
|
const results: Steam250Game[] = [];
|
||||||
|
|
||||||
|
for (const game of games) {
|
||||||
|
const catalogue = await searchSteamGames({ query: game.title });
|
||||||
|
|
||||||
|
if (catalogue.length) {
|
||||||
|
const [steamGame] = catalogue;
|
||||||
|
|
||||||
|
if (steamGame.repacks.length) {
|
||||||
|
results.push(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
|
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||||
if (state.games.length == 0) {
|
if (state.games.length == 0) {
|
||||||
const steam250List = await getSteam250List();
|
const steam250List = await getSteam250List();
|
||||||
|
|
||||||
const filteredSteam250List = steam250List.filter((game) => {
|
const filteredSteam250List = await filterGames(steam250List);
|
||||||
const repacks = searchRepacks(game.title);
|
|
||||||
const catalogue = searchGames({ query: game.title });
|
|
||||||
|
|
||||||
return repacks.length && catalogue.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
state.games = shuffle(filteredSteam250List);
|
state.games = shuffle(filteredSteam250List);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { searchRepacks } from "../helpers/search-games";
|
import { RepacksManager } from "@main/services";
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
const searchGameRepacks = (
|
const searchGameRepacks = (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
query: string
|
query: string
|
||||||
) => {
|
) => RepacksManager.search({ query });
|
||||||
return searchRepacks(query);
|
|
||||||
};
|
|
||||||
|
|
||||||
registerEvent("searchGameRepacks", searchGameRepacks);
|
registerEvent("searchGameRepacks", searchGameRepacks);
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { searchGames } from "../helpers/search-games";
|
import { searchSteamGames } from "../helpers/search-games";
|
||||||
import { CatalogueEntry } from "@types";
|
import { CatalogueEntry } from "@types";
|
||||||
|
|
||||||
const searchGamesEvent = async (
|
const searchGamesEvent = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
query: string
|
query: string
|
||||||
): Promise<CatalogueEntry[]> => {
|
): Promise<CatalogueEntry[]> => searchSteamGames({ query, limit: 12 });
|
||||||
return searchGames({ query, take: 12 });
|
|
||||||
};
|
|
||||||
|
|
||||||
registerEvent("searchGames", searchGamesEvent);
|
registerEvent("searchGames", searchGamesEvent);
|
||||||
|
|
42
src/main/events/download-sources/add-download-source.ts
Normal file
42
src/main/events/download-sources/add-download-source.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { dataSource } from "@main/data-source";
|
||||||
|
import { DownloadSource } from "@main/entity";
|
||||||
|
import axios from "axios";
|
||||||
|
import { downloadSourceSchema } from "../helpers/validators";
|
||||||
|
import { insertDownloadsFromSource } from "@main/helpers";
|
||||||
|
import { RepacksManager } from "@main/services";
|
||||||
|
|
||||||
|
const addDownloadSource = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
url: string
|
||||||
|
) => {
|
||||||
|
const response = await axios.get(url);
|
||||||
|
|
||||||
|
const source = downloadSourceSchema.parse(response.data);
|
||||||
|
|
||||||
|
const downloadSource = await dataSource.transaction(
|
||||||
|
async (transactionalEntityManager) => {
|
||||||
|
const downloadSource = await transactionalEntityManager
|
||||||
|
.getRepository(DownloadSource)
|
||||||
|
.save({
|
||||||
|
url,
|
||||||
|
name: source.name,
|
||||||
|
downloadCount: source.downloads.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
await insertDownloadsFromSource(
|
||||||
|
transactionalEntityManager,
|
||||||
|
downloadSource,
|
||||||
|
source.downloads
|
||||||
|
);
|
||||||
|
|
||||||
|
return downloadSource;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await RepacksManager.updateRepacks();
|
||||||
|
|
||||||
|
return downloadSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("addDownloadSource", addDownloadSource);
|
16
src/main/events/download-sources/get-download-sources.ts
Normal file
16
src/main/events/download-sources/get-download-sources.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { downloadSourceRepository } from "@main/repository";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
|
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||||
|
return downloadSourceRepository
|
||||||
|
.createQueryBuilder("downloadSource")
|
||||||
|
.leftJoin("downloadSource.repacks", "repacks")
|
||||||
|
.orderBy("downloadSource.createdAt", "DESC")
|
||||||
|
.loadRelationCountAndMap(
|
||||||
|
"downloadSource.repackCount",
|
||||||
|
"downloadSource.repacks"
|
||||||
|
)
|
||||||
|
.getMany();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getDownloadSources", getDownloadSources);
|
13
src/main/events/download-sources/remove-download-source.ts
Normal file
13
src/main/events/download-sources/remove-download-source.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { downloadSourceRepository } from "@main/repository";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { RepacksManager } from "@main/services";
|
||||||
|
|
||||||
|
const removeDownloadSource = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
id: number
|
||||||
|
) => {
|
||||||
|
await downloadSourceRepository.delete(id);
|
||||||
|
await RepacksManager.updateRepacks();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("removeDownloadSource", removeDownloadSource);
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { fetchDownloadSourcesAndUpdate } from "@main/helpers";
|
||||||
|
|
||||||
|
const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
|
||||||
|
fetchDownloadSourcesAndUpdate();
|
||||||
|
|
||||||
|
registerEvent("syncDownloadSources", syncDownloadSources);
|
34
src/main/events/download-sources/validate-download-source.ts
Normal file
34
src/main/events/download-sources/validate-download-source.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import axios from "axios";
|
||||||
|
import { downloadSourceRepository } from "@main/repository";
|
||||||
|
import { downloadSourceSchema } from "../helpers/validators";
|
||||||
|
import { RepacksManager } from "@main/services";
|
||||||
|
|
||||||
|
const validateDownloadSource = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
url: string
|
||||||
|
) => {
|
||||||
|
const response = await axios.get(url);
|
||||||
|
|
||||||
|
const source = downloadSourceSchema.parse(response.data);
|
||||||
|
|
||||||
|
const existingSource = await downloadSourceRepository.findOne({
|
||||||
|
where: { url },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingSource)
|
||||||
|
throw new Error("Source with the same url already exists");
|
||||||
|
|
||||||
|
const repacks = RepacksManager.repacks;
|
||||||
|
|
||||||
|
const existingUris = source.downloads
|
||||||
|
.flatMap((download) => download.uris)
|
||||||
|
.filter((uri) => repacks.some((repack) => repack.magnet === uri));
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: source.name,
|
||||||
|
downloadCount: source.downloads.length - existingUris.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("validateDownloadSource", validateDownloadSource);
|
|
@ -1,40 +1,11 @@
|
||||||
import flexSearch from "flexsearch";
|
|
||||||
import { orderBy } from "lodash-es";
|
import { orderBy } from "lodash-es";
|
||||||
|
import flexSearch from "flexsearch";
|
||||||
|
|
||||||
import type { GameRepack, GameShop, CatalogueEntry } from "@types";
|
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
|
||||||
|
|
||||||
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
|
import { getSteamAppAsset } from "@main/helpers";
|
||||||
import { stateManager } from "@main/state-manager";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
|
import { RepacksManager } from "@main/services";
|
||||||
const { Index } = flexSearch;
|
|
||||||
const repacksIndex = new Index();
|
|
||||||
const steamGamesIndex = new Index({ tokenize: "forward" });
|
|
||||||
|
|
||||||
const repacks = stateManager.getValue("repacks");
|
|
||||||
const steamGames = stateManager.getValue("steamGames");
|
|
||||||
|
|
||||||
for (let i = 0; i < repacks.length; i++) {
|
|
||||||
const repack = repacks[i];
|
|
||||||
const formatter =
|
|
||||||
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
|
|
||||||
|
|
||||||
repacksIndex.add(i, formatName(formatter(repack.title)));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < steamGames.length; i++) {
|
|
||||||
const steamGame = steamGames[i];
|
|
||||||
steamGamesIndex.add(i, formatName(steamGame.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchRepacks = (title: string): GameRepack[] => {
|
|
||||||
return orderBy(
|
|
||||||
repacksIndex
|
|
||||||
.search(formatName(title))
|
|
||||||
.map((index) => repacks.at(index as number)!),
|
|
||||||
["uploadDate"],
|
|
||||||
"desc"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface SearchGamesArgs {
|
export interface SearchGamesArgs {
|
||||||
query?: string;
|
query?: string;
|
||||||
|
@ -42,27 +13,29 @@ export interface SearchGamesArgs {
|
||||||
skip?: number;
|
skip?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchGames = ({
|
export const convertSteamGameToCatalogueEntry = (
|
||||||
query,
|
game: SteamGame
|
||||||
take,
|
): CatalogueEntry => ({
|
||||||
skip,
|
objectID: String(game.id),
|
||||||
}: SearchGamesArgs): CatalogueEntry[] => {
|
title: game.name,
|
||||||
const results = steamGamesIndex
|
shop: "steam" as GameShop,
|
||||||
.search(formatName(query || ""), { limit: take, offset: skip })
|
cover: getSteamAppAsset("library", String(game.id)),
|
||||||
.map((index) => {
|
repacks: [],
|
||||||
const result = steamGames.at(index as number)!;
|
});
|
||||||
|
|
||||||
return {
|
export const searchSteamGames = async (
|
||||||
objectID: String(result.id),
|
options: flexSearch.SearchOptions
|
||||||
title: result.name,
|
): Promise<CatalogueEntry[]> => {
|
||||||
shop: "steam" as GameShop,
|
const steamGames = (await steamGamesWorker.run(options, {
|
||||||
cover: getSteamAppAsset("library", String(result.id)),
|
name: "search",
|
||||||
repacks: searchRepacks(result.name),
|
})) as SteamGame[];
|
||||||
};
|
|
||||||
});
|
const result = RepacksManager.findRepacksForCatalogueEntries(
|
||||||
|
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
|
||||||
|
);
|
||||||
|
|
||||||
return orderBy(
|
return orderBy(
|
||||||
results,
|
result,
|
||||||
[({ repacks }) => repacks.length, "repacks"],
|
[({ repacks }) => repacks.length, "repacks"],
|
||||||
["desc"]
|
["desc"]
|
||||||
);
|
);
|
||||||
|
|
14
src/main/events/helpers/validators.ts
Normal file
14
src/main/events/helpers/validators.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const downloadSourceSchema = z.object({
|
||||||
|
name: z.string().max(255),
|
||||||
|
downloads: z.array(
|
||||||
|
z.object({
|
||||||
|
title: z.string().max(255),
|
||||||
|
downloaders: z.array(z.enum(["real_debrid", "torrent"])),
|
||||||
|
uris: z.array(z.string()),
|
||||||
|
uploadDate: z.string().max(255),
|
||||||
|
fileSize: z.string().max(255),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
|
@ -10,12 +10,16 @@ import "./catalogue/search-games";
|
||||||
import "./catalogue/search-game-repacks";
|
import "./catalogue/search-game-repacks";
|
||||||
import "./hardware/get-disk-free-space";
|
import "./hardware/get-disk-free-space";
|
||||||
import "./library/add-game-to-library";
|
import "./library/add-game-to-library";
|
||||||
|
import "./library/create-game-shortcut";
|
||||||
import "./library/close-game";
|
import "./library/close-game";
|
||||||
import "./library/delete-game-folder";
|
import "./library/delete-game-folder";
|
||||||
import "./library/get-game-by-object-id";
|
import "./library/get-game-by-object-id";
|
||||||
import "./library/get-library";
|
import "./library/get-library";
|
||||||
import "./library/open-game";
|
import "./library/open-game";
|
||||||
|
import "./library/open-game-executable-path";
|
||||||
import "./library/open-game-installer";
|
import "./library/open-game-installer";
|
||||||
|
import "./library/open-game-installer-path";
|
||||||
|
import "./library/update-executable-path";
|
||||||
import "./library/remove-game";
|
import "./library/remove-game";
|
||||||
import "./library/remove-game-from-library";
|
import "./library/remove-game-from-library";
|
||||||
import "./misc/open-external";
|
import "./misc/open-external";
|
||||||
|
@ -30,6 +34,11 @@ import "./user-preferences/auto-launch";
|
||||||
import "./autoupdater/check-for-updates";
|
import "./autoupdater/check-for-updates";
|
||||||
import "./autoupdater/restart-and-install-update";
|
import "./autoupdater/restart-and-install-update";
|
||||||
import "./user-preferences/authenticate-real-debrid";
|
import "./user-preferences/authenticate-real-debrid";
|
||||||
|
import "./download-sources/get-download-sources";
|
||||||
|
import "./download-sources/validate-download-source";
|
||||||
|
import "./download-sources/add-download-source";
|
||||||
|
import "./download-sources/remove-download-source";
|
||||||
|
import "./download-sources/sync-download-sources";
|
||||||
|
|
||||||
ipcMain.handle("ping", () => "pong");
|
ipcMain.handle("ping", () => "pong");
|
||||||
ipcMain.handle("getVersion", () => app.getVersion());
|
ipcMain.handle("getVersion", () => app.getVersion());
|
||||||
|
|
|
@ -4,14 +4,14 @@ import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
||||||
import { stateManager } from "@main/state-manager";
|
|
||||||
|
import { steamGamesWorker } from "@main/workers";
|
||||||
|
|
||||||
const addGameToLibrary = async (
|
const addGameToLibrary = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
objectID: string,
|
objectID: string,
|
||||||
title: string,
|
title: string,
|
||||||
shop: GameShop,
|
shop: GameShop
|
||||||
executablePath: string | null
|
|
||||||
) => {
|
) => {
|
||||||
return gameRepository
|
return gameRepository
|
||||||
.update(
|
.update(
|
||||||
|
@ -21,15 +21,14 @@ const addGameToLibrary = async (
|
||||||
{
|
{
|
||||||
shop,
|
shop,
|
||||||
status: null,
|
status: null,
|
||||||
executablePath,
|
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(async ({ affected }) => {
|
.then(async ({ affected }) => {
|
||||||
if (!affected) {
|
if (!affected) {
|
||||||
const steamGame = stateManager
|
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
||||||
.getValue("steamGames")
|
name: "getById",
|
||||||
.find((game) => game.id === Number(objectID));
|
});
|
||||||
|
|
||||||
const iconUrl = steamGame?.clientIcon
|
const iconUrl = steamGame?.clientIcon
|
||||||
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
|
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
|
||||||
|
@ -41,7 +40,6 @@ const addGameToLibrary = async (
|
||||||
iconUrl,
|
iconUrl,
|
||||||
objectID,
|
objectID,
|
||||||
shop,
|
shop,
|
||||||
executablePath,
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (iconUrl) {
|
if (iconUrl) {
|
||||||
|
|
29
src/main/events/library/create-game-shortcut.ts
Normal file
29
src/main/events/library/create-game-shortcut.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { gameRepository } from "@main/repository";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { IsNull, Not } from "typeorm";
|
||||||
|
import createDesktopShortcut from "create-desktop-shortcuts";
|
||||||
|
|
||||||
|
const createGameShortcut = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
id: number
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const game = await gameRepository.findOne({
|
||||||
|
where: { id, executablePath: Not(IsNull()) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (game) {
|
||||||
|
const filePath = game.executablePath;
|
||||||
|
|
||||||
|
const options = { filePath, name: game.title };
|
||||||
|
|
||||||
|
return createDesktopShortcut({
|
||||||
|
windows: options,
|
||||||
|
linux: options,
|
||||||
|
osx: options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("createGameShortcut", createGameShortcut);
|
|
@ -1,8 +1,6 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
import { In } from "typeorm";
|
|
||||||
|
|
||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
|
|
||||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
||||||
|
@ -14,11 +12,18 @@ const deleteGameFolder = async (
|
||||||
gameId: number
|
gameId: number
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const game = await gameRepository.findOne({
|
const game = await gameRepository.findOne({
|
||||||
where: {
|
where: [
|
||||||
id: gameId,
|
{
|
||||||
status: In(["removed", "complete"]),
|
id: gameId,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
},
|
status: "removed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: gameId,
|
||||||
|
progress: 1,
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
|
@ -30,7 +35,7 @@ const deleteGameFolder = async (
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fs.existsSync(folderPath)) {
|
if (fs.existsSync(folderPath)) {
|
||||||
return new Promise((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
fs.rm(
|
fs.rm(
|
||||||
folderPath,
|
folderPath,
|
||||||
{ recursive: true, force: true, maxRetries: 5, retryDelay: 200 },
|
{ recursive: true, force: true, maxRetries: 5, retryDelay: 200 },
|
||||||
|
@ -40,12 +45,21 @@ const deleteGameFolder = async (
|
||||||
reject();
|
reject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const aria2ControlFilePath = `${folderPath}.aria2`;
|
||||||
|
if (fs.existsSync(aria2ControlFilePath))
|
||||||
|
fs.rmSync(aria2ControlFilePath);
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await gameRepository.update(
|
||||||
|
{ id: gameId },
|
||||||
|
{ downloadPath: null, folderName: null, status: null, progress: 0 }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("deleteGameFolder", deleteGameFolder);
|
registerEvent("deleteGameFolder", deleteGameFolder);
|
||||||
|
|
|
@ -11,9 +11,6 @@ const getGameByObjectID = async (
|
||||||
objectID,
|
objectID,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
},
|
},
|
||||||
relations: {
|
|
||||||
repack: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
registerEvent("getGameByObjectID", getGameByObjectID);
|
registerEvent("getGameByObjectID", getGameByObjectID);
|
||||||
|
|
|
@ -1,30 +1,17 @@
|
||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
|
|
||||||
import { searchRepacks } from "../helpers/search-games";
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { sortBy } from "lodash-es";
|
|
||||||
|
|
||||||
const getLibrary = async () =>
|
const getLibrary = async () =>
|
||||||
gameRepository
|
gameRepository.find({
|
||||||
.find({
|
where: {
|
||||||
where: {
|
isDeleted: false,
|
||||||
isDeleted: false,
|
},
|
||||||
},
|
relations: {
|
||||||
order: {
|
downloadQueue: true,
|
||||||
createdAt: "desc",
|
},
|
||||||
},
|
order: {
|
||||||
relations: {
|
createdAt: "desc",
|
||||||
repack: true,
|
},
|
||||||
},
|
});
|
||||||
})
|
|
||||||
.then((games) =>
|
|
||||||
sortBy(
|
|
||||||
games.map((game) => ({
|
|
||||||
...game,
|
|
||||||
repacks: searchRepacks(game.title),
|
|
||||||
})),
|
|
||||||
(game) => (game.status !== "removed" ? 0 : 1)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
registerEvent("getLibrary", getLibrary);
|
registerEvent("getLibrary", getLibrary);
|
||||||
|
|
18
src/main/events/library/open-game-executable-path.ts
Normal file
18
src/main/events/library/open-game-executable-path.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { shell } from "electron";
|
||||||
|
import { gameRepository } from "@main/repository";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
|
const openGameExecutablePath = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
gameId: number
|
||||||
|
) => {
|
||||||
|
const game = await gameRepository.findOne({
|
||||||
|
where: { id: gameId, isDeleted: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game || !game.executablePath) return;
|
||||||
|
|
||||||
|
shell.showItemInFolder(game.executablePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("openGameExecutablePath", openGameExecutablePath);
|
27
src/main/events/library/open-game-installer-path.ts
Normal file
27
src/main/events/library/open-game-installer-path.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { shell } from "electron";
|
||||||
|
import path from "node:path";
|
||||||
|
import { gameRepository } from "@main/repository";
|
||||||
|
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
|
const openGameInstallerPath = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
gameId: number
|
||||||
|
) => {
|
||||||
|
const game = await gameRepository.findOne({
|
||||||
|
where: { id: gameId, isDeleted: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game || !game.folderName || !game.downloadPath) return true;
|
||||||
|
|
||||||
|
const gamePath = path.join(
|
||||||
|
game.downloadPath ?? (await getDownloadsPath()),
|
||||||
|
game.folderName!
|
||||||
|
);
|
||||||
|
|
||||||
|
shell.showItemInFolder(gamePath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("openGameInstallerPath", openGameInstallerPath);
|
|
@ -44,6 +44,11 @@ const openGameInstaller = async (
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
shell.openPath(gamePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (fs.lstatSync(gamePath).isFile()) {
|
if (fs.lstatSync(gamePath).isFile()) {
|
||||||
return executeGameInstaller(gamePath);
|
return executeGameInstaller(gamePath);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,10 @@ const removeGameFromLibrary = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
gameId: number
|
gameId: number
|
||||||
) => {
|
) => {
|
||||||
gameRepository.update({ id: gameId }, { isDeleted: true });
|
gameRepository.update(
|
||||||
|
{ id: gameId },
|
||||||
|
{ isDeleted: true, executablePath: null }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("removeGameFromLibrary", removeGameFromLibrary);
|
registerEvent("removeGameFromLibrary", removeGameFromLibrary);
|
||||||
|
|
20
src/main/events/library/update-executable-path.ts
Normal file
20
src/main/events/library/update-executable-path.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { gameRepository } from "@main/repository";
|
||||||
|
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
|
const updateExecutablePath = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
id: number,
|
||||||
|
executablePath: string
|
||||||
|
) => {
|
||||||
|
return gameRepository.update(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
executablePath,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("updateExecutablePath", updateExecutablePath);
|
|
@ -1,25 +1,31 @@
|
||||||
import { gameRepository } from "@main/repository";
|
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
import { DownloadManager } from "@main/services";
|
import { DownloadManager } from "@main/services";
|
||||||
|
import { dataSource } from "@main/data-source";
|
||||||
|
import { DownloadQueue, Game } from "@main/entity";
|
||||||
|
|
||||||
const cancelGameDownload = async (
|
const cancelGameDownload = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
gameId: number
|
gameId: number
|
||||||
) => {
|
) => {
|
||||||
await DownloadManager.cancelDownload(gameId);
|
await dataSource.transaction(async (transactionalEntityManager) => {
|
||||||
|
await DownloadManager.cancelDownload(gameId);
|
||||||
|
|
||||||
await gameRepository.update(
|
await transactionalEntityManager.getRepository(DownloadQueue).delete({
|
||||||
{
|
game: { id: gameId },
|
||||||
id: gameId,
|
});
|
||||||
},
|
|
||||||
{
|
await transactionalEntityManager.getRepository(Game).update(
|
||||||
status: "removed",
|
{
|
||||||
bytesDownloaded: 0,
|
id: gameId,
|
||||||
progress: 0,
|
},
|
||||||
}
|
{
|
||||||
);
|
status: "removed",
|
||||||
|
bytesDownloaded: 0,
|
||||||
|
progress: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("cancelGameDownload", cancelGameDownload);
|
registerEvent("cancelGameDownload", cancelGameDownload);
|
||||||
|
|
|
@ -1,13 +1,24 @@
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { gameRepository } from "../../repository";
|
|
||||||
import { DownloadManager } from "@main/services";
|
import { DownloadManager } from "@main/services";
|
||||||
|
import { dataSource } from "@main/data-source";
|
||||||
|
import { DownloadQueue, Game } from "@main/entity";
|
||||||
|
|
||||||
const pauseGameDownload = async (
|
const pauseGameDownload = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
gameId: number
|
gameId: number
|
||||||
) => {
|
) => {
|
||||||
await DownloadManager.pauseDownload();
|
await dataSource.transaction(async (transactionalEntityManager) => {
|
||||||
await gameRepository.update({ id: gameId }, { status: "paused" });
|
await DownloadManager.pauseDownload();
|
||||||
|
|
||||||
|
await transactionalEntityManager.getRepository(DownloadQueue).delete({
|
||||||
|
game: { id: gameId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await transactionalEntityManager
|
||||||
|
.getRepository(Game)
|
||||||
|
.update({ id: gameId }, { status: "paused" });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("pauseGameDownload", pauseGameDownload);
|
registerEvent("pauseGameDownload", pauseGameDownload);
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { gameRepository } from "../../repository";
|
||||||
|
|
||||||
import { DownloadManager } from "@main/services";
|
import { DownloadManager } from "@main/services";
|
||||||
import { dataSource } from "@main/data-source";
|
import { dataSource } from "@main/data-source";
|
||||||
import { Game } from "@main/entity";
|
import { DownloadQueue, Game } from "@main/entity";
|
||||||
|
|
||||||
const resumeGameDownload = async (
|
const resumeGameDownload = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
@ -16,7 +16,6 @@ const resumeGameDownload = async (
|
||||||
id: gameId,
|
id: gameId,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
},
|
},
|
||||||
relations: { repack: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
|
@ -31,6 +30,14 @@ const resumeGameDownload = async (
|
||||||
|
|
||||||
await DownloadManager.resumeDownload(game);
|
await DownloadManager.resumeDownload(game);
|
||||||
|
|
||||||
|
await transactionalEntityManager
|
||||||
|
.getRepository(DownloadQueue)
|
||||||
|
.delete({ game: { id: gameId } });
|
||||||
|
|
||||||
|
await transactionalEntityManager
|
||||||
|
.getRepository(DownloadQueue)
|
||||||
|
.insert({ game: { id: gameId } });
|
||||||
|
|
||||||
await transactionalEntityManager
|
await transactionalEntityManager
|
||||||
.getRepository(Game)
|
.getRepository(Game)
|
||||||
.update({ id: gameId }, { status: "active" });
|
.update({ id: gameId }, { status: "active" });
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
import { gameRepository, repackRepository } from "@main/repository";
|
import {
|
||||||
|
downloadQueueRepository,
|
||||||
|
gameRepository,
|
||||||
|
repackRepository,
|
||||||
|
} from "@main/repository";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
import type { StartGameDownloadPayload } from "@types";
|
import type { StartGameDownloadPayload } from "@types";
|
||||||
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
||||||
import { DownloadManager } from "@main/services";
|
import { DownloadManager } from "@main/services";
|
||||||
import { stateManager } from "@main/state-manager";
|
|
||||||
import { Not } from "typeorm";
|
import { Not } from "typeorm";
|
||||||
|
import { steamGamesWorker } from "@main/workers";
|
||||||
|
|
||||||
const startGameDownload = async (
|
const startGameDownload = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
@ -20,7 +25,6 @@ const startGameDownload = async (
|
||||||
objectID,
|
objectID,
|
||||||
shop,
|
shop,
|
||||||
},
|
},
|
||||||
relations: { repack: true },
|
|
||||||
}),
|
}),
|
||||||
repackRepository.findOne({
|
repackRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
|
@ -49,14 +53,14 @@ const startGameDownload = async (
|
||||||
bytesDownloaded: 0,
|
bytesDownloaded: 0,
|
||||||
downloadPath,
|
downloadPath,
|
||||||
downloader,
|
downloader,
|
||||||
repack: { id: repackId },
|
uri: repack.magnet,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const steamGame = stateManager
|
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
||||||
.getValue("steamGames")
|
name: "getById",
|
||||||
.find((game) => game.id === Number(objectID));
|
});
|
||||||
|
|
||||||
const iconUrl = steamGame?.clientIcon
|
const iconUrl = steamGame?.clientIcon
|
||||||
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
|
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
|
||||||
|
@ -71,7 +75,7 @@ const startGameDownload = async (
|
||||||
shop,
|
shop,
|
||||||
status: "active",
|
status: "active",
|
||||||
downloadPath,
|
downloadPath,
|
||||||
repack: { id: repackId },
|
uri: repack.magnet,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (iconUrl) {
|
if (iconUrl) {
|
||||||
|
@ -88,9 +92,11 @@ const startGameDownload = async (
|
||||||
where: {
|
where: {
|
||||||
objectID,
|
objectID,
|
||||||
},
|
},
|
||||||
relations: { repack: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
|
||||||
|
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
|
||||||
|
|
||||||
await DownloadManager.startDownload(updatedGame!);
|
await DownloadManager.startDownload(updatedGame!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
75
src/main/helpers/download-source.ts
Normal file
75
src/main/helpers/download-source.ts
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import { dataSource } from "@main/data-source";
|
||||||
|
import { DownloadSource, Repack } from "@main/entity";
|
||||||
|
import { downloadSourceSchema } from "@main/events/helpers/validators";
|
||||||
|
import { downloadSourceRepository } from "@main/repository";
|
||||||
|
import { RepacksManager } from "@main/services";
|
||||||
|
import { downloadSourceWorker } from "@main/workers";
|
||||||
|
import { chunk } from "lodash-es";
|
||||||
|
import type { EntityManager } from "typeorm";
|
||||||
|
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const insertDownloadsFromSource = async (
|
||||||
|
trx: EntityManager,
|
||||||
|
downloadSource: DownloadSource,
|
||||||
|
downloads: z.infer<typeof downloadSourceSchema>["downloads"]
|
||||||
|
) => {
|
||||||
|
const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map(
|
||||||
|
(download) => ({
|
||||||
|
title: download.title,
|
||||||
|
magnet: download.uris[0],
|
||||||
|
fileSize: download.fileSize,
|
||||||
|
repacker: downloadSource.name,
|
||||||
|
uploadDate: download.uploadDate,
|
||||||
|
downloadSource: { id: downloadSource.id },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadsChunks = chunk(repacks, 800);
|
||||||
|
|
||||||
|
for (const chunk of downloadsChunks) {
|
||||||
|
await trx
|
||||||
|
.getRepository(Repack)
|
||||||
|
.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.values(chunk)
|
||||||
|
.updateEntity(false)
|
||||||
|
.orIgnore()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDownloadSourcesAndUpdate = async () => {
|
||||||
|
const downloadSources = await downloadSourceRepository.find({
|
||||||
|
order: {
|
||||||
|
id: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await downloadSourceWorker.run(downloadSources, {
|
||||||
|
name: "getUpdatedRepacks",
|
||||||
|
});
|
||||||
|
|
||||||
|
await dataSource.transaction(async (transactionalEntityManager) => {
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.etag !== null) {
|
||||||
|
await transactionalEntityManager.getRepository(DownloadSource).update(
|
||||||
|
{ id: result.id },
|
||||||
|
{
|
||||||
|
etag: result.etag,
|
||||||
|
status: result.status,
|
||||||
|
downloadCount: result.downloads.length,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertDownloadsFromSource(
|
||||||
|
transactionalEntityManager,
|
||||||
|
result,
|
||||||
|
result.downloads
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await RepacksManager.updateRepacks();
|
||||||
|
});
|
||||||
|
};
|
|
@ -1,98 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, test } from "node:test";
|
|
||||||
import {
|
|
||||||
dodiFormatter,
|
|
||||||
empressFormatter,
|
|
||||||
fitGirlFormatter,
|
|
||||||
kaosKrewFormatter,
|
|
||||||
} from "./formatters";
|
|
||||||
|
|
||||||
describe("testing formatters", () => {
|
|
||||||
describe("testing fitgirl formatter", () => {
|
|
||||||
const fitGirlGames = [
|
|
||||||
"REVEIL (v1.0.3f4 + 0.5 DLC, MULTi14) [FitGirl Repack]",
|
|
||||||
"Dune: Spice Wars - The Ixian Edition (v2.0.0.31558 + DLC, MULTi9) [FitGirl Repack]",
|
|
||||||
"HUMANKIND: Premium Edition (v1.0.22.3819 + 17 DLCs/Bonus Content, MULTi12) [FitGirl Repack, Selective Download - from 7.3 GB]",
|
|
||||||
"Call to Arms: Gates of Hell - Ostfront: WW2 Bundle (v1.034 Hotfix 3 + 3 DLCs, MULTi9) [FitGirl Repack, Selective Download - from 21.8 GB]",
|
|
||||||
"SUPER BOMBERMAN R 2 (v1.2.0, MULTi12) [FitGirl Repack]",
|
|
||||||
"God of Rock (v3110, MULTi11) [FitGirl Repack]",
|
|
||||||
];
|
|
||||||
|
|
||||||
test("should format games correctly", () => {
|
|
||||||
assert.equal(fitGirlGames.map(fitGirlFormatter), [
|
|
||||||
"REVEIL",
|
|
||||||
"Dune: Spice Wars - The Ixian Edition",
|
|
||||||
"HUMANKIND: Premium Edition",
|
|
||||||
"Call to Arms: Gates of Hell - Ostfront: WW2 Bundle",
|
|
||||||
"SUPER BOMBERMAN R 2",
|
|
||||||
"God of Rock",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("testing kaoskrew formatter", () => {
|
|
||||||
const kaosKrewGames = [
|
|
||||||
"Song.Of.Horror.Complete.Edition.v1.25.MULTi4.REPACK-KaOs",
|
|
||||||
"Remoteness.REPACK-KaOs",
|
|
||||||
"Persona.5.Royal.v1.0.0.MULTi5.NSW.For.PC.REPACK-KaOs",
|
|
||||||
"The.Wreck.MULTi5.REPACK-KaOs",
|
|
||||||
"Nemezis.Mysterious.Journey.III.v1.04.Deluxe.Edition.REPACK-KaOs",
|
|
||||||
"The.World.Of.Others.v1.05.REPACK-KaOs",
|
|
||||||
];
|
|
||||||
|
|
||||||
test("should format games correctly", () => {
|
|
||||||
assert.equal(kaosKrewGames.map(kaosKrewFormatter), [
|
|
||||||
"Song Of Horror Complete Edition",
|
|
||||||
"Remoteness",
|
|
||||||
"Persona 5 Royal NSW For PC",
|
|
||||||
"The Wreck",
|
|
||||||
"Nemezis Mysterious Journey III Deluxe Edition",
|
|
||||||
"The World Of Others",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("testing empress formatter", () => {
|
|
||||||
const empressGames = [
|
|
||||||
"Resident.Evil.4-EMPRESS",
|
|
||||||
"Marvels.Guardians.of.the.Galaxy.Crackfix-EMPRESS",
|
|
||||||
"Life.is.Strange.2.Complete.Edition-EMPRESS",
|
|
||||||
"Forza.Horizon.4.PROPER-EMPRESS",
|
|
||||||
"Just.Cause.4.Complete.Edition.READNFO-EMPRESS",
|
|
||||||
"Immortals.Fenyx.Rising.Crackfix.V2-EMPRESS",
|
|
||||||
];
|
|
||||||
|
|
||||||
test("should format games correctly", () => {
|
|
||||||
assert.equal(empressGames.map(empressFormatter), [
|
|
||||||
"Resident Evil 4",
|
|
||||||
"Marvels Guardians of the Galaxy",
|
|
||||||
"Life is Strange 2 Complete Edition",
|
|
||||||
"Forza Horizon 4 PROPER",
|
|
||||||
"Just Cause 4 Complete Edition",
|
|
||||||
"Immortals Fenyx Rising",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("testing kodi formatter", () => {
|
|
||||||
const dodiGames = [
|
|
||||||
"Tomb Raider I-III Remastered Starring Lara Croft (MULTi20) (From 2.5 GB) [DODI Repack]",
|
|
||||||
"Trail Out: Complete Edition (v2.9st + All DLCs + MULTi11) [DODI Repack]",
|
|
||||||
"Call to Arms - Gates of Hell: Ostfront (v1.034.0 + All DLCs + MULTi9) (From 22.4 GB) [DODI Repack]",
|
|
||||||
"Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition (Digital book + MULTi6) [DODI Repack]",
|
|
||||||
"DREDGE: Digital Deluxe Edition (v1.2.0.1922 + All DLCs + Bonus Content + MULTi11) (From 413 MB) [DODI Repack]",
|
|
||||||
"Outliver: Tribulation [DODI Repack]",
|
|
||||||
];
|
|
||||||
|
|
||||||
test("should format games correctly", () => {
|
|
||||||
assert.equal(dodiGames.map(dodiFormatter), [
|
|
||||||
"Tomb Raider I-III Remastered Starring Lara Croft",
|
|
||||||
"Trail Out: Complete Edition",
|
|
||||||
"Call to Arms - Gates of Hell: Ostfront",
|
|
||||||
"Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition",
|
|
||||||
"DREDGE: Digital Deluxe Edition",
|
|
||||||
"Outliver: Tribulation",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,56 +0,0 @@
|
||||||
/* String formatting */
|
|
||||||
|
|
||||||
export const removeReleaseYearFromName = (name: string) =>
|
|
||||||
name.replace(/\([0-9]{4}\)/g, "");
|
|
||||||
|
|
||||||
export const removeSymbolsFromName = (name: string) =>
|
|
||||||
name.replace(/[^A-Za-z 0-9]/g, "");
|
|
||||||
|
|
||||||
export const removeSpecialEditionFromName = (name: string) =>
|
|
||||||
name.replace(
|
|
||||||
/(The |Digital )?(GOTY|Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year|Reloaded|[0-9]{4}) Edition/g,
|
|
||||||
""
|
|
||||||
);
|
|
||||||
|
|
||||||
export const removeDuplicateSpaces = (name: string) =>
|
|
||||||
name.replace(/\s{2,}/g, " ");
|
|
||||||
|
|
||||||
export const removeTrash = (title: string) =>
|
|
||||||
title.replace(/\(.*\)|\[.*]/g, "").replace(/:/g, "");
|
|
||||||
|
|
||||||
/* Formatters per repacker */
|
|
||||||
|
|
||||||
export const fitGirlFormatter = (title: string) =>
|
|
||||||
title.replace(/\(.*\)/g, "").trim();
|
|
||||||
|
|
||||||
export const kaosKrewFormatter = (title: string) =>
|
|
||||||
title
|
|
||||||
.replace(/(v\.?[0-9])+([0-9]|\.)+/, "")
|
|
||||||
.replace(
|
|
||||||
/(\.Build\.[0-9]*)?(\.MULTi[0-9]{1,2})?(\.REPACK-KaOs|\.UPDATE-KaOs)?/g,
|
|
||||||
""
|
|
||||||
)
|
|
||||||
.replace(/\./g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
export const empressFormatter = (title: string) =>
|
|
||||||
title
|
|
||||||
.replace(/-EMPRESS/, "")
|
|
||||||
.replace(/\./g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
export const dodiFormatter = (title: string) =>
|
|
||||||
title.replace(/\(.*?\)/g, "").trim();
|
|
||||||
|
|
||||||
export const xatabFormatter = (title: string) =>
|
|
||||||
title
|
|
||||||
.replace(/RePack от xatab|RePack от Decepticon|R.G. GOGFAN/, "")
|
|
||||||
.replace(/[\u0400-\u04FF]/g, "")
|
|
||||||
.replace(/(v\.?([0-9]| )+)+([0-9]|\.|-|_|\/|[a-zA-Z]| )+/, "");
|
|
||||||
|
|
||||||
export const tinyRepacksFormatter = (title: string) => title;
|
|
||||||
export const onlinefixFormatter = (title: string) =>
|
|
||||||
title.replace("по сети", "").trim();
|
|
||||||
|
|
||||||
export const gogFormatter = (title: string) =>
|
|
||||||
title.replace(/(v\.[0-9]+|v[0-9]+\.|v[0-9]{4})+.+/, "");
|
|
|
@ -1,48 +1,5 @@
|
||||||
import {
|
import axios from "axios";
|
||||||
removeReleaseYearFromName,
|
import UserAgent from "user-agents";
|
||||||
removeSymbolsFromName,
|
|
||||||
removeSpecialEditionFromName,
|
|
||||||
empressFormatter,
|
|
||||||
kaosKrewFormatter,
|
|
||||||
fitGirlFormatter,
|
|
||||||
removeDuplicateSpaces,
|
|
||||||
dodiFormatter,
|
|
||||||
removeTrash,
|
|
||||||
xatabFormatter,
|
|
||||||
tinyRepacksFormatter,
|
|
||||||
gogFormatter,
|
|
||||||
onlinefixFormatter,
|
|
||||||
} from "./formatters";
|
|
||||||
import { repackers } from "../constants";
|
|
||||||
|
|
||||||
export const pipe =
|
|
||||||
<T>(...fns: ((arg: T) => any)[]) =>
|
|
||||||
(arg: T) =>
|
|
||||||
fns.reduce((prev, fn) => fn(prev), arg);
|
|
||||||
|
|
||||||
export const formatName = pipe<string>(
|
|
||||||
removeTrash,
|
|
||||||
removeReleaseYearFromName,
|
|
||||||
removeSymbolsFromName,
|
|
||||||
removeSpecialEditionFromName,
|
|
||||||
removeDuplicateSpaces,
|
|
||||||
(str) => str.trim()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const repackerFormatter: Record<
|
|
||||||
(typeof repackers)[number],
|
|
||||||
(title: string) => string
|
|
||||||
> = {
|
|
||||||
DODI: dodiFormatter,
|
|
||||||
"0xEMPRESS": empressFormatter,
|
|
||||||
KaOsKrew: kaosKrewFormatter,
|
|
||||||
FitGirl: fitGirlFormatter,
|
|
||||||
Xatab: xatabFormatter,
|
|
||||||
CPG: (title: string) => title,
|
|
||||||
TinyRepacks: tinyRepacksFormatter,
|
|
||||||
GOG: gogFormatter,
|
|
||||||
onlinefix: onlinefixFormatter,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSteamAppAsset = (
|
export const getSteamAppAsset = (
|
||||||
category: "library" | "hero" | "logo" | "icon",
|
category: "library" | "hero" | "logo" | "icon",
|
||||||
|
@ -88,5 +45,17 @@ export const steamUrlBuilder = {
|
||||||
export const sleep = (ms: number) =>
|
export const sleep = (ms: number) =>
|
||||||
new Promise((resolve) => setTimeout(resolve, ms));
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
export * from "./formatters";
|
export const requestWebPage = async (url: string) => {
|
||||||
|
const userAgent = new UserAgent();
|
||||||
|
|
||||||
|
return axios
|
||||||
|
.get(url, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": userAgent.toString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => response.data);
|
||||||
|
};
|
||||||
|
|
||||||
export * from "./ps";
|
export * from "./ps";
|
||||||
|
export * from "./download-source";
|
||||||
|
|
|
@ -3,15 +3,11 @@ import updater from "electron-updater";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
||||||
import {
|
import { DownloadManager, logger, WindowManager } from "@main/services";
|
||||||
DownloadManager,
|
|
||||||
logger,
|
|
||||||
resolveDatabaseUpdates,
|
|
||||||
WindowManager,
|
|
||||||
} from "@main/services";
|
|
||||||
import { dataSource } from "@main/data-source";
|
import { dataSource } from "@main/data-source";
|
||||||
import * as resources from "@locales";
|
import * as resources from "@locales";
|
||||||
import { userPreferencesRepository } from "@main/repository";
|
import { userPreferencesRepository } from "@main/repository";
|
||||||
|
|
||||||
const { autoUpdater } = updater;
|
const { autoUpdater } = updater;
|
||||||
|
|
||||||
autoUpdater.setFeedURL({
|
autoUpdater.setFeedURL({
|
||||||
|
@ -51,27 +47,24 @@ if (process.defaultApp) {
|
||||||
// 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.
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(async () => {
|
||||||
electronApp.setAppUserModelId("site.hydralauncher.hydra");
|
electronApp.setAppUserModelId("site.hydralauncher.hydra");
|
||||||
|
|
||||||
protocol.handle("hydra", (request) =>
|
protocol.handle("hydra", (request) =>
|
||||||
net.fetch("file://" + request.url.slice("hydra://".length))
|
net.fetch("file://" + request.url.slice("hydra://".length))
|
||||||
);
|
);
|
||||||
|
|
||||||
dataSource.initialize().then(async () => {
|
await dataSource.initialize();
|
||||||
await dataSource.runMigrations();
|
await dataSource.runMigrations();
|
||||||
|
|
||||||
await resolveDatabaseUpdates();
|
await import("./main");
|
||||||
|
|
||||||
await import("./main");
|
const userPreferences = await userPreferencesRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
const userPreferences = await userPreferencesRepository.findOne({
|
|
||||||
where: { id: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
WindowManager.createMainWindow();
|
|
||||||
WindowManager.createSystemTray(userPreferences?.language || "en");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
WindowManager.createMainWindow();
|
||||||
|
WindowManager.createSystemTray(userPreferences?.language || "en");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
|
|
111
src/main/main.ts
111
src/main/main.ts
|
@ -1,103 +1,48 @@
|
||||||
import { stateManager } from "./state-manager";
|
import { DownloadManager, RepacksManager, startMainLoop } from "./services";
|
||||||
import { repackersOn1337x, seedsPath } from "./constants";
|
|
||||||
import {
|
import {
|
||||||
getNewGOGGames,
|
downloadQueueRepository,
|
||||||
getNewRepacksFromUser,
|
|
||||||
getNewRepacksFromXatab,
|
|
||||||
getNewRepacksFromOnlineFix,
|
|
||||||
DownloadManager,
|
|
||||||
startMainLoop,
|
|
||||||
} from "./services";
|
|
||||||
import {
|
|
||||||
gameRepository,
|
|
||||||
repackRepository,
|
repackRepository,
|
||||||
userPreferencesRepository,
|
userPreferencesRepository,
|
||||||
} from "./repository";
|
} from "./repository";
|
||||||
import { Repack, UserPreferences } from "./entity";
|
import { UserPreferences } from "./entity";
|
||||||
import { Notification } from "electron";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { RealDebridClient } from "./services/real-debrid";
|
import { RealDebridClient } from "./services/real-debrid";
|
||||||
import { orderBy } from "lodash-es";
|
import { fetchDownloadSourcesAndUpdate } from "./helpers";
|
||||||
import { SteamGame } from "@types";
|
import { publishNewRepacksNotifications } from "./services/notifications";
|
||||||
import { Not } from "typeorm";
|
import { MoreThan } from "typeorm";
|
||||||
|
|
||||||
startMainLoop();
|
startMainLoop();
|
||||||
|
|
||||||
const track1337xUsers = async (existingRepacks: Repack[]) => {
|
|
||||||
for (const repacker of repackersOn1337x) {
|
|
||||||
await getNewRepacksFromUser(
|
|
||||||
repacker,
|
|
||||||
existingRepacks.filter((repack) => repack.repacker === repacker)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkForNewRepacks = async (userPreferences: UserPreferences | null) => {
|
|
||||||
const existingRepacks = stateManager.getValue("repacks");
|
|
||||||
|
|
||||||
Promise.allSettled([
|
|
||||||
track1337xUsers(existingRepacks),
|
|
||||||
getNewRepacksFromXatab(
|
|
||||||
existingRepacks.filter((repack) => repack.repacker === "Xatab")
|
|
||||||
),
|
|
||||||
getNewGOGGames(
|
|
||||||
existingRepacks.filter((repack) => repack.repacker === "GOG")
|
|
||||||
),
|
|
||||||
getNewRepacksFromOnlineFix(
|
|
||||||
existingRepacks.filter((repack) => repack.repacker === "onlinefix")
|
|
||||||
),
|
|
||||||
]).then(() => {
|
|
||||||
repackRepository.count().then((count) => {
|
|
||||||
const total = count - stateManager.getValue("repacks").length;
|
|
||||||
|
|
||||||
if (total > 0 && userPreferences?.repackUpdatesNotificationsEnabled) {
|
|
||||||
new Notification({
|
|
||||||
title: t("repack_list_updated", {
|
|
||||||
ns: "notifications",
|
|
||||||
lng: userPreferences?.language || "en",
|
|
||||||
}),
|
|
||||||
body: t("repack_count", {
|
|
||||||
ns: "notifications",
|
|
||||||
lng: userPreferences?.language || "en",
|
|
||||||
count: total,
|
|
||||||
}),
|
|
||||||
}).show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadState = async (userPreferences: UserPreferences | null) => {
|
const loadState = async (userPreferences: UserPreferences | null) => {
|
||||||
const repacks = repackRepository.find({
|
await RepacksManager.updateRepacks();
|
||||||
order: {
|
|
||||||
createdAt: "desc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const steamGames = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(seedsPath, "steam-games.json"), "utf-8")
|
|
||||||
) as SteamGame[];
|
|
||||||
|
|
||||||
stateManager.setValue("repacks", await repacks);
|
|
||||||
stateManager.setValue("steamGames", orderBy(steamGames, ["name"], "asc"));
|
|
||||||
|
|
||||||
import("./events");
|
import("./events");
|
||||||
|
|
||||||
if (userPreferences?.realDebridApiToken)
|
if (userPreferences?.realDebridApiToken)
|
||||||
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
||||||
|
|
||||||
const game = await gameRepository.findOne({
|
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||||
where: {
|
order: {
|
||||||
status: "active",
|
id: "DESC",
|
||||||
progress: Not(1),
|
},
|
||||||
isDeleted: false,
|
relations: {
|
||||||
|
game: true,
|
||||||
},
|
},
|
||||||
relations: { repack: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (game) DownloadManager.startDownload(game);
|
if (nextQueueItem?.game.status === "active")
|
||||||
|
DownloadManager.startDownload(nextQueueItem.game);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
fetchDownloadSourcesAndUpdate().then(async () => {
|
||||||
|
const newRepacksCount = await repackRepository.count({
|
||||||
|
where: {
|
||||||
|
createdAt: MoreThan(now),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newRepacksCount > 0) publishNewRepacksNotifications(newRepacksCount);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
userPreferencesRepository
|
userPreferencesRepository
|
||||||
|
@ -105,5 +50,5 @@ userPreferencesRepository
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
})
|
})
|
||||||
.then((userPreferences) => {
|
.then((userPreferences) => {
|
||||||
loadState(userPreferences).then(() => checkForNewRepacks(userPreferences));
|
loadState(userPreferences);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,75 +1,8 @@
|
||||||
import { createDataSource } from "@main/data-source";
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
import { Repack } from "@main/entity";
|
|
||||||
import { app } from "electron";
|
|
||||||
import { chunk } from "lodash-es";
|
|
||||||
import path from "path";
|
|
||||||
import { In, MigrationInterface, QueryRunner, Table } from "typeorm";
|
|
||||||
|
|
||||||
export class FixRepackUploadDate1715900413313 implements MigrationInterface {
|
export class FixRepackUploadDate1715900413313 implements MigrationInterface {
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(_: QueryRunner): Promise<void> {
|
||||||
await queryRunner.createTable(
|
return;
|
||||||
new Table({
|
|
||||||
name: "repack_temp",
|
|
||||||
columns: [
|
|
||||||
{ name: "title", type: "varchar" },
|
|
||||||
{ name: "old_id", type: "int" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryRunner.query(
|
|
||||||
`INSERT INTO repack_temp (title, old_id) SELECT title, id FROM repack WHERE repacker IN ('onlinefix', 'Xatab');`
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryRunner.query(
|
|
||||||
`DELETE FROM repack WHERE repacker IN ('onlinefix', 'Xatab');`
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateDataSource = createDataSource({
|
|
||||||
database: app.isPackaged
|
|
||||||
? path.join(process.resourcesPath, "hydra.db")
|
|
||||||
: path.join(__dirname, "..", "..", "hydra.db"),
|
|
||||||
});
|
|
||||||
|
|
||||||
await updateDataSource.initialize();
|
|
||||||
|
|
||||||
const updateRepackRepository = updateDataSource.getRepository(Repack);
|
|
||||||
|
|
||||||
const updatedRepacks = await updateRepackRepository.find({
|
|
||||||
where: {
|
|
||||||
repacker: In(["onlinefix", "Xatab"]),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const chunks = chunk(
|
|
||||||
updatedRepacks.map((repack) => {
|
|
||||||
const { id: _, ...rest } = repack;
|
|
||||||
return rest;
|
|
||||||
}),
|
|
||||||
500
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
await queryRunner.manager
|
|
||||||
.createQueryBuilder(Repack, "repack")
|
|
||||||
.insert()
|
|
||||||
.values(chunk)
|
|
||||||
.orIgnore()
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
await queryRunner.query(
|
|
||||||
`UPDATE game
|
|
||||||
SET repackId = (
|
|
||||||
SELECT id
|
|
||||||
from repack LEFT JOIN repack_temp ON repack_temp.title = repack.title
|
|
||||||
WHERE repack_temp.old_id = game.repackId
|
|
||||||
)
|
|
||||||
WHERE EXISTS (select old_id from repack_temp WHERE old_id = game.repackId)`
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryRunner.dropTable("repack_temp");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(_: QueryRunner): Promise<void> {
|
public async down(_: QueryRunner): Promise<void> {
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
import { dataSource } from "./data-source";
|
import { dataSource } from "./data-source";
|
||||||
import { Game, GameShopCache, Repack, UserPreferences } from "@main/entity";
|
import {
|
||||||
|
DownloadQueue,
|
||||||
|
DownloadSource,
|
||||||
|
Game,
|
||||||
|
GameShopCache,
|
||||||
|
Repack,
|
||||||
|
UserPreferences,
|
||||||
|
} from "@main/entity";
|
||||||
|
|
||||||
export const gameRepository = dataSource.getRepository(Game);
|
export const gameRepository = dataSource.getRepository(Game);
|
||||||
|
|
||||||
|
@ -9,3 +16,8 @@ export const userPreferencesRepository =
|
||||||
dataSource.getRepository(UserPreferences);
|
dataSource.getRepository(UserPreferences);
|
||||||
|
|
||||||
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
|
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
|
||||||
|
|
||||||
|
export const downloadSourceRepository =
|
||||||
|
dataSource.getRepository(DownloadSource);
|
||||||
|
|
||||||
|
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import Aria2, { StatusResponse } from "aria2";
|
import Aria2, { StatusResponse } from "aria2";
|
||||||
|
|
||||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
||||||
|
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import { RealDebridClient } from "./real-debrid";
|
import { RealDebridClient } from "./real-debrid";
|
||||||
import { Notification } from "electron";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
import { DownloadProgress } from "@types";
|
import { DownloadProgress } from "@types";
|
||||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||||
|
@ -14,6 +15,7 @@ import { startAria2 } from "./aria2c";
|
||||||
import { sleep } from "@main/helpers";
|
import { sleep } from "@main/helpers";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
|
import { publishDownloadCompleteNotification } from "./notifications";
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
private static downloads = new Map<number, string>();
|
private static downloads = new Map<number, string>();
|
||||||
|
@ -65,29 +67,13 @@ export class DownloadManager {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async publishNotification() {
|
|
||||||
const userPreferences = await userPreferencesRepository.findOne({
|
|
||||||
where: { id: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userPreferences?.downloadNotificationsEnabled && this.game) {
|
|
||||||
new Notification({
|
|
||||||
title: t("download_complete", {
|
|
||||||
ns: "notifications",
|
|
||||||
lng: userPreferences.language,
|
|
||||||
}),
|
|
||||||
body: t("game_ready_to_install", {
|
|
||||||
ns: "notifications",
|
|
||||||
lng: userPreferences.language,
|
|
||||||
title: this.game.title,
|
|
||||||
}),
|
|
||||||
}).show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getFolderName(status: StatusResponse) {
|
private static getFolderName(status: StatusResponse) {
|
||||||
if (status.bittorrent?.info) return status.bittorrent.info.name;
|
if (status.bittorrent?.info) return status.bittorrent.info.name;
|
||||||
return "";
|
|
||||||
|
const [file] = status.files;
|
||||||
|
if (file) return path.win32.basename(file.path);
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async getRealDebridDownloadUrl() {
|
private static async getRealDebridDownloadUrl() {
|
||||||
|
@ -192,22 +178,8 @@ export class DownloadManager {
|
||||||
|
|
||||||
const game = await gameRepository.findOne({
|
const game = await gameRepository.findOne({
|
||||||
where: { id: this.game.id, isDeleted: false },
|
where: { id: this.game.id, isDeleted: false },
|
||||||
relations: { repack: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (progress === 1 && this.game && !isDownloadingMetadata) {
|
|
||||||
await this.publishNotification();
|
|
||||||
|
|
||||||
/*
|
|
||||||
Only cancel bittorrent downloads to stop seeding
|
|
||||||
*/
|
|
||||||
if (status.bittorrent) {
|
|
||||||
await this.cancelDownload(this.game.id);
|
|
||||||
} else {
|
|
||||||
this.clearCurrentDownload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WindowManager.mainWindow && game) {
|
if (WindowManager.mainWindow && game) {
|
||||||
if (!isNaN(progress))
|
if (!isNaN(progress))
|
||||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||||
|
@ -230,6 +202,34 @@ export class DownloadManager {
|
||||||
JSON.parse(JSON.stringify(payload))
|
JSON.parse(JSON.stringify(payload))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (progress === 1 && this.game && !isDownloadingMetadata) {
|
||||||
|
publishDownloadCompleteNotification(this.game);
|
||||||
|
|
||||||
|
await downloadQueueRepository.delete({ game: this.game });
|
||||||
|
|
||||||
|
/*
|
||||||
|
Only cancel bittorrent downloads to stop seeding
|
||||||
|
*/
|
||||||
|
if (status.bittorrent) {
|
||||||
|
await this.cancelDownload(this.game.id);
|
||||||
|
} else {
|
||||||
|
this.clearCurrentDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||||
|
order: {
|
||||||
|
id: "DESC",
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
game: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextQueueItem) {
|
||||||
|
this.resumeDownload(nextQueueItem.game);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static clearCurrentDownload() {
|
private static clearCurrentDownload() {
|
||||||
|
@ -245,7 +245,7 @@ export class DownloadManager {
|
||||||
const gid = this.downloads.get(gameId);
|
const gid = this.downloads.get(gameId);
|
||||||
|
|
||||||
if (gid) {
|
if (gid) {
|
||||||
await this.aria2.call("remove", gid);
|
await this.aria2.call("forceRemove", gid);
|
||||||
|
|
||||||
if (this.gid === gid) {
|
if (this.gid === gid) {
|
||||||
this.clearCurrentDownload();
|
this.clearCurrentDownload();
|
||||||
|
@ -291,10 +291,10 @@ export class DownloadManager {
|
||||||
|
|
||||||
if (game.downloader === Downloader.RealDebrid) {
|
if (game.downloader === Downloader.RealDebrid) {
|
||||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
|
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
|
||||||
game!.repack.magnet
|
game!.uri!
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.gid = await this.aria2.call("addUri", [game.repack.magnet], options);
|
this.gid = await this.aria2.call("addUri", [game.uri!], options);
|
||||||
this.downloads.set(game.id, this.gid);
|
this.downloads.set(game.id, this.gid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { formatName } from "@main/helpers";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { requestWebPage } from "./repack-tracker/helpers";
|
import { requestWebPage } from "@main/helpers";
|
||||||
import { HowLongToBeatCategory } from "@types";
|
import { HowLongToBeatCategory } from "@types";
|
||||||
|
import { formatName } from "@shared";
|
||||||
|
|
||||||
export interface HowLongToBeatResult {
|
export interface HowLongToBeatResult {
|
||||||
game_id: number;
|
game_id: number;
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
export * from "./logger";
|
export * from "./logger";
|
||||||
export * from "./repack-tracker";
|
|
||||||
export * from "./steam";
|
export * from "./steam";
|
||||||
export * from "./steam-250";
|
export * from "./steam-250";
|
||||||
export * from "./steam-grid";
|
export * from "./steam-grid";
|
||||||
export * from "./update-resolver";
|
|
||||||
export * from "./window-manager";
|
export * from "./window-manager";
|
||||||
export * from "./download-manager";
|
export * from "./download-manager";
|
||||||
export * from "./how-long-to-beat";
|
export * from "./how-long-to-beat";
|
||||||
export * from "./process-watcher";
|
export * from "./process-watcher";
|
||||||
export * from "./main-loop";
|
export * from "./main-loop";
|
||||||
|
export * from "./repacks-manager";
|
||||||
|
|
72
src/main/services/notifications.ts
Normal file
72
src/main/services/notifications.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { Notification, nativeImage } from "electron";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { parseICO } from "icojs";
|
||||||
|
|
||||||
|
import { Game } from "@main/entity";
|
||||||
|
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||||
|
|
||||||
|
const getGameIconNativeImage = async (gameId: number) => {
|
||||||
|
try {
|
||||||
|
const game = await gameRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: gameId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game?.iconUrl) return undefined;
|
||||||
|
|
||||||
|
const images = await parseICO(
|
||||||
|
Buffer.from(game.iconUrl.split("base64,")[1], "base64")
|
||||||
|
);
|
||||||
|
|
||||||
|
const highResIcon = images.find((image) => image.width >= 128);
|
||||||
|
if (!highResIcon) return undefined;
|
||||||
|
|
||||||
|
return nativeImage.createFromBuffer(Buffer.from(highResIcon.buffer));
|
||||||
|
} catch (err) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const publishDownloadCompleteNotification = async (game: Game) => {
|
||||||
|
const userPreferences = await userPreferencesRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = await getGameIconNativeImage(game.id);
|
||||||
|
|
||||||
|
if (userPreferences?.downloadNotificationsEnabled) {
|
||||||
|
new Notification({
|
||||||
|
title: t("download_complete", {
|
||||||
|
ns: "notifications",
|
||||||
|
lng: userPreferences.language,
|
||||||
|
}),
|
||||||
|
body: t("game_ready_to_install", {
|
||||||
|
ns: "notifications",
|
||||||
|
lng: userPreferences.language,
|
||||||
|
title: game.title,
|
||||||
|
}),
|
||||||
|
icon,
|
||||||
|
}).show();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const publishNewRepacksNotifications = async (count: number) => {
|
||||||
|
const userPreferences = await userPreferencesRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userPreferences?.repackUpdatesNotificationsEnabled) {
|
||||||
|
new Notification({
|
||||||
|
title: t("repack_list_updated", {
|
||||||
|
ns: "notifications",
|
||||||
|
lng: userPreferences?.language || "en",
|
||||||
|
}),
|
||||||
|
body: t("repack_count", {
|
||||||
|
ns: "notifications",
|
||||||
|
lng: userPreferences?.language || "en",
|
||||||
|
count: count,
|
||||||
|
}),
|
||||||
|
}).show();
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,146 +0,0 @@
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
|
|
||||||
import { Repack } from "@main/entity";
|
|
||||||
import { requestWebPage, savePage } from "./helpers";
|
|
||||||
|
|
||||||
const months = [
|
|
||||||
"Jan",
|
|
||||||
"Feb",
|
|
||||||
"Mar",
|
|
||||||
"Apr",
|
|
||||||
"May",
|
|
||||||
"Jun",
|
|
||||||
"Jul",
|
|
||||||
"Aug",
|
|
||||||
"Sep",
|
|
||||||
"Oct",
|
|
||||||
"Nov",
|
|
||||||
"Dec",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const request1337x = async (path: string) =>
|
|
||||||
requestWebPage(`https://1337xx.to${path}`);
|
|
||||||
|
|
||||||
const formatUploadDate = (str: string) => {
|
|
||||||
const date = new Date();
|
|
||||||
|
|
||||||
const [month, day, year] = str.split(" ");
|
|
||||||
|
|
||||||
date.setMonth(months.indexOf(month.replace(".", "")));
|
|
||||||
date.setDate(Number(day.substring(0, 2)));
|
|
||||||
date.setFullYear(Number("20" + year.replace("'", "")));
|
|
||||||
date.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
return date;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* TODO: $a will often be null */
|
|
||||||
const getTorrentDetails = async (path: string) => {
|
|
||||||
const response = await request1337x(path);
|
|
||||||
|
|
||||||
const { window } = new JSDOM(response);
|
|
||||||
const { document } = window;
|
|
||||||
|
|
||||||
const $a = window.document.querySelector(
|
|
||||||
".torrentdown1"
|
|
||||||
) as HTMLAnchorElement;
|
|
||||||
|
|
||||||
const $ul = Array.from(
|
|
||||||
document.querySelectorAll(".torrent-detail-page .list")
|
|
||||||
);
|
|
||||||
const [$firstColumn, $secondColumn] = $ul;
|
|
||||||
|
|
||||||
if (!$firstColumn || !$secondColumn) {
|
|
||||||
return { magnet: $a?.href };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [_$category, _$type, _$language, $totalSize] = $firstColumn.children;
|
|
||||||
const [_$downloads, _$lastChecked, $dateUploaded] = $secondColumn.children;
|
|
||||||
|
|
||||||
return {
|
|
||||||
magnet: $a?.href,
|
|
||||||
fileSize: $totalSize.querySelector("span")!.textContent,
|
|
||||||
uploadDate: formatUploadDate(
|
|
||||||
$dateUploaded.querySelector("span")!.textContent!
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTorrentListLastPage = async (user: string) => {
|
|
||||||
const response = await request1337x(`/user/${user}/1`);
|
|
||||||
|
|
||||||
const { window } = new JSDOM(response);
|
|
||||||
|
|
||||||
const $ul = window.document.querySelector(".pagination > ul");
|
|
||||||
|
|
||||||
if ($ul) {
|
|
||||||
const $li = Array.from($ul.querySelectorAll("li")).at(-1);
|
|
||||||
const text = $li?.textContent;
|
|
||||||
|
|
||||||
if (text === ">>") {
|
|
||||||
const $previousLi = Array.from($ul.querySelectorAll("li")).at(-2);
|
|
||||||
return Number($previousLi?.textContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Number(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractTorrentsFromDocument = async (
|
|
||||||
page: number,
|
|
||||||
user: string,
|
|
||||||
document: Document
|
|
||||||
) => {
|
|
||||||
const $trs = Array.from(document.querySelectorAll("tbody tr"));
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
$trs.map(async ($tr) => {
|
|
||||||
const $td = $tr.querySelector("td");
|
|
||||||
|
|
||||||
const [, $name] = Array.from($td!.querySelectorAll("a"));
|
|
||||||
const url = $name.href;
|
|
||||||
const title = $name.textContent ?? "";
|
|
||||||
|
|
||||||
const details = await getTorrentDetails(url);
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
magnet: details.magnet,
|
|
||||||
fileSize: details.fileSize ?? "N/A",
|
|
||||||
uploadDate: details.uploadDate ?? new Date(),
|
|
||||||
repacker: user,
|
|
||||||
page,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getNewRepacksFromUser = async (
|
|
||||||
user: string,
|
|
||||||
existingRepacks: Repack[],
|
|
||||||
page = 1
|
|
||||||
) => {
|
|
||||||
const response = await request1337x(`/user/${user}/${page}`);
|
|
||||||
const { window } = new JSDOM(response);
|
|
||||||
|
|
||||||
const repacks = await extractTorrentsFromDocument(
|
|
||||||
page,
|
|
||||||
user,
|
|
||||||
window.document
|
|
||||||
);
|
|
||||||
|
|
||||||
const newRepacks = repacks.filter(
|
|
||||||
(repack) =>
|
|
||||||
!existingRepacks.some(
|
|
||||||
(existingRepack) => existingRepack.title === repack.title
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!newRepacks.length) return;
|
|
||||||
|
|
||||||
await savePage(newRepacks);
|
|
||||||
|
|
||||||
return getNewRepacksFromUser(user, existingRepacks, page + 1);
|
|
||||||
};
|
|
|
@ -1,96 +0,0 @@
|
||||||
import { JSDOM, VirtualConsole } from "jsdom";
|
|
||||||
import { requestWebPage, savePage } from "./helpers";
|
|
||||||
import { Repack } from "@main/entity";
|
|
||||||
|
|
||||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
|
||||||
|
|
||||||
const virtualConsole = new VirtualConsole();
|
|
||||||
|
|
||||||
const getUploadDate = (document: Document) => {
|
|
||||||
const $modifiedTime = document.querySelector(
|
|
||||||
'[property="article:modified_time"]'
|
|
||||||
) as HTMLMetaElement;
|
|
||||||
if ($modifiedTime) return $modifiedTime.content;
|
|
||||||
|
|
||||||
const $publishedTime = document.querySelector(
|
|
||||||
'[property="article:published_time"]'
|
|
||||||
) as HTMLMetaElement;
|
|
||||||
return $publishedTime.content;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDownloadLink = (document: Document) => {
|
|
||||||
const $latestDownloadButton = document.querySelector(
|
|
||||||
".download-btn:not(.lightweight-accordion *)"
|
|
||||||
) as HTMLAnchorElement;
|
|
||||||
if ($latestDownloadButton) return $latestDownloadButton.href;
|
|
||||||
|
|
||||||
const $downloadButton = document.querySelector(
|
|
||||||
".download-btn"
|
|
||||||
) as HTMLAnchorElement;
|
|
||||||
if (!$downloadButton) return null;
|
|
||||||
|
|
||||||
return $downloadButton.href;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMagnet = (downloadLink: string) => {
|
|
||||||
if (downloadLink.startsWith("http")) {
|
|
||||||
const { searchParams } = new URL(downloadLink);
|
|
||||||
return Buffer.from(searchParams.get("url")!, "base64").toString("utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
return downloadLink;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGOGGame = async (url: string) => {
|
|
||||||
const data = await requestWebPage(url);
|
|
||||||
const { window } = new JSDOM(data, { virtualConsole });
|
|
||||||
|
|
||||||
const downloadLink = getDownloadLink(window.document);
|
|
||||||
if (!downloadLink) return null;
|
|
||||||
|
|
||||||
const $em = window.document.querySelector("p em");
|
|
||||||
if (!$em) return null;
|
|
||||||
const fileSize = $em.textContent!.split("Size: ").at(1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fileSize: fileSize ?? "N/A",
|
|
||||||
uploadDate: new Date(getUploadDate(window.document)),
|
|
||||||
repacker: "GOG",
|
|
||||||
magnet: getMagnet(downloadLink),
|
|
||||||
page: 1,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
|
|
||||||
const data = await requestWebPage(
|
|
||||||
"https://freegogpcgames.com/a-z-games-list/"
|
|
||||||
);
|
|
||||||
|
|
||||||
const { window } = new JSDOM(data, { virtualConsole });
|
|
||||||
|
|
||||||
const $uls = Array.from(window.document.querySelectorAll(".az-columns"));
|
|
||||||
|
|
||||||
for (const $ul of $uls) {
|
|
||||||
const repacks: QueryDeepPartialEntity<Repack>[] = [];
|
|
||||||
const $lis = Array.from($ul.querySelectorAll("li"));
|
|
||||||
|
|
||||||
for (const $li of $lis) {
|
|
||||||
const $a = $li.querySelector("a")!;
|
|
||||||
const href = $a.href;
|
|
||||||
|
|
||||||
const title = $a.textContent!.trim();
|
|
||||||
|
|
||||||
const gameExists = existingRepacks.some(
|
|
||||||
(existingRepack) => existingRepack.title === title
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!gameExists) {
|
|
||||||
const game = await getGOGGame(href);
|
|
||||||
|
|
||||||
if (game) repacks.push({ ...game, title });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repacks.length) await savePage(repacks);
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,40 +0,0 @@
|
||||||
import axios from "axios";
|
|
||||||
import UserAgent from "user-agents";
|
|
||||||
|
|
||||||
import type { Repack } from "@main/entity";
|
|
||||||
import { repackRepository } from "@main/repository";
|
|
||||||
|
|
||||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
|
||||||
|
|
||||||
export const savePage = async (repacks: QueryDeepPartialEntity<Repack>[]) =>
|
|
||||||
Promise.all(
|
|
||||||
repacks.map((repack) => repackRepository.insert(repack).catch(() => {}))
|
|
||||||
);
|
|
||||||
|
|
||||||
export const requestWebPage = async (url: string) => {
|
|
||||||
const userAgent = new UserAgent();
|
|
||||||
|
|
||||||
return axios
|
|
||||||
.get(url, {
|
|
||||||
headers: {
|
|
||||||
"User-Agent": userAgent.toString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((response) => response.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decodeNonUtf8Response = async (res: Response) => {
|
|
||||||
const contentType = res.headers.get("content-type");
|
|
||||||
if (!contentType) return res.text();
|
|
||||||
|
|
||||||
const charset = contentType.substring(contentType.indexOf("charset=") + 8);
|
|
||||||
|
|
||||||
const text = await res.arrayBuffer().then((ab) => {
|
|
||||||
const dataView = new DataView(ab);
|
|
||||||
const decoder = new TextDecoder(charset);
|
|
||||||
|
|
||||||
return decoder.decode(dataView);
|
|
||||||
});
|
|
||||||
|
|
||||||
return text;
|
|
||||||
};
|
|
|
@ -1,4 +0,0 @@
|
||||||
export * from "./1337x";
|
|
||||||
export * from "./xatab";
|
|
||||||
export * from "./gog";
|
|
||||||
export * from "./online-fix";
|
|
|
@ -1,157 +0,0 @@
|
||||||
import { Repack } from "@main/entity";
|
|
||||||
import { decodeNonUtf8Response, savePage } from "./helpers";
|
|
||||||
import { logger } from "../logger";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
|
|
||||||
import createWorker from "@main/workers/torrent-parser.worker?nodeWorker";
|
|
||||||
import { toMagnetURI } from "parse-torrent";
|
|
||||||
|
|
||||||
const worker = createWorker({});
|
|
||||||
|
|
||||||
import makeFetchCookie from "fetch-cookie";
|
|
||||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
|
||||||
import { formatBytes } from "@shared";
|
|
||||||
|
|
||||||
const ONLINE_FIX_URL = "https://online-fix.me/";
|
|
||||||
|
|
||||||
let totalPages = 1;
|
|
||||||
|
|
||||||
export const getNewRepacksFromOnlineFix = async (
|
|
||||||
existingRepacks: Repack[] = [],
|
|
||||||
page = 1,
|
|
||||||
cookieJar = new makeFetchCookie.toughCookie.CookieJar()
|
|
||||||
): Promise<void> => {
|
|
||||||
const hasCredentials =
|
|
||||||
import.meta.env.MAIN_VITE_ONLINEFIX_USERNAME &&
|
|
||||||
import.meta.env.MAIN_VITE_ONLINEFIX_PASSWORD;
|
|
||||||
if (!hasCredentials) return;
|
|
||||||
|
|
||||||
const http = makeFetchCookie(fetch, cookieJar);
|
|
||||||
|
|
||||||
if (page === 1) {
|
|
||||||
await http(ONLINE_FIX_URL);
|
|
||||||
|
|
||||||
const preLogin =
|
|
||||||
((await http("https://online-fix.me/engine/ajax/authtoken.php", {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"X-Requested-With": "XMLHttpRequest",
|
|
||||||
Referer: ONLINE_FIX_URL,
|
|
||||||
},
|
|
||||||
}).then((res) => res.json())) as {
|
|
||||||
field: string;
|
|
||||||
value: string;
|
|
||||||
}) || undefined;
|
|
||||||
|
|
||||||
if (!preLogin.field || !preLogin.value) return;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
login_name: import.meta.env.MAIN_VITE_ONLINEFIX_USERNAME,
|
|
||||||
login_password: import.meta.env.MAIN_VITE_ONLINEFIX_PASSWORD,
|
|
||||||
login: "submit",
|
|
||||||
[preLogin.field]: preLogin.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
await http(ONLINE_FIX_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Referer: ONLINE_FIX_URL,
|
|
||||||
Origin: ONLINE_FIX_URL,
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageParams = page > 1 ? `${`/page/${page}`}` : "";
|
|
||||||
|
|
||||||
const home = await http(`https://online-fix.me${pageParams}`).then((res) =>
|
|
||||||
decodeNonUtf8Response(res)
|
|
||||||
);
|
|
||||||
const document = new JSDOM(home).window.document;
|
|
||||||
|
|
||||||
const repacks: QueryDeepPartialEntity<Repack>[] = [];
|
|
||||||
const articles = Array.from(document.querySelectorAll(".news"));
|
|
||||||
|
|
||||||
if (page == 1) {
|
|
||||||
totalPages = Number(
|
|
||||||
document.querySelector("nav > a:nth-child(13)")?.textContent
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all(
|
|
||||||
articles.map(async (article) => {
|
|
||||||
const gameLink = article.querySelector("a")?.getAttribute("href");
|
|
||||||
if (!gameLink) return;
|
|
||||||
|
|
||||||
const gamePage = await http(gameLink).then((res) =>
|
|
||||||
decodeNonUtf8Response(res)
|
|
||||||
);
|
|
||||||
const gameDocument = new JSDOM(gamePage).window.document;
|
|
||||||
|
|
||||||
const torrentButtons = Array.from(
|
|
||||||
gameDocument.querySelectorAll("a")
|
|
||||||
).filter((a) => a.textContent?.includes("Torrent"));
|
|
||||||
|
|
||||||
const torrentPrePage = torrentButtons[0]?.getAttribute("href");
|
|
||||||
if (!torrentPrePage) return;
|
|
||||||
|
|
||||||
const torrentPage = await http(torrentPrePage, {
|
|
||||||
headers: {
|
|
||||||
Referer: gameLink,
|
|
||||||
},
|
|
||||||
}).then((res) => res.text());
|
|
||||||
|
|
||||||
const torrentDocument = new JSDOM(torrentPage).window.document;
|
|
||||||
|
|
||||||
const torrentLink = torrentDocument
|
|
||||||
.querySelector("a:nth-child(2)")
|
|
||||||
?.getAttribute("href");
|
|
||||||
|
|
||||||
const torrentFile = Buffer.from(
|
|
||||||
await http(`${torrentPrePage}${torrentLink}`).then((res) =>
|
|
||||||
res.arrayBuffer()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
worker.once("message", (torrent) => {
|
|
||||||
if (!torrent) return;
|
|
||||||
|
|
||||||
const { name, created } = torrent;
|
|
||||||
|
|
||||||
repacks.push({
|
|
||||||
fileSize: formatBytes(torrent.length ?? 0),
|
|
||||||
magnet: toMagnetURI(torrent),
|
|
||||||
page: 1,
|
|
||||||
repacker: "onlinefix",
|
|
||||||
title: name,
|
|
||||||
uploadDate: created,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
worker.postMessage(torrentFile);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
logger.error((err as Error).message, {
|
|
||||||
method: "getNewRepacksFromOnlineFix",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const newRepacks = repacks.filter(
|
|
||||||
(repack) =>
|
|
||||||
repack.uploadDate &&
|
|
||||||
!existingRepacks.some(
|
|
||||||
(existingRepack) => existingRepack.title === repack.title
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!newRepacks.length) return;
|
|
||||||
|
|
||||||
await savePage(newRepacks);
|
|
||||||
|
|
||||||
if (page === totalPages) return;
|
|
||||||
|
|
||||||
return getNewRepacksFromOnlineFix(existingRepacks, page + 1, cookieJar);
|
|
||||||
};
|
|
|
@ -1,120 +0,0 @@
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
|
|
||||||
import { Repack } from "@main/entity";
|
|
||||||
import { logger } from "../logger";
|
|
||||||
import { requestWebPage, savePage } from "./helpers";
|
|
||||||
|
|
||||||
import createWorker from "@main/workers/torrent-parser.worker?nodeWorker";
|
|
||||||
import { toMagnetURI } from "parse-torrent";
|
|
||||||
import type { Instance } from "parse-torrent";
|
|
||||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
|
||||||
import { formatBytes } from "@shared";
|
|
||||||
import { getFileBuffer } from "@main/helpers";
|
|
||||||
|
|
||||||
const worker = createWorker({});
|
|
||||||
worker.setMaxListeners(11);
|
|
||||||
|
|
||||||
let totalPages = 1;
|
|
||||||
|
|
||||||
const formatXatabDate = (str: string) => {
|
|
||||||
const date = new Date();
|
|
||||||
|
|
||||||
const [day, month, year] = str.split(".");
|
|
||||||
|
|
||||||
date.setDate(Number(day));
|
|
||||||
date.setMonth(Number(month) - 1);
|
|
||||||
date.setFullYear(Number(year));
|
|
||||||
date.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
return date;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getXatabRepack = (
|
|
||||||
url: string
|
|
||||||
): Promise<{ fileSize: string; magnet: string; uploadDate: Date } | null> => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
(async () => {
|
|
||||||
const data = await requestWebPage(url);
|
|
||||||
const { window } = new JSDOM(data);
|
|
||||||
const { document } = window;
|
|
||||||
|
|
||||||
const $uploadDate = document.querySelector(".entry__date");
|
|
||||||
|
|
||||||
const $downloadButton = document.querySelector(
|
|
||||||
".download-torrent"
|
|
||||||
) as HTMLAnchorElement;
|
|
||||||
|
|
||||||
if (!$downloadButton) return resolve(null);
|
|
||||||
|
|
||||||
worker.once("message", (torrent: Instance | null) => {
|
|
||||||
if (!torrent) return resolve(null);
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
fileSize: formatBytes(torrent.length ?? 0),
|
|
||||||
magnet: toMagnetURI(torrent),
|
|
||||||
uploadDate: formatXatabDate($uploadDate!.textContent!),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const buffer = await getFileBuffer($downloadButton.href);
|
|
||||||
worker.postMessage(buffer);
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getNewRepacksFromXatab = async (
|
|
||||||
existingRepacks: Repack[] = [],
|
|
||||||
page = 1
|
|
||||||
): Promise<void> => {
|
|
||||||
const data = await requestWebPage(`https://byxatab.com/page/${page}`);
|
|
||||||
|
|
||||||
const { window } = new JSDOM(data);
|
|
||||||
|
|
||||||
const repacks: QueryDeepPartialEntity<Repack>[] = [];
|
|
||||||
|
|
||||||
if (page === 1) {
|
|
||||||
totalPages = Number(
|
|
||||||
window.document.querySelector(
|
|
||||||
"#bottom-nav > div.pagination > a:nth-child(12)"
|
|
||||||
)?.textContent
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const repacksFromPage = Array.from(
|
|
||||||
window.document.querySelectorAll(".entry__title a")
|
|
||||||
).map(($a) => {
|
|
||||||
return getXatabRepack(($a as HTMLAnchorElement).href)
|
|
||||||
.then((repack) => {
|
|
||||||
if (repack) {
|
|
||||||
repacks.push({
|
|
||||||
title: $a.textContent!,
|
|
||||||
repacker: "Xatab",
|
|
||||||
...repack,
|
|
||||||
page,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
logger.error((err as Error).message, {
|
|
||||||
method: "getNewRepacksFromXatab",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(repacksFromPage);
|
|
||||||
|
|
||||||
const newRepacks = repacks.filter(
|
|
||||||
(repack) =>
|
|
||||||
!existingRepacks.some(
|
|
||||||
(existingRepack) => existingRepack.title === repack.title
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!newRepacks.length) return;
|
|
||||||
|
|
||||||
await savePage(newRepacks);
|
|
||||||
|
|
||||||
if (page === totalPages) return;
|
|
||||||
|
|
||||||
return getNewRepacksFromXatab(existingRepacks, page + 1);
|
|
||||||
};
|
|
44
src/main/services/repacks-manager.ts
Normal file
44
src/main/services/repacks-manager.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { repackRepository } from "@main/repository";
|
||||||
|
import { formatName } from "@shared";
|
||||||
|
import { CatalogueEntry, GameRepack } from "@types";
|
||||||
|
import flexSearch from "flexsearch";
|
||||||
|
|
||||||
|
export class RepacksManager {
|
||||||
|
public static repacks: GameRepack[] = [];
|
||||||
|
private static repacksIndex = new flexSearch.Index();
|
||||||
|
|
||||||
|
public static async updateRepacks() {
|
||||||
|
this.repacks = await repackRepository.find({
|
||||||
|
order: {
|
||||||
|
createdAt: "DESC",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < this.repacks.length; i++) {
|
||||||
|
this.repacksIndex.remove(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.repacksIndex = new flexSearch.Index();
|
||||||
|
|
||||||
|
for (let i = 0; i < this.repacks.length; i++) {
|
||||||
|
const repack = this.repacks[i];
|
||||||
|
|
||||||
|
const formattedTitle = formatName(repack.title);
|
||||||
|
|
||||||
|
this.repacksIndex.add(i, formattedTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static search(options: flexSearch.SearchOptions) {
|
||||||
|
return this.repacksIndex
|
||||||
|
.search({ ...options, query: formatName(options.query ?? "") })
|
||||||
|
.map((index) => this.repacks[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static findRepacksForCatalogueEntries(entries: CatalogueEntry[]) {
|
||||||
|
return entries.map((entry) => {
|
||||||
|
const repacks = this.search({ query: formatName(entry.title) });
|
||||||
|
return { ...entry, repacks };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,6 +30,6 @@ export const getSteamAppDetails = async (
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
logger.error(err, { method: "getSteamAppDetails" });
|
logger.error(err, { method: "getSteamAppDetails" });
|
||||||
throw new Error(err);
|
return null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import { app } from "electron";
|
|
||||||
|
|
||||||
import { chunk } from "lodash-es";
|
|
||||||
|
|
||||||
import { createDataSource } from "@main/data-source";
|
|
||||||
import { Repack } from "@main/entity";
|
|
||||||
import { repackRepository } from "@main/repository";
|
|
||||||
|
|
||||||
export const resolveDatabaseUpdates = async () => {
|
|
||||||
const updateDataSource = createDataSource({
|
|
||||||
database: app.isPackaged
|
|
||||||
? path.join(process.resourcesPath, "hydra.db")
|
|
||||||
: path.join(__dirname, "..", "..", "hydra.db"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return updateDataSource.initialize().then(async () => {
|
|
||||||
const updateRepackRepository = updateDataSource.getRepository(Repack);
|
|
||||||
|
|
||||||
const updateRepacks = await updateRepackRepository.find();
|
|
||||||
|
|
||||||
const updateRepacksChunks = chunk(updateRepacks, 800);
|
|
||||||
|
|
||||||
for (const chunk of updateRepacksChunks) {
|
|
||||||
await repackRepository
|
|
||||||
.createQueryBuilder()
|
|
||||||
.insert()
|
|
||||||
.values(chunk)
|
|
||||||
.orIgnore()
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
MenuItemConstructorOptions,
|
MenuItemConstructorOptions,
|
||||||
Tray,
|
Tray,
|
||||||
app,
|
app,
|
||||||
|
nativeImage,
|
||||||
shell,
|
shell,
|
||||||
} from "electron";
|
} from "electron";
|
||||||
import { is } from "@electron-toolkit/utils";
|
import { is } from "@electron-toolkit/utils";
|
||||||
|
@ -88,7 +89,16 @@ export class WindowManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static createSystemTray(language: string) {
|
public static createSystemTray(language: string) {
|
||||||
const tray = new Tray(trayIcon);
|
let tray;
|
||||||
|
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
const macIcon = nativeImage
|
||||||
|
.createFromPath(trayIcon)
|
||||||
|
.resize({ width: 24, height: 24 });
|
||||||
|
tray = new Tray(macIcon);
|
||||||
|
} else {
|
||||||
|
tray = new Tray(trayIcon);
|
||||||
|
}
|
||||||
|
|
||||||
const updateSystemTray = async () => {
|
const updateSystemTray = async () => {
|
||||||
const games = await gameRepository.find({
|
const games = await gameRepository.find({
|
||||||
|
@ -149,9 +159,14 @@ export class WindowManager {
|
||||||
return contextMenu;
|
return contextMenu;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showContextMenu = async () => {
|
||||||
|
const contextMenu = await updateSystemTray();
|
||||||
|
tray.popUpContextMenu(contextMenu);
|
||||||
|
};
|
||||||
|
|
||||||
tray.setToolTip("Hydra");
|
tray.setToolTip("Hydra");
|
||||||
|
|
||||||
if (process.platform === "win32" || process.platform === "linux") {
|
if (process.platform !== "darwin") {
|
||||||
tray.addListener("click", () => {
|
tray.addListener("click", () => {
|
||||||
if (this.mainWindow) {
|
if (this.mainWindow) {
|
||||||
if (WindowManager.mainWindow?.isMinimized())
|
if (WindowManager.mainWindow?.isMinimized())
|
||||||
|
@ -164,10 +179,10 @@ export class WindowManager {
|
||||||
this.createMainWindow();
|
this.createMainWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
tray.addListener("right-click", async () => {
|
tray.addListener("right-click", showContextMenu);
|
||||||
const contextMenu = await updateSystemTray();
|
} else {
|
||||||
tray.popUpContextMenu(contextMenu);
|
tray.addListener("click", showContextMenu);
|
||||||
});
|
tray.addListener("right-click", showContextMenu);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
import type { Repack } from "@main/entity";
|
|
||||||
import type { SteamGame } from "@types";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
repacks: Repack[];
|
|
||||||
steamGames: SteamGame[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: State = {
|
|
||||||
repacks: [],
|
|
||||||
steamGames: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export class StateManager {
|
|
||||||
private state = initialState;
|
|
||||||
|
|
||||||
public setValue<T extends keyof State>(key: T, value: State[T]) {
|
|
||||||
this.state = { ...this.state, [key]: value };
|
|
||||||
}
|
|
||||||
|
|
||||||
public getValue<T extends keyof State>(key: T) {
|
|
||||||
return this.state[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
public clearValue<T extends keyof State>(key: T) {
|
|
||||||
this.state = { ...this.state, [key]: initialState[key] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const stateManager = new StateManager();
|
|
50
src/main/workers/download-source.worker.ts
Normal file
50
src/main/workers/download-source.worker.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { downloadSourceSchema } from "@main/events/helpers/validators";
|
||||||
|
import { DownloadSourceStatus } from "@shared";
|
||||||
|
import type { DownloadSource } from "@types";
|
||||||
|
import axios, { AxiosError, AxiosHeaders } from "axios";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type DownloadSourceResponse = z.infer<typeof downloadSourceSchema> & {
|
||||||
|
etag: string | null;
|
||||||
|
status: DownloadSourceStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
|
||||||
|
const results: DownloadSourceResponse[] = [];
|
||||||
|
|
||||||
|
for (const downloadSource of downloadSources) {
|
||||||
|
const headers = new AxiosHeaders();
|
||||||
|
|
||||||
|
if (downloadSource.etag) {
|
||||||
|
headers.set("If-None-Match", downloadSource.etag);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(downloadSource.url, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const source = downloadSourceSchema.parse(response.data);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
...downloadSource,
|
||||||
|
downloads: source.downloads,
|
||||||
|
etag: response.headers["etag"],
|
||||||
|
status: DownloadSourceStatus.UpToDate,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const isNotModified = (err as AxiosError).response?.status === 304;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
...downloadSource,
|
||||||
|
downloads: [],
|
||||||
|
etag: null,
|
||||||
|
status: isNotModified
|
||||||
|
? DownloadSourceStatus.UpToDate
|
||||||
|
: DownloadSourceStatus.Errored,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
18
src/main/workers/index.ts
Normal file
18
src/main/workers/index.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import steamGamesWorkerPath from "./steam-games.worker?modulePath";
|
||||||
|
import downloadSourceWorkerPath from "./download-source.worker?modulePath";
|
||||||
|
|
||||||
|
import Piscina from "piscina";
|
||||||
|
|
||||||
|
import { seedsPath } from "@main/constants";
|
||||||
|
|
||||||
|
export const steamGamesWorker = new Piscina({
|
||||||
|
filename: steamGamesWorkerPath,
|
||||||
|
workerData: {
|
||||||
|
steamGamesPath: path.join(seedsPath, "steam-games.json"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const downloadSourceWorker = new Piscina({
|
||||||
|
filename: downloadSourceWorkerPath,
|
||||||
|
});
|
38
src/main/workers/steam-games.worker.ts
Normal file
38
src/main/workers/steam-games.worker.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { SteamGame } from "@types";
|
||||||
|
import { orderBy, slice } from "lodash-es";
|
||||||
|
import flexSearch from "flexsearch";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
import { formatName } from "@shared";
|
||||||
|
import { workerData } from "node:worker_threads";
|
||||||
|
|
||||||
|
const steamGamesIndex = new flexSearch.Index({
|
||||||
|
tokenize: "reverse",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { steamGamesPath } = workerData;
|
||||||
|
|
||||||
|
const data = fs.readFileSync(steamGamesPath, "utf-8");
|
||||||
|
|
||||||
|
const steamGames = JSON.parse(data) as SteamGame[];
|
||||||
|
|
||||||
|
for (let i = 0; i < steamGames.length; i++) {
|
||||||
|
const steamGame = steamGames[i];
|
||||||
|
|
||||||
|
const formattedName = formatName(steamGame.name);
|
||||||
|
|
||||||
|
steamGamesIndex.add(i, formattedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const search = (options: flexSearch.SearchOptions) => {
|
||||||
|
const results = steamGamesIndex.search(options);
|
||||||
|
const games = results.map((index) => steamGames[index]);
|
||||||
|
|
||||||
|
return orderBy(games, ["name"], ["asc"]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getById = (id: number) =>
|
||||||
|
steamGames.find((game) => game.id === id);
|
||||||
|
|
||||||
|
export const list = ({ limit, offset }: { limit: number; offset: number }) =>
|
||||||
|
slice(steamGames, offset, offset + limit);
|
|
@ -1,14 +0,0 @@
|
||||||
import { parentPort } from "worker_threads";
|
|
||||||
import parseTorrent from "parse-torrent";
|
|
||||||
|
|
||||||
const port = parentPort;
|
|
||||||
if (!port) throw new Error("IllegalState");
|
|
||||||
|
|
||||||
port.on("message", async (buffer: Buffer) => {
|
|
||||||
try {
|
|
||||||
const torrent = await parseTorrent(buffer);
|
|
||||||
port.postMessage(torrent);
|
|
||||||
} catch (err) {
|
|
||||||
port.postMessage(null);
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -3,7 +3,6 @@
|
||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CatalogueCategory,
|
|
||||||
GameShop,
|
GameShop,
|
||||||
DownloadProgress,
|
DownloadProgress,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
|
@ -32,8 +31,7 @@ contextBridge.exposeInMainWorld("electron", {
|
||||||
|
|
||||||
/* Catalogue */
|
/* Catalogue */
|
||||||
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
||||||
getCatalogue: (category: CatalogueCategory) =>
|
getCatalogue: () => ipcRenderer.invoke("getCatalogue"),
|
||||||
ipcRenderer.invoke("getCatalogue", category),
|
|
||||||
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
|
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
|
||||||
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
|
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
|
||||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||||
|
@ -52,23 +50,30 @@ contextBridge.exposeInMainWorld("electron", {
|
||||||
authenticateRealDebrid: (apiToken: string) =>
|
authenticateRealDebrid: (apiToken: string) =>
|
||||||
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
|
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
|
||||||
|
|
||||||
|
/* Download sources */
|
||||||
|
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
|
||||||
|
validateDownloadSource: (url: string) =>
|
||||||
|
ipcRenderer.invoke("validateDownloadSource", url),
|
||||||
|
addDownloadSource: (url: string) =>
|
||||||
|
ipcRenderer.invoke("addDownloadSource", url),
|
||||||
|
removeDownloadSource: (id: number) =>
|
||||||
|
ipcRenderer.invoke("removeDownloadSource", id),
|
||||||
|
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
|
||||||
|
|
||||||
/* Library */
|
/* Library */
|
||||||
addGameToLibrary: (
|
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
|
||||||
objectID: string,
|
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
|
||||||
title: string,
|
createGameShortcut: (id: number) =>
|
||||||
shop: GameShop,
|
ipcRenderer.invoke("createGameShortcut", id),
|
||||||
executablePath: string
|
updateExecutablePath: (id: number, executablePath: string) =>
|
||||||
) =>
|
ipcRenderer.invoke("updateExecutablePath", id, executablePath),
|
||||||
ipcRenderer.invoke(
|
|
||||||
"addGameToLibrary",
|
|
||||||
objectID,
|
|
||||||
title,
|
|
||||||
shop,
|
|
||||||
executablePath
|
|
||||||
),
|
|
||||||
getLibrary: () => ipcRenderer.invoke("getLibrary"),
|
getLibrary: () => ipcRenderer.invoke("getLibrary"),
|
||||||
openGameInstaller: (gameId: number) =>
|
openGameInstaller: (gameId: number) =>
|
||||||
ipcRenderer.invoke("openGameInstaller", gameId),
|
ipcRenderer.invoke("openGameInstaller", gameId),
|
||||||
|
openGameInstallerPath: (gameId: number) =>
|
||||||
|
ipcRenderer.invoke("openGameInstallerPath", gameId),
|
||||||
|
openGameExecutablePath: (gameId: number) =>
|
||||||
|
ipcRenderer.invoke("openGameExecutablePath", gameId),
|
||||||
openGame: (gameId: number, executablePath: string) =>
|
openGame: (gameId: number, executablePath: string) =>
|
||||||
ipcRenderer.invoke("openGame", gameId, executablePath),
|
ipcRenderer.invoke("openGame", gameId, executablePath),
|
||||||
closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),
|
closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),
|
||||||
|
|
|
@ -7,6 +7,7 @@ globalStyle("*", {
|
||||||
|
|
||||||
globalStyle("::-webkit-scrollbar", {
|
globalStyle("::-webkit-scrollbar", {
|
||||||
width: "9px",
|
width: "9px",
|
||||||
|
backgroundColor: vars.color.darkBackground,
|
||||||
});
|
});
|
||||||
|
|
||||||
globalStyle("::-webkit-scrollbar-track", {
|
globalStyle("::-webkit-scrollbar-track", {
|
||||||
|
|
|
@ -10,7 +10,6 @@ import {
|
||||||
} from "@renderer/hooks";
|
} from "@renderer/hooks";
|
||||||
|
|
||||||
import * as styles from "./app.css";
|
import * as styles from "./app.css";
|
||||||
import { themeClass } from "./theme.css";
|
|
||||||
|
|
||||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
@ -21,8 +20,6 @@ import {
|
||||||
closeToast,
|
closeToast,
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
|
|
||||||
document.body.classList.add(themeClass);
|
|
||||||
|
|
||||||
export interface AppProps {
|
export interface AppProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { keyframes } from "@vanilla-extract/css";
|
import { keyframes } from "@vanilla-extract/css";
|
||||||
import { recipe } from "@vanilla-extract/recipes";
|
import { recipe } from "@vanilla-extract/recipes";
|
||||||
|
|
||||||
import { SPACING_UNIT } from "../../theme.css";
|
import { SPACING_UNIT } from "../../theme.css";
|
||||||
|
|
||||||
export const backdropFadeIn = keyframes({
|
export const backdropFadeIn = keyframes({
|
||||||
|
|
13
src/renderer/src/components/badge/badge.css.ts
Normal file
13
src/renderer/src/components/badge/badge.css.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
|
import { SPACING_UNIT } from "../../theme.css";
|
||||||
|
|
||||||
|
export const badge = style({
|
||||||
|
color: "#c0c1c7",
|
||||||
|
fontSize: "10px",
|
||||||
|
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
|
||||||
|
border: "solid 1px #c0c1c7",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
});
|
14
src/renderer/src/components/badge/badge.tsx
Normal file
14
src/renderer/src/components/badge/badge.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import React from "react";
|
||||||
|
import * as styles from "./badge.css";
|
||||||
|
|
||||||
|
export interface BadgeProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ children }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.badge}>
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
|
||||||
export const bottomPanel = style({
|
export const bottomPanel = style({
|
||||||
|
|
|
@ -55,4 +55,15 @@ export const button = styleVariants({
|
||||||
color: "#c0c1c7",
|
color: "#c0c1c7",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
danger: [
|
||||||
|
base,
|
||||||
|
{
|
||||||
|
border: `solid 1px #a31533`,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "white",
|
||||||
|
":hover": {
|
||||||
|
backgroundColor: "#a31533",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
|
||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
|
||||||
export const checkboxField = style({
|
export const checkboxField = style({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
|
||||||
export const card = style({
|
export const card = style({
|
||||||
|
@ -69,16 +70,7 @@ export const downloadOptions = style({
|
||||||
padding: "0",
|
padding: "0",
|
||||||
gap: `${SPACING_UNIT}px`,
|
gap: `${SPACING_UNIT}px`,
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
});
|
listStyle: "none",
|
||||||
|
|
||||||
export const downloadOption = style({
|
|
||||||
color: "#c0c1c7",
|
|
||||||
fontSize: "10px",
|
|
||||||
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
|
|
||||||
border: "solid 1px #c0c1c7",
|
|
||||||
borderRadius: "4px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const specifics = style({
|
export const specifics = style({
|
||||||
|
|
|
@ -5,6 +5,7 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
|
|
||||||
import * as styles from "./game-card.css";
|
import * as styles from "./game-card.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "../badge/badge";
|
||||||
|
|
||||||
export interface GameCardProps
|
export interface GameCardProps
|
||||||
extends React.DetailedHTMLProps<
|
extends React.DetailedHTMLProps<
|
||||||
|
@ -39,8 +40,8 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||||
{uniqueRepackers.length > 0 ? (
|
{uniqueRepackers.length > 0 ? (
|
||||||
<ul className={styles.downloadOptions}>
|
<ul className={styles.downloadOptions}>
|
||||||
{uniqueRepackers.map((repacker) => (
|
{uniqueRepackers.map((repacker) => (
|
||||||
<li key={repacker} className={styles.downloadOption}>
|
<li key={repacker}>
|
||||||
<span>{repacker}</span>
|
<Badge>{repacker}</Badge>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { SyncIcon } from "@primer/octicons-react";
|
||||||
|
import { Link } from "../link/link";
|
||||||
|
import * as styles from "./header.css";
|
||||||
|
import { AppUpdaterEvent } from "@types";
|
||||||
|
|
||||||
|
export const releasesPageUrl =
|
||||||
|
"https://github.com/hydralauncher/hydra/releases/latest";
|
||||||
|
|
||||||
|
export function AutoUpdateSubHeader() {
|
||||||
|
const [isReadyToInstall, setIsReadyToInstall] = useState(false);
|
||||||
|
const [newVersion, setNewVersion] = useState<string | null>(null);
|
||||||
|
const [isAutoInstallAvailable, setIsAutoInstallAvailable] = useState(false);
|
||||||
|
|
||||||
|
const { t } = useTranslation("header");
|
||||||
|
|
||||||
|
const handleClickInstallUpdate = () => {
|
||||||
|
window.electron.restartAndInstallUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electron.onAutoUpdaterEvent(
|
||||||
|
(event: AppUpdaterEvent) => {
|
||||||
|
if (event.type == "update-available") {
|
||||||
|
setNewVersion(event.info.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type == "update-downloaded") {
|
||||||
|
setIsReadyToInstall(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
window.electron.checkForUpdates().then((isAutoInstallAvailable) => {
|
||||||
|
setIsAutoInstallAvailable(isAutoInstallAvailable);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!newVersion) return null;
|
||||||
|
|
||||||
|
if (!isAutoInstallAvailable) {
|
||||||
|
return (
|
||||||
|
<header className={styles.subheader}>
|
||||||
|
<Link to={releasesPageUrl} className={styles.newVersionLink}>
|
||||||
|
<SyncIcon size={12} />
|
||||||
|
<small>
|
||||||
|
{t("version_available_download", { version: newVersion })}
|
||||||
|
</small>
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadyToInstall) {
|
||||||
|
return (
|
||||||
|
<header className={styles.subheader}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.newVersionButton}
|
||||||
|
onClick={handleClickInstallUpdate}
|
||||||
|
>
|
||||||
|
<SyncIcon size={12} />
|
||||||
|
<small>
|
||||||
|
{t("version_available_install", { version: newVersion })}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
|
@ -157,9 +157,17 @@ export const newVersionButton = style({
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: `${SPACING_UNIT}px`,
|
gap: `${SPACING_UNIT}px`,
|
||||||
color: vars.color.body,
|
color: vars.color.body,
|
||||||
borderBottom: "1px solid transparent",
|
fontSize: "13px",
|
||||||
":hover": {
|
":hover": {
|
||||||
borderBottom: `1px solid ${vars.color.body}`,
|
textDecoration: "underline",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const newVersionLink = style({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
color: "#8e919b",
|
||||||
|
fontSize: "13px",
|
||||||
|
});
|
||||||
|
|
|
@ -1,18 +1,13 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||||
ArrowLeftIcon,
|
|
||||||
SearchIcon,
|
|
||||||
SyncIcon,
|
|
||||||
XIcon,
|
|
||||||
} from "@primer/octicons-react";
|
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
||||||
|
|
||||||
import * as styles from "./header.css";
|
import * as styles from "./header.css";
|
||||||
import { clearSearch } from "@renderer/features";
|
import { clearSearch } from "@renderer/features";
|
||||||
import { AppUpdaterEvents } from "@types";
|
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
||||||
|
|
||||||
export interface HeaderProps {
|
export interface HeaderProps {
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
|
@ -40,9 +35,6 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||||
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
const [showUpdateSubheader, setShowUpdateSubheader] = useState(false);
|
|
||||||
const [newVersion, setNewVersion] = useState("");
|
|
||||||
|
|
||||||
const { t } = useTranslation("header");
|
const { t } = useTranslation("header");
|
||||||
|
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
|
@ -58,30 +50,6 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||||
}
|
}
|
||||||
}, [location.pathname, search, dispatch]);
|
}, [location.pathname, search, dispatch]);
|
||||||
|
|
||||||
const handleClickRestartAndUpdate = () => {
|
|
||||||
window.electron.restartAndInstallUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = window.electron.onAutoUpdaterEvent(
|
|
||||||
(event: AppUpdaterEvents) => {
|
|
||||||
if (event.type == "update-available") {
|
|
||||||
setNewVersion(event.info.version || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type == "update-downloaded") {
|
|
||||||
setShowUpdateSubheader(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
window.electron.checkForUpdates();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const focusInput = () => {
|
const focusInput = () => {
|
||||||
setIsFocused(true);
|
setIsFocused(true);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
|
@ -103,7 +71,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||||
isWindows: window.electron.platform === "win32",
|
isWindows: window.electron.platform === "win32",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className={styles.section}>
|
<section className={styles.section}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.backButton({
|
className={styles.backButton({
|
||||||
|
@ -122,7 +90,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<div className={styles.search({ focused: isFocused })}>
|
<div className={styles.search({ focused: isFocused })}>
|
||||||
|
@ -158,18 +126,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</header>
|
</header>
|
||||||
{showUpdateSubheader && (
|
<AutoUpdateSubHeader />
|
||||||
<header className={styles.subheader}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.newVersionButton}
|
|
||||||
onClick={handleClickRestartAndUpdate}
|
|
||||||
>
|
|
||||||
<SyncIcon size={12} />
|
|
||||||
<small>{t("version_available", { version: newVersion })}</small>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
|
||||||
export const hero = style({
|
export const hero = style({
|
||||||
|
@ -23,6 +24,7 @@ export const heroMedia = style({
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
transition: "all ease 0.2s",
|
transition: "all ease 0.2s",
|
||||||
|
imageRendering: "revert",
|
||||||
selectors: {
|
selectors: {
|
||||||
[`${hero}:hover &`]: {
|
[`${hero}:hover &`]: {
|
||||||
transform: "scale(1.02)",
|
transform: "scale(1.02)",
|
||||||
|
|
|
@ -54,7 +54,7 @@ export function Hero() {
|
||||||
>
|
>
|
||||||
<div className={styles.backdrop}>
|
<div className={styles.backdrop}>
|
||||||
<img
|
<img
|
||||||
src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg"
|
src={steamUrlBuilder.libraryHero(FEATURED_GAME_ID)}
|
||||||
alt={FEATURED_GAME_TITLE}
|
alt={FEATURED_GAME_TITLE}
|
||||||
className={styles.heroMedia}
|
className={styles.heroMedia}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -8,5 +8,6 @@ export * from "./sidebar/sidebar";
|
||||||
export * from "./text-field/text-field";
|
export * from "./text-field/text-field";
|
||||||
export * from "./checkbox-field/checkbox-field";
|
export * from "./checkbox-field/checkbox-field";
|
||||||
export * from "./link/link";
|
export * from "./link/link";
|
||||||
export * from "./select/select";
|
export * from "./select-field/select-field";
|
||||||
export * from "./toast/toast";
|
export * from "./toast/toast";
|
||||||
|
export * from "./badge/badge";
|
||||||
|
|
|
@ -1,27 +1,29 @@
|
||||||
import { keyframes, style } from "@vanilla-extract/css";
|
import { keyframes, style } from "@vanilla-extract/css";
|
||||||
import { recipe } from "@vanilla-extract/recipes";
|
import { recipe } from "@vanilla-extract/recipes";
|
||||||
|
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
|
||||||
export const fadeIn = keyframes({
|
export const scaleFadeIn = keyframes({
|
||||||
"0%": { opacity: 0 },
|
"0%": { opacity: "0", scale: "0.5" },
|
||||||
"100%": {
|
"100%": {
|
||||||
opacity: 1,
|
opacity: "1",
|
||||||
|
scale: "1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fadeOut = keyframes({
|
export const scaleFadeOut = keyframes({
|
||||||
"0%": { opacity: 1 },
|
"0%": { opacity: "1", scale: "1" },
|
||||||
"100%": {
|
"100%": {
|
||||||
opacity: 0,
|
opacity: "0",
|
||||||
|
scale: "0.5",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const modal = recipe({
|
export const modal = recipe({
|
||||||
base: {
|
base: {
|
||||||
animationName: fadeIn,
|
animation: `${scaleFadeIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
|
||||||
animationDuration: "0.3s",
|
|
||||||
backgroundColor: vars.color.background,
|
backgroundColor: vars.color.background,
|
||||||
borderRadius: "5px",
|
borderRadius: "4px",
|
||||||
maxWidth: "600px",
|
maxWidth: "600px",
|
||||||
color: vars.color.body,
|
color: vars.color.body,
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
|
@ -33,8 +35,14 @@ export const modal = recipe({
|
||||||
variants: {
|
variants: {
|
||||||
closing: {
|
closing: {
|
||||||
true: {
|
true: {
|
||||||
animationName: fadeOut,
|
animationName: scaleFadeOut,
|
||||||
opacity: 0,
|
opacity: "0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
large: {
|
||||||
|
true: {
|
||||||
|
width: "800px",
|
||||||
|
maxWidth: "800px",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -12,7 +12,9 @@ export interface ModalProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
large?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
clickOutsideToClose?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({
|
export function Modal({
|
||||||
|
@ -20,7 +22,9 @@ export function Modal({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
onClose,
|
onClose,
|
||||||
|
large,
|
||||||
children,
|
children,
|
||||||
|
clickOutsideToClose = true,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const modalContentRef = useRef<HTMLDivElement | null>(null);
|
const modalContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
@ -58,6 +62,18 @@ export function Modal({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {};
|
||||||
|
}, [handleCloseClick, visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clickOutsideToClose) {
|
||||||
const onMouseDown = (e: MouseEvent) => {
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
if (!isTopMostModal()) return;
|
if (!isTopMostModal()) return;
|
||||||
if (modalContentRef.current) {
|
if (modalContentRef.current) {
|
||||||
|
@ -71,24 +87,22 @@ export function Modal({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
window.addEventListener("mousedown", onMouseDown);
|
window.addEventListener("mousedown", onMouseDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
|
||||||
window.removeEventListener("mousedown", onMouseDown);
|
window.removeEventListener("mousedown", onMouseDown);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {};
|
return () => {};
|
||||||
}, [handleCloseClick, visible]);
|
}, [clickOutsideToClose, handleCloseClick]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<Backdrop isClosing={isClosing}>
|
<Backdrop isClosing={isClosing}>
|
||||||
<div
|
<div
|
||||||
className={styles.modal({ closing: isClosing })}
|
className={styles.modal({ closing: isClosing, large })}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby={title}
|
aria-labelledby={title}
|
||||||
aria-describedby={description}
|
aria-describedby={description}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
|
||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
import { recipe } from "@vanilla-extract/recipes";
|
import { recipe } from "@vanilla-extract/recipes";
|
||||||
|
|
||||||
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
|
||||||
export const select = recipe({
|
export const select = recipe({
|
||||||
base: {
|
base: {
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
|
@ -49,9 +50,6 @@ export const option = style({
|
||||||
fontSize: vars.size.body,
|
fontSize: vars.size.body,
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
padding: `${SPACING_UNIT}px`,
|
padding: `${SPACING_UNIT}px`,
|
||||||
":focus": {
|
|
||||||
cursor: "text",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const label = style({
|
export const label = style({
|
|
@ -1,6 +1,6 @@
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import type { RecipeVariants } from "@vanilla-extract/recipes";
|
import type { RecipeVariants } from "@vanilla-extract/recipes";
|
||||||
import * as styles from "./select.css";
|
import * as styles from "./select-field.css";
|
||||||
|
|
||||||
export interface SelectProps
|
export interface SelectProps
|
||||||
extends React.DetailedHTMLProps<
|
extends React.DetailedHTMLProps<
|
||||||
|
@ -12,7 +12,7 @@ export interface SelectProps
|
||||||
options?: { key: string; value: string; label: string }[];
|
options?: { key: string; value: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Select({
|
export function SelectField({
|
||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
options = [{ key: "-", value: value?.toString() || "-", label: "-" }],
|
options = [{ key: "-", value: value?.toString() || "-", label: "-" }],
|
|
@ -1,4 +1,5 @@
|
||||||
import { AppsIcon, GearIcon, HomeIcon } from "@primer/octicons-react";
|
import { AppsIcon, GearIcon, HomeIcon } from "@primer/octicons-react";
|
||||||
|
|
||||||
import { DownloadIcon } from "./download-icon";
|
import { DownloadIcon } from "./download-icon";
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
import { recipe } from "@vanilla-extract/recipes";
|
import { recipe } from "@vanilla-extract/recipes";
|
||||||
|
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
|
||||||
export const sidebar = recipe({
|
export const sidebar = recipe({
|
||||||
|
@ -11,6 +12,7 @@ export const sidebar = recipe({
|
||||||
transition: "opacity ease 0.2s",
|
transition: "opacity ease 0.2s",
|
||||||
borderRight: `solid 1px ${vars.color.border}`,
|
borderRight: `solid 1px ${vars.color.border}`,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
resizing: {
|
resizing: {
|
||||||
|
@ -123,3 +125,46 @@ export const section = style({
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
paddingBottom: `${SPACING_UNIT}px`,
|
paddingBottom: `${SPACING_UNIT}px`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const profileButton = style({
|
||||||
|
display: "flex",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all ease 0.1s",
|
||||||
|
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
|
||||||
|
alignItems: "center",
|
||||||
|
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
||||||
|
color: vars.color.muted,
|
||||||
|
borderBottom: `solid 1px ${vars.color.border}`,
|
||||||
|
boxShadow: "0px 0px 15px 0px #000000",
|
||||||
|
":hover": {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileAvatar = style({
|
||||||
|
width: "30px",
|
||||||
|
height: "30px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: vars.color.background,
|
||||||
|
position: "relative",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileButtonInformation = style({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const statusBadge = style({
|
||||||
|
width: "9px",
|
||||||
|
height: "9px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: vars.color.danger,
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "-2px",
|
||||||
|
right: "-3px",
|
||||||
|
zIndex: "1",
|
||||||
|
});
|
||||||
|
|
|
@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { Game } from "@types";
|
import type { LibraryGame } from "@types";
|
||||||
|
|
||||||
import { TextField } from "@renderer/components";
|
import { TextField } from "@renderer/components";
|
||||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
|
||||||
|
|
||||||
import { routes } from "./routes";
|
import { routes } from "./routes";
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ import * as styles from "./sidebar.css";
|
||||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
|
||||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
|
import { PersonIcon } from "@primer/octicons-react";
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 200;
|
const SIDEBAR_MIN_WIDTH = 200;
|
||||||
const SIDEBAR_INITIAL_WIDTH = 250;
|
const SIDEBAR_INITIAL_WIDTH = 250;
|
||||||
|
@ -25,7 +26,7 @@ export function Sidebar() {
|
||||||
const { library, updateLibrary } = useLibrary();
|
const { library, updateLibrary } = useLibrary();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
|
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
|
||||||
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(
|
const [sidebarWidth, setSidebarWidth] = useState(
|
||||||
|
@ -36,6 +37,8 @@ export function Sidebar() {
|
||||||
|
|
||||||
const { lastPacket, progress } = useDownload();
|
const { lastPacket, progress } = useDownload();
|
||||||
|
|
||||||
|
const { showWarningToast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateLibrary();
|
updateLibrary();
|
||||||
}, [lastPacket?.game.id, updateLibrary]);
|
}, [lastPacket?.game.id, updateLibrary]);
|
||||||
|
@ -99,9 +102,7 @@ export function Sidebar() {
|
||||||
};
|
};
|
||||||
}, [isResizing]);
|
}, [isResizing]);
|
||||||
|
|
||||||
const getGameTitle = (game: Game) => {
|
const getGameTitle = (game: LibraryGame) => {
|
||||||
if (game.status === "paused") return t("paused", { title: game.title });
|
|
||||||
|
|
||||||
if (lastPacket?.game.id === game.id) {
|
if (lastPacket?.game.id === game.id) {
|
||||||
return t("downloading", {
|
return t("downloading", {
|
||||||
title: game.title,
|
title: game.title,
|
||||||
|
@ -109,6 +110,12 @@ export function Sidebar() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (game.downloadQueue !== null) {
|
||||||
|
return t("queued", { title: game.title });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.status === "paused") return t("paused", { title: game.title });
|
||||||
|
|
||||||
return game.title;
|
return game.title;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -118,96 +125,128 @@ export function Sidebar() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSidebarGameClick = (
|
||||||
|
event: React.MouseEvent,
|
||||||
|
game: LibraryGame
|
||||||
|
) => {
|
||||||
|
const path = buildGameDetailsPath(game);
|
||||||
|
if (path !== location.pathname) {
|
||||||
|
navigate(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.detail == 2) {
|
||||||
|
if (game.executablePath) {
|
||||||
|
window.electron.openGame(game.id, game.executablePath);
|
||||||
|
} else {
|
||||||
|
showWarningToast(t("game_has_no_executable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
ref={sidebarRef}
|
<aside
|
||||||
className={styles.sidebar({ resizing: isResizing })}
|
ref={sidebarRef}
|
||||||
style={{
|
className={styles.sidebar({ resizing: isResizing })}
|
||||||
width: sidebarWidth,
|
style={{
|
||||||
minWidth: sidebarWidth,
|
width: sidebarWidth,
|
||||||
maxWidth: sidebarWidth,
|
minWidth: sidebarWidth,
|
||||||
}}
|
maxWidth: sidebarWidth,
|
||||||
>
|
}}
|
||||||
<div
|
|
||||||
className={styles.content({
|
|
||||||
macos: window.electron.platform === "darwin",
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{window.electron.platform === "darwin" && <h2>Hydra</h2>}
|
<button type="button" className={styles.profileButton}>
|
||||||
|
<div className={styles.profileAvatar}>
|
||||||
|
<PersonIcon />
|
||||||
|
|
||||||
<section className={styles.section}>
|
<div className={styles.statusBadge} />
|
||||||
<ul className={styles.menu}>
|
</div>
|
||||||
{routes.map(({ nameKey, path, render }) => (
|
|
||||||
<li
|
<div className={styles.profileButtonInformation}>
|
||||||
key={nameKey}
|
<p style={{ fontWeight: "bold" }}>hydra</p>
|
||||||
className={styles.menuItem({
|
<p style={{ fontSize: 12 }}>Jogando ABC</p>
|
||||||
active: location.pathname === path,
|
</div>
|
||||||
})}
|
</button>
|
||||||
>
|
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={styles.content({
|
||||||
className={styles.menuItemButton}
|
macos: window.electron.platform === "darwin",
|
||||||
onClick={() => handleSidebarItemClick(path)}
|
})}
|
||||||
|
>
|
||||||
|
{window.electron.platform === "darwin" && <h2>Hydra</h2>}
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<ul className={styles.menu}>
|
||||||
|
{routes.map(({ nameKey, path, render }) => (
|
||||||
|
<li
|
||||||
|
key={nameKey}
|
||||||
|
className={styles.menuItem({
|
||||||
|
active: location.pathname === path,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{render(isDownloading)}
|
<button
|
||||||
<span>{t(nameKey)}</span>
|
type="button"
|
||||||
</button>
|
className={styles.menuItemButton}
|
||||||
</li>
|
onClick={() => handleSidebarItemClick(path)}
|
||||||
))}
|
>
|
||||||
</ul>
|
{render(isDownloading)}
|
||||||
</section>
|
<span>{t(nameKey)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<small className={styles.sectionTitle}>{t("my_library")}</small>
|
<small className={styles.sectionTitle}>{t("my_library")}</small>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
placeholder={t("filter")}
|
placeholder={t("filter")}
|
||||||
onChange={handleFilter}
|
onChange={handleFilter}
|
||||||
theme="dark"
|
theme="dark"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ul className={styles.menu}>
|
<ul className={styles.menu}>
|
||||||
{filteredLibrary.map((game) => (
|
{filteredLibrary.map((game) => (
|
||||||
<li
|
<li
|
||||||
key={game.id}
|
key={game.id}
|
||||||
className={styles.menuItem({
|
className={styles.menuItem({
|
||||||
active:
|
active:
|
||||||
location.pathname === `/game/${game.shop}/${game.objectID}`,
|
location.pathname ===
|
||||||
muted: game.status === "removed",
|
`/game/${game.shop}/${game.objectID}`,
|
||||||
})}
|
muted: game.status === "removed",
|
||||||
>
|
})}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.menuItemButton}
|
|
||||||
onClick={() =>
|
|
||||||
handleSidebarItemClick(buildGameDetailsPath(game))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{game.iconUrl ? (
|
<button
|
||||||
<img
|
type="button"
|
||||||
className={styles.gameIcon}
|
className={styles.menuItemButton}
|
||||||
src={game.iconUrl}
|
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||||
alt={game.title}
|
>
|
||||||
/>
|
{game.iconUrl ? (
|
||||||
) : (
|
<img
|
||||||
<SteamLogo className={styles.gameIcon} />
|
className={styles.gameIcon}
|
||||||
)}
|
src={game.iconUrl}
|
||||||
|
alt={game.title}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SteamLogo className={styles.gameIcon} />
|
||||||
|
)}
|
||||||
|
|
||||||
<span className={styles.menuItemButtonLabel}>
|
<span className={styles.menuItemButtonLabel}>
|
||||||
{getGameTitle(game)}
|
{getGameTitle(game)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.handle}
|
className={styles.handle}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
|
||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
import { recipe } from "@vanilla-extract/recipes";
|
import { recipe } from "@vanilla-extract/recipes";
|
||||||
|
|
||||||
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
|
||||||
export const textFieldContainer = style({
|
export const textFieldContainer = style({
|
||||||
flex: "1",
|
flex: "1",
|
||||||
gap: `${SPACING_UNIT}px`,
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
@ -39,21 +40,46 @@ export const textField = recipe({
|
||||||
backgroundColor: vars.color.background,
|
backgroundColor: vars.color.background,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
state: {
|
||||||
|
error: {
|
||||||
|
borderColor: vars.color.danger,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const textFieldInput = style({
|
export const textFieldInput = recipe({
|
||||||
backgroundColor: "transparent",
|
base: {
|
||||||
border: "none",
|
backgroundColor: "transparent",
|
||||||
width: "100%",
|
border: "none",
|
||||||
height: "100%",
|
width: "100%",
|
||||||
outline: "none",
|
height: "100%",
|
||||||
color: "#DADBE1",
|
outline: "none",
|
||||||
cursor: "default",
|
color: "#DADBE1",
|
||||||
fontFamily: "inherit",
|
cursor: "default",
|
||||||
textOverflow: "ellipsis",
|
fontFamily: "inherit",
|
||||||
padding: `${SPACING_UNIT}px`,
|
textOverflow: "ellipsis",
|
||||||
":focus": {
|
padding: `${SPACING_UNIT}px`,
|
||||||
cursor: "text",
|
":focus": {
|
||||||
|
cursor: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
readOnly: {
|
||||||
|
true: {
|
||||||
|
textOverflow: "inherit",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const togglePasswordButton = style({
|
||||||
|
cursor: "pointer",
|
||||||
|
color: vars.color.muted,
|
||||||
|
padding: `${SPACING_UNIT}px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const textFieldWrapper = style({
|
||||||
|
display: "flex",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
});
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue