From 48e07370e4954623f8a0015f76c4fad330ccb525 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Mon, 3 Jun 2024 02:12:05 +0100 Subject: [PATCH] feat: adding import download source --- electron.vite.config.ts | 1 + package.json | 7 +- src/locales/en/translation.json | 16 +- src/locales/pt/translation.json | 15 +- src/main/constants.ts | 17 -- src/main/data-source.ts | 10 +- src/main/entity/download-source.entity.ts | 30 ++++ src/main/entity/game.entity.ts | 3 + src/main/entity/index.ts | 1 + src/main/entity/repack.entity.ts | 8 +- src/main/events/catalogue/get-catalogue.ts | 92 ++-------- .../events/catalogue/get-game-shop-details.ts | 30 ++-- src/main/events/catalogue/get-games.ts | 38 ++--- src/main/events/catalogue/get-random-game.ts | 31 +++- .../events/catalogue/search-game-repacks.ts | 6 +- src/main/events/catalogue/search-games.ts | 6 +- .../download-sources/add-download-source.ts | 57 +++++++ .../download-sources/get-download-sources.ts | 16 ++ .../remove-download-source.ts | 13 ++ .../validate-download-source.ts | 39 +++++ src/main/events/helpers/search-games.ts | 73 +++----- src/main/events/helpers/validators.ts | 16 ++ src/main/events/index.ts | 4 + .../events/library/get-game-by-object-id.ts | 3 - src/main/events/library/get-library.ts | 32 +--- .../events/torrenting/resume-game-download.ts | 1 - .../events/torrenting/start-game-download.ts | 6 +- src/main/helpers/formatters.test.ts | 98 ----------- src/main/helpers/formatters.ts | 56 ------- src/main/helpers/index.ts | 60 ++----- src/main/index.ts | 29 ++-- src/main/main.ts | 86 +--------- src/main/repository.ts | 11 +- src/main/services/download-manager.ts | 5 +- src/main/services/how-long-to-beat.ts | 4 +- src/main/services/index.ts | 3 +- src/main/services/repack-tracker/1337x.ts | 146 ---------------- src/main/services/repack-tracker/gog.ts | 96 ----------- src/main/services/repack-tracker/helpers.ts | 40 ----- src/main/services/repack-tracker/index.ts | 4 - .../services/repack-tracker/online-fix.ts | 157 ------------------ src/main/services/repack-tracker/xatab.ts | 120 ------------- src/main/services/search-engine.ts | 36 ++++ src/main/services/update-resolver.ts | 33 ---- src/main/vite-env.d.ts | 1 + src/main/workers/index.ts | 13 ++ src/main/workers/steam-games.worker.ts | 38 +++++ src/main/workers/torrent-parser.worker.ts | 14 -- src/preload/index.ts | 12 +- src/renderer/src/components/hero/hero.css.ts | 1 + .../src/components/modal/modal.css.ts | 2 +- .../components/text-field/text-field.css.ts | 41 +++-- .../src/components/text-field/text-field.tsx | 36 +++- src/renderer/src/declaration.d.ts | 13 +- src/renderer/src/helpers.ts | 4 + .../src/pages/downloads/downloads.tsx | 7 +- .../game-details/game-details.context.tsx | 14 +- .../dodi-installation-guide.tsx | 4 +- .../pages/game-details/sidebar/sidebar.css.ts | 9 + .../pages/game-details/sidebar/sidebar.tsx | 12 +- src/renderer/src/pages/home/home.css.ts | 6 +- src/renderer/src/pages/home/home.tsx | 55 ++---- .../settings/add-download-source-modal.tsx | 112 +++++++++++++ .../settings/settings-download-sources.css.ts | 24 +++ .../settings/settings-download-sources.tsx | 102 ++++++++++++ src/renderer/src/pages/settings/settings.tsx | 12 +- .../shared-modals/binary-not-found-modal.tsx | 6 +- src/shared/index.ts | 28 ++++ src/types/index.ts | 12 +- yarn.lock | 53 +++++- 70 files changed, 925 insertions(+), 1261 deletions(-) create mode 100644 src/main/entity/download-source.entity.ts create mode 100644 src/main/events/download-sources/add-download-source.ts create mode 100644 src/main/events/download-sources/get-download-sources.ts create mode 100644 src/main/events/download-sources/remove-download-source.ts create mode 100644 src/main/events/download-sources/validate-download-source.ts create mode 100644 src/main/events/helpers/validators.ts delete mode 100644 src/main/helpers/formatters.test.ts delete mode 100644 src/main/helpers/formatters.ts delete mode 100644 src/main/services/repack-tracker/1337x.ts delete mode 100644 src/main/services/repack-tracker/gog.ts delete mode 100644 src/main/services/repack-tracker/helpers.ts delete mode 100644 src/main/services/repack-tracker/index.ts delete mode 100644 src/main/services/repack-tracker/online-fix.ts delete mode 100644 src/main/services/repack-tracker/xatab.ts create mode 100644 src/main/services/search-engine.ts delete mode 100644 src/main/services/update-resolver.ts create mode 100644 src/main/workers/index.ts create mode 100644 src/main/workers/steam-games.worker.ts delete mode 100644 src/main/workers/torrent-parser.worker.ts create mode 100644 src/renderer/src/pages/settings/add-download-source-modal.tsx create mode 100644 src/renderer/src/pages/settings/settings-download-sources.css.ts create mode 100644 src/renderer/src/pages/settings/settings-download-sources.tsx diff --git a/electron.vite.config.ts b/electron.vite.config.ts index b59f762b..be37cc40 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,6 +8,7 @@ import { import react from "@vitejs/plugin-react"; import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; import svgr from "vite-plugin-svgr"; + export default defineConfig(({ mode }) => { loadEnv(mode); diff --git a/package.json b/package.json index 608c91fd..0ed0305a 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "lodash-es": "^4.17.21", "lottie-react": "^2.4.0", "parse-torrent": "^11.0.16", + "piscina": "^4.5.1", "ps-list": "^8.1.1", "react-i18next": "^14.1.0", "react-loading-skeleton": "^3.4.0", @@ -66,7 +67,8 @@ "react-router-dom": "^6.22.3", "typeorm": "^0.3.20", "user-agents": "^1.1.193", - "yaml": "^2.4.1" + "yaml": "^2.4.1", + "zod": "^3.23.8" }, "devDependencies": { "@commitlint/cli": "^19.3.0", @@ -82,9 +84,10 @@ "@types/parse-torrent": "^5.8.7", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", + "@types/user-agents": "^1.0.4", "@vanilla-extract/vite-plugin": "^4.0.7", "@vitejs/plugin-react": "^4.2.1", - "electron": "^28.2.0", + "electron": "^30.0.9", "electron-builder": "^24.9.1", "electron-vite": "^2.0.0", "eslint": "^8.56.0", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 31d1a5a0..c7db2e86 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "Featured", - "recently_added": "Recently added", "trending": "Trending", "surprise_me": "Surprise me", "no_results": "No results found" @@ -149,6 +148,7 @@ "launch_with_system": "Launch Hydra on system start-up", "general": "General", "behavior": "Behavior", + "download_sources": "Download sources", "real_debrid_api_token": "API Token", "enable_real_debrid": "Enable Real-Debrid", "real_debrid_description": "Real-Debrid is an unrestricted downloader that allows you to download files instantly and at the best of your Internet speed.", @@ -157,7 +157,16 @@ "real_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to Real-Debrid", "real_debrid_linked_message": "Account \"{{username}}\" linked", "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_options_zero": "No download option", + "download_options_one": "{{countFormatted}} download option", + "download_options_other": "{{countFormatted}} download options", + "download_source_url": "Download source URL", + "add_download_source_description": "Insert the URL containing the JSON file" }, "notifications": { "download_complete": "Download complete", @@ -180,5 +189,8 @@ }, "modal": { "close": "Close button" + }, + "forms": { + "toggle_password_visibility": "Toggle password visibility" } } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index fadd1e49..90d126be 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -145,6 +145,7 @@ "launch_with_system": "Iniciar o Hydra junto com o sistema", "general": "Geral", "behavior": "Comportamento", + "download_sources": "Bibliotecas de download", "real_debrid_api_token": "Token de API", "enable_real_debrid": "Habilitar Real-Debrid", "real_debrid_api_token_hint": "Você pode obter seu token de API <0>aqui", @@ -153,7 +154,16 @@ "real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine a Real-Debrid", "real_debrid_linked_message": "Conta \"{{username}}\" vinculada", "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 bibliotecas habilitadas. A URL da biblioteca 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 biblioteca", + "download_options_zero": "Sem opções de download", + "download_options_one": "{{countFormatted}} opcão de download", + "download_options_other": "{{countFormatted}} opções de download", + "download_source_url": "URL da biblioteca", + "add_download_source_description": "Insira a URL contendo o arquivo JSON" }, "notifications": { "download_complete": "Download concluído", @@ -180,5 +190,8 @@ }, "modal": { "close": "Botão de fechar" + }, + "forms": { + "toggle_password_visibility": "Alternar visibilidade da senha" } } diff --git a/src/main/constants.ts b/src/main/constants.ts index de1ccb60..850c9ada 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -1,23 +1,6 @@ import { app } from "electron"; 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 databasePath = path.join( diff --git a/src/main/data-source.ts b/src/main/data-source.ts index 3898a0c5..21c9ba51 100644 --- a/src/main/data-source.ts +++ b/src/main/data-source.ts @@ -1,5 +1,11 @@ import { DataSource } from "typeorm"; -import { Game, GameShopCache, Repack, UserPreferences } from "@main/entity"; +import { + DownloadSource, + Game, + GameShopCache, + Repack, + UserPreferences, +} from "@main/entity"; import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions"; import { databasePath } from "./constants"; @@ -10,7 +16,7 @@ export const createDataSource = ( ) => new DataSource({ type: "better-sqlite3", - entities: [Game, Repack, UserPreferences, GameShopCache], + entities: [Game, Repack, UserPreferences, GameShopCache, DownloadSource], synchronize: true, database: databasePath, ...options, diff --git a/src/main/entity/download-source.entity.ts b/src/main/entity/download-source.entity.ts new file mode 100644 index 00000000..1f302611 --- /dev/null +++ b/src/main/entity/download-source.entity.ts @@ -0,0 +1,30 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from "typeorm"; +import { Repack } from "./repack.entity"; + +@Entity("download_source") +export class DownloadSource { + @PrimaryGeneratedColumn() + id: number; + + @Column("text", { nullable: true, unique: true }) + url: string; + + @Column("text", { unique: true }) + name: string; + + @OneToMany(() => Repack, (repack) => repack.downloadSource, { cascade: true }) + repacks: Repack[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts index 784380e0..cd12b155 100644 --- a/src/main/entity/game.entity.ts +++ b/src/main/entity/game.entity.ts @@ -63,6 +63,9 @@ export class Game { @Column("float", { default: 0 }) fileSize: number; + @Column("text", { nullable: true }) + uri: string | null; + @OneToOne(() => Repack, { nullable: true }) @JoinColumn() repack: Repack; diff --git a/src/main/entity/index.ts b/src/main/entity/index.ts index 62fa5b6c..bfbe51c7 100644 --- a/src/main/entity/index.ts +++ b/src/main/entity/index.ts @@ -2,3 +2,4 @@ export * from "./game.entity"; export * from "./repack.entity"; export * from "./user-preferences.entity"; export * from "./game-shop-cache.entity"; +export * from "./download-source.entity"; diff --git a/src/main/entity/repack.entity.ts b/src/main/entity/repack.entity.ts index b87925a7..ee91bfad 100644 --- a/src/main/entity/repack.entity.ts +++ b/src/main/entity/repack.entity.ts @@ -4,7 +4,9 @@ import { Column, CreateDateColumn, UpdateDateColumn, + ManyToOne, } from "typeorm"; +import { DownloadSource } from "./download-source.entity"; @Entity("repack") export class Repack { @@ -17,9 +19,6 @@ export class Repack { @Column("text", { unique: true }) magnet: string; - @Column("int") - page: number; - @Column("text") repacker: string; @@ -29,6 +28,9 @@ export class Repack { @Column("datetime") uploadDate: Date | string; + @ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" }) + downloadSource: DownloadSource; + @CreateDateColumn() createdAt: Date; diff --git a/src/main/events/catalogue/get-catalogue.ts b/src/main/events/catalogue/get-catalogue.ts index 16a2fdbe..24654bdc 100644 --- a/src/main/events/catalogue/get-catalogue.ts +++ b/src/main/events/catalogue/get-catalogue.ts @@ -1,95 +1,35 @@ -import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers"; -import type { CatalogueCategory, CatalogueEntry, GameShop } from "@types"; +import { getSteamAppAsset } from "@main/helpers"; +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 { requestSteam250 } from "@main/services"; - -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)); -}; +import { SearchEngine, requestSteam250 } from "@main/services"; const resultSize = 12; -const getCatalogue = async ( - _event: Electron.IpcMainInvokeEvent, - category: CatalogueCategory -) => { - if (!repacks.length) return []; - - if (category === "trending") { - return getTrendingCatalogue(resultSize); - } - - return getRecentlyAddedCatalogue(resultSize); -}; - -const getTrendingCatalogue = async ( - resultSize: number -): Promise => { - const results: CatalogueEntry[] = []; +const getCatalogue = async (_event: Electron.IpcMainInvokeEvent) => { 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 => { const results: CatalogueEntry[] = []; - for (let i = 0; results.length < resultSize; i++) { - const stringForLookup = getStringForLookup(i); - - if (!stringForLookup) { + for (let i = 0; i < resultSize; i++) { + if (!trendingGames[i]) { i++; continue; } - const games = searchGames({ query: stringForLookup }); + const { title, objectID } = trendingGames[i]!; + const repacks = SearchEngine.searchRepacks(title); - for (const game of games) { - const isAlreadyIncluded = results.some( - (result) => result.objectID === game?.objectID - ); + const catalogueEntry = { + objectID, + title, + shop: "steam" as GameShop, + cover: getSteamAppAsset("library", objectID), + }; - if (!game || !game.repacks.length || isAlreadyIncluded) { - continue; - } - - results.push(game); - } + results.push({ ...catalogueEntry, repacks }); } - return results.slice(0, resultSize); + return results; }; registerEvent("getCatalogue", getCatalogue); diff --git a/src/main/events/catalogue/get-game-shop-details.ts b/src/main/events/catalogue/get-game-shop-details.ts index 1b321c11..0b4535f6 100644 --- a/src/main/events/catalogue/get-game-shop-details.ts +++ b/src/main/events/catalogue/get-game-shop-details.ts @@ -4,9 +4,9 @@ import { getSteamAppDetails } from "@main/services"; import type { ShopDetails, GameShop, SteamAppDetails } from "@types"; import { registerEvent } from "../register-event"; -import { stateManager } from "@main/state-manager"; +import { steamGamesWorker } from "@main/workers"; -const getLocalizedSteamAppDetails = ( +const getLocalizedSteamAppDetails = async ( objectID: string, language: string ): Promise => { @@ -14,20 +14,22 @@ const getLocalizedSteamAppDetails = ( return getSteamAppDetails(objectID, language); } - return getSteamAppDetails(objectID, language).then((localizedAppDetails) => { - const steamGame = stateManager - .getValue("steamGames") - .find((game) => game.id === Number(objectID)); + return getSteamAppDetails(objectID, language).then( + async (localizedAppDetails) => { + const steamGame = await steamGamesWorker.run(Number(objectID), { + name: "getById", + }); - if (steamGame && localizedAppDetails) { - return { - ...localizedAppDetails, - name: steamGame.name, - }; + if (steamGame && localizedAppDetails) { + return { + ...localizedAppDetails, + name: steamGame.name, + }; + } + + return null; } - - return null; - }); + ); }; const getGameShopDetails = async ( diff --git a/src/main/events/catalogue/get-games.ts b/src/main/events/catalogue/get-games.ts index c4004ad0..21e7bc03 100644 --- a/src/main/events/catalogue/get-games.ts +++ b/src/main/events/catalogue/get-games.ts @@ -1,39 +1,23 @@ -import type { CatalogueEntry, GameShop } from "@types"; +import type { CatalogueEntry } from "@types"; import { registerEvent } from "../register-event"; -import { searchRepacks } from "../helpers/search-games"; -import { stateManager } from "@main/state-manager"; -import { getSteamAppAsset } from "@main/helpers"; - -const steamGames = stateManager.getValue("steamGames"); +import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; +import { steamGamesWorker } from "@main/workers"; const getGames = async ( _event: Electron.IpcMainInvokeEvent, take = 12, cursor = 0 ): Promise<{ results: CatalogueEntry[]; cursor: number }> => { - const results: CatalogueEntry[] = []; + const results = await steamGamesWorker.run( + { limit: take, offset: cursor }, + { name: "list" } + ); - let i = 0 + cursor; - - while (results.length < take) { - const game = steamGames[i]; - const repacks = searchRepacks(game.name); - - 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 }; + return { + results: results.map((result) => convertSteamGameToCatalogueEntry(result)), + cursor: cursor + results.length, + }; }; registerEvent("getGames", getGames); diff --git a/src/main/events/catalogue/get-random-game.ts b/src/main/events/catalogue/get-random-game.ts index d4a62ac6..d33b7ec9 100644 --- a/src/main/events/catalogue/get-random-game.ts +++ b/src/main/events/catalogue/get-random-game.ts @@ -1,23 +1,36 @@ -import { shuffle } from "lodash-es"; +import { shuffle, slice } from "lodash-es"; -import { getSteam250List } from "@main/services"; +import { SearchEngine, getSteam250List } from "@main/services"; import { registerEvent } from "../register-event"; -import { searchGames, searchRepacks } from "../helpers/search-games"; +import { searchSteamGames } from "../helpers/search-games"; import type { Steam250Game } from "@types"; const state = { games: Array(), 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 repacks = SearchEngine.searchRepacks(catalogue[0].title); + + if (repacks.length) { + results.push(game); + } + } + } + + return results; +}; + const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => { if (state.games.length == 0) { const steam250List = await getSteam250List(); - const filteredSteam250List = steam250List.filter((game) => { - const repacks = searchRepacks(game.title); - const catalogue = searchGames({ query: game.title }); - - return repacks.length && catalogue.length; - }); + const filteredSteam250List = await filterGames(steam250List); state.games = shuffle(filteredSteam250List); } diff --git a/src/main/events/catalogue/search-game-repacks.ts b/src/main/events/catalogue/search-game-repacks.ts index 1833f9e9..a03fe5f0 100644 --- a/src/main/events/catalogue/search-game-repacks.ts +++ b/src/main/events/catalogue/search-game-repacks.ts @@ -1,11 +1,9 @@ -import { searchRepacks } from "../helpers/search-games"; +import { SearchEngine } from "@main/services"; import { registerEvent } from "../register-event"; const searchGameRepacks = ( _event: Electron.IpcMainInvokeEvent, query: string -) => { - return searchRepacks(query); -}; +) => SearchEngine.searchRepacks(query); registerEvent("searchGameRepacks", searchGameRepacks); diff --git a/src/main/events/catalogue/search-games.ts b/src/main/events/catalogue/search-games.ts index c6a832ec..ec397599 100644 --- a/src/main/events/catalogue/search-games.ts +++ b/src/main/events/catalogue/search-games.ts @@ -1,12 +1,10 @@ import { registerEvent } from "../register-event"; -import { searchGames } from "../helpers/search-games"; +import { searchSteamGames } from "../helpers/search-games"; import { CatalogueEntry } from "@types"; const searchGamesEvent = async ( _event: Electron.IpcMainInvokeEvent, query: string -): Promise => { - return searchGames({ query, take: 12 }); -}; +): Promise => searchSteamGames({ query, limit: 12 }); registerEvent("searchGames", searchGamesEvent); diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts new file mode 100644 index 00000000..a58f0cc6 --- /dev/null +++ b/src/main/events/download-sources/add-download-source.ts @@ -0,0 +1,57 @@ +import { registerEvent } from "../register-event"; +import { chunk } from "lodash-es"; +import { dataSource } from "@main/data-source"; +import { DownloadSource, Repack } from "@main/entity"; +import axios from "axios"; +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; +import { downloadSourceSchema } from "../helpers/validators"; +import { SearchEngine } 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 }); + + const repacks: QueryDeepPartialEntity[] = source.downloads.map( + (download) => ({ + title: download.title, + magnet: download.uris[0], + fileSize: download.fileSize, + repacker: source.name, + uploadDate: download.uploadDate, + downloadSource: { id: downloadSource.id }, + }) + ); + + const downloadsChunks = chunk(repacks, 800); + + for (const chunk of downloadsChunks) { + await transactionalEntityManager + .getRepository(Repack) + .createQueryBuilder() + .insert() + .values(chunk) + .updateEntity(false) + .orIgnore() + .execute(); + } + + return downloadSource; + } + ); + + await SearchEngine.updateRepacks(); + + return downloadSource; +}; + +registerEvent("addDownloadSource", addDownloadSource); diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts new file mode 100644 index 00000000..8f24caad --- /dev/null +++ b/src/main/events/download-sources/get-download-sources.ts @@ -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); diff --git a/src/main/events/download-sources/remove-download-source.ts b/src/main/events/download-sources/remove-download-source.ts new file mode 100644 index 00000000..0f52c58e --- /dev/null +++ b/src/main/events/download-sources/remove-download-source.ts @@ -0,0 +1,13 @@ +import { downloadSourceRepository } from "@main/repository"; +import { registerEvent } from "../register-event"; +import { SearchEngine } from "@main/services"; + +const removeDownloadSource = async ( + _event: Electron.IpcMainInvokeEvent, + id: number +) => { + await downloadSourceRepository.delete(id); + await SearchEngine.updateRepacks(); +}; + +registerEvent("removeDownloadSource", removeDownloadSource); diff --git a/src/main/events/download-sources/validate-download-source.ts b/src/main/events/download-sources/validate-download-source.ts new file mode 100644 index 00000000..622e2748 --- /dev/null +++ b/src/main/events/download-sources/validate-download-source.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { registerEvent } from "../register-event"; +import axios from "axios"; +import { downloadSourceRepository } from "@main/repository"; + +const downloadSourceSchema = z.object({ + name: z.string().max(255), + downloads: z.array( + z.object({ + title: z.string().max(255), + objectId: z.string().max(255).nullable(), + shop: z.enum(["steam"]).nullable(), + downloaders: z.array(z.enum(["real_debrid", "torrent"])), + uris: z.array(z.string()), + uploadDate: z.string().max(255), + fileSize: z.string().max(255), + }) + ), +}); + +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 }, { name: source.name }], + }); + + if (existingSource?.url === url) + throw new Error("Source with the same url already exists"); + + return { name: source.name, downloadCount: source.downloads.length }; +}; + +registerEvent("validateDownloadSource", validateDownloadSource); diff --git a/src/main/events/helpers/search-games.ts b/src/main/events/helpers/search-games.ts index abc8b55c..4d075409 100644 --- a/src/main/events/helpers/search-games.ts +++ b/src/main/events/helpers/search-games.ts @@ -1,40 +1,11 @@ -import flexSearch from "flexsearch"; 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 { stateManager } from "@main/state-manager"; - -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" - ); -}; +import { getSteamAppAsset } from "@main/helpers"; +import { SearchEngine } from "@main/services"; +import { steamGamesWorker } from "@main/workers"; export interface SearchGamesArgs { query?: string; @@ -42,27 +13,25 @@ export interface SearchGamesArgs { skip?: number; } -export const searchGames = ({ - query, - take, - skip, -}: SearchGamesArgs): CatalogueEntry[] => { - const results = steamGamesIndex - .search(formatName(query || ""), { limit: take, offset: skip }) - .map((index) => { - const result = steamGames.at(index as number)!; +export const convertSteamGameToCatalogueEntry = ( + result: SteamGame +): CatalogueEntry => { + return { + objectID: String(result.id), + title: result.name, + shop: "steam" as GameShop, + cover: getSteamAppAsset("library", String(result.id)), + repacks: SearchEngine.searchRepacks(result.name), + }; +}; - return { - objectID: String(result.id), - title: result.name, - shop: "steam" as GameShop, - cover: getSteamAppAsset("library", String(result.id)), - repacks: searchRepacks(result.name), - }; - }); +export const searchSteamGames = async ( + options: flexSearch.SearchOptions +): Promise => { + const steamGames = await steamGamesWorker.run(options, { name: "search" }); return orderBy( - results, + steamGames.map((result) => convertSteamGameToCatalogueEntry(result)), [({ repacks }) => repacks.length, "repacks"], ["desc"] ); diff --git a/src/main/events/helpers/validators.ts b/src/main/events/helpers/validators.ts new file mode 100644 index 00000000..1f7cdcb7 --- /dev/null +++ b/src/main/events/helpers/validators.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const downloadSourceSchema = z.object({ + name: z.string().max(255), + downloads: z.array( + z.object({ + title: z.string().max(255), + objectId: z.string().max(255).nullable(), + shop: z.enum(["steam"]).nullable(), + downloaders: z.array(z.enum(["real_debrid", "torrent"])), + uris: z.array(z.string()), + uploadDate: z.string().max(255), + fileSize: z.string().max(255), + }) + ), +}); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 0ebb5d86..83b90595 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -30,6 +30,10 @@ import "./user-preferences/auto-launch"; import "./autoupdater/check-for-updates"; import "./autoupdater/restart-and-install-update"; 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"; ipcMain.handle("ping", () => "pong"); ipcMain.handle("getVersion", () => app.getVersion()); diff --git a/src/main/events/library/get-game-by-object-id.ts b/src/main/events/library/get-game-by-object-id.ts index 3b56d962..91cc1b5a 100644 --- a/src/main/events/library/get-game-by-object-id.ts +++ b/src/main/events/library/get-game-by-object-id.ts @@ -11,9 +11,6 @@ const getGameByObjectID = async ( objectID, isDeleted: false, }, - relations: { - repack: true, - }, }); registerEvent("getGameByObjectID", getGameByObjectID); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 4fd4e254..429ab4ea 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -1,30 +1,14 @@ import { gameRepository } from "@main/repository"; - -import { searchRepacks } from "../helpers/search-games"; import { registerEvent } from "../register-event"; -import { sortBy } from "lodash-es"; const getLibrary = async () => - gameRepository - .find({ - where: { - isDeleted: false, - }, - order: { - createdAt: "desc", - }, - relations: { - repack: true, - }, - }) - .then((games) => - sortBy( - games.map((game) => ({ - ...game, - repacks: searchRepacks(game.title), - })), - (game) => (game.status !== "removed" ? 0 : 1) - ) - ); + gameRepository.find({ + where: { + isDeleted: false, + }, + order: { + createdAt: "desc", + }, + }); registerEvent("getLibrary", getLibrary); diff --git a/src/main/events/torrenting/resume-game-download.ts b/src/main/events/torrenting/resume-game-download.ts index f8007206..38d204ed 100644 --- a/src/main/events/torrenting/resume-game-download.ts +++ b/src/main/events/torrenting/resume-game-download.ts @@ -16,7 +16,6 @@ const resumeGameDownload = async ( id: gameId, isDeleted: false, }, - relations: { repack: true }, }); if (!game) return; diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index c52ccb35..9a5d9b64 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -20,7 +20,6 @@ const startGameDownload = async ( objectID, shop, }, - relations: { repack: true }, }), repackRepository.findOne({ where: { @@ -49,7 +48,7 @@ const startGameDownload = async ( bytesDownloaded: 0, downloadPath, downloader, - repack: { id: repackId }, + uri: repack.magnet, isDeleted: false, } ); @@ -71,7 +70,7 @@ const startGameDownload = async ( shop, status: "active", downloadPath, - repack: { id: repackId }, + uri: repack.magnet, }) .then((result) => { if (iconUrl) { @@ -88,7 +87,6 @@ const startGameDownload = async ( where: { objectID, }, - relations: { repack: true }, }); await DownloadManager.startDownload(updatedGame!); diff --git a/src/main/helpers/formatters.test.ts b/src/main/helpers/formatters.test.ts deleted file mode 100644 index 6b0d0fae..00000000 --- a/src/main/helpers/formatters.test.ts +++ /dev/null @@ -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", - ]); - }); - }); -}); diff --git a/src/main/helpers/formatters.ts b/src/main/helpers/formatters.ts deleted file mode 100644 index 6ad67625..00000000 --- a/src/main/helpers/formatters.ts +++ /dev/null @@ -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})+.+/, ""); diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 367ff0d8..3e40c2b8 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -1,48 +1,5 @@ -import { - removeReleaseYearFromName, - removeSymbolsFromName, - removeSpecialEditionFromName, - empressFormatter, - kaosKrewFormatter, - fitGirlFormatter, - removeDuplicateSpaces, - dodiFormatter, - removeTrash, - xatabFormatter, - tinyRepacksFormatter, - gogFormatter, - onlinefixFormatter, -} from "./formatters"; -import { repackers } from "../constants"; - -export const pipe = - (...fns: ((arg: T) => any)[]) => - (arg: T) => - fns.reduce((prev, fn) => fn(prev), arg); - -export const formatName = pipe( - 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, -}; +import axios from "axios"; +import UserAgent from "user-agents"; export const getSteamAppAsset = ( category: "library" | "hero" | "logo" | "icon", @@ -88,5 +45,16 @@ export const steamUrlBuilder = { export const sleep = (ms: number) => 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"; diff --git a/src/main/index.ts b/src/main/index.ts index 74481090..e2333789 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,15 +3,11 @@ import updater from "electron-updater"; import i18n from "i18next"; import path from "node:path"; import { electronApp, optimizer } from "@electron-toolkit/utils"; -import { - DownloadManager, - logger, - resolveDatabaseUpdates, - WindowManager, -} from "@main/services"; +import { DownloadManager, logger, WindowManager } from "@main/services"; import { dataSource } from "@main/data-source"; import * as resources from "@locales"; import { userPreferencesRepository } from "@main/repository"; + const { autoUpdater } = updater; autoUpdater.setFeedURL({ @@ -51,27 +47,24 @@ if (process.defaultApp) { // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. -app.whenReady().then(() => { +app.whenReady().then(async () => { electronApp.setAppUserModelId("site.hydralauncher.hydra"); protocol.handle("hydra", (request) => net.fetch("file://" + request.url.slice("hydra://".length)) ); - dataSource.initialize().then(async () => { - await dataSource.runMigrations(); + await dataSource.initialize(); + await dataSource.runMigrations(); - await resolveDatabaseUpdates(); + await import("./main"); - await import("./main"); - - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); - - WindowManager.createMainWindow(); - WindowManager.createSystemTray(userPreferences?.language || "en"); + const userPreferences = await userPreferencesRepository.findOne({ + where: { id: 1 }, }); + + WindowManager.createMainWindow(); + WindowManager.createSystemTray(userPreferences?.language || "en"); }); app.on("browser-window-created", (_, window) => { diff --git a/src/main/main.ts b/src/main/main.ts index 501f03fa..e12b5f8c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,88 +1,12 @@ -import { stateManager } from "./state-manager"; -import { repackersOn1337x, seedsPath } from "./constants"; -import { - getNewGOGGames, - getNewRepacksFromUser, - getNewRepacksFromXatab, - getNewRepacksFromOnlineFix, - DownloadManager, - startMainLoop, -} from "./services"; -import { - gameRepository, - repackRepository, - userPreferencesRepository, -} from "./repository"; -import { Repack, UserPreferences } from "./entity"; -import { Notification } from "electron"; -import { t } from "i18next"; -import fs from "node:fs"; -import path from "node:path"; +import { DownloadManager, SearchEngine, startMainLoop } from "./services"; +import { gameRepository, userPreferencesRepository } from "./repository"; +import { UserPreferences } from "./entity"; import { RealDebridClient } from "./services/real-debrid"; -import { orderBy } from "lodash-es"; -import { SteamGame } from "@types"; import { Not } from "typeorm"; 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 repacks = repackRepository.find({ - 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"); if (userPreferences?.realDebridApiToken) @@ -94,10 +18,10 @@ const loadState = async (userPreferences: UserPreferences | null) => { progress: Not(1), isDeleted: false, }, - relations: { repack: true }, }); if (game) DownloadManager.startDownload(game); + await SearchEngine.updateRepacks(); }; userPreferencesRepository @@ -105,5 +29,5 @@ userPreferencesRepository where: { id: 1 }, }) .then((userPreferences) => { - loadState(userPreferences).then(() => checkForNewRepacks(userPreferences)); + loadState(userPreferences); }); diff --git a/src/main/repository.ts b/src/main/repository.ts index 95601524..f0cef8d7 100644 --- a/src/main/repository.ts +++ b/src/main/repository.ts @@ -1,5 +1,11 @@ import { dataSource } from "./data-source"; -import { Game, GameShopCache, Repack, UserPreferences } from "@main/entity"; +import { + DownloadSource, + Game, + GameShopCache, + Repack, + UserPreferences, +} from "@main/entity"; export const gameRepository = dataSource.getRepository(Game); @@ -9,3 +15,6 @@ export const userPreferencesRepository = dataSource.getRepository(UserPreferences); export const gameShopCacheRepository = dataSource.getRepository(GameShopCache); + +export const downloadSourceRepository = + dataSource.getRepository(DownloadSource); diff --git a/src/main/services/download-manager.ts b/src/main/services/download-manager.ts index 60a796a6..ab1b2fb3 100644 --- a/src/main/services/download-manager.ts +++ b/src/main/services/download-manager.ts @@ -192,7 +192,6 @@ export class DownloadManager { const game = await gameRepository.findOne({ where: { id: this.game.id, isDeleted: false }, - relations: { repack: true }, }); if (progress === 1 && this.game && !isDownloadingMetadata) { @@ -291,10 +290,10 @@ export class DownloadManager { if (game.downloader === Downloader.RealDebrid) { this.realDebridTorrentId = await RealDebridClient.getTorrentId( - game!.repack.magnet + game!.uri! ); } 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); } diff --git a/src/main/services/how-long-to-beat.ts b/src/main/services/how-long-to-beat.ts index ba5c98c1..39b938c5 100644 --- a/src/main/services/how-long-to-beat.ts +++ b/src/main/services/how-long-to-beat.ts @@ -1,8 +1,8 @@ -import { formatName } from "@main/helpers"; import axios from "axios"; import { JSDOM } from "jsdom"; -import { requestWebPage } from "./repack-tracker/helpers"; +import { requestWebPage } from "@main/helpers"; import { HowLongToBeatCategory } from "@types"; +import { formatName } from "@shared"; export interface HowLongToBeatResult { game_id: number; diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 776fd7f6..bada604e 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -1,11 +1,10 @@ export * from "./logger"; -export * from "./repack-tracker"; export * from "./steam"; export * from "./steam-250"; export * from "./steam-grid"; -export * from "./update-resolver"; export * from "./window-manager"; export * from "./download-manager"; export * from "./how-long-to-beat"; export * from "./process-watcher"; export * from "./main-loop"; +export * from "./search-engine"; diff --git a/src/main/services/repack-tracker/1337x.ts b/src/main/services/repack-tracker/1337x.ts deleted file mode 100644 index f02aa6e4..00000000 --- a/src/main/services/repack-tracker/1337x.ts +++ /dev/null @@ -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); -}; diff --git a/src/main/services/repack-tracker/gog.ts b/src/main/services/repack-tracker/gog.ts deleted file mode 100644 index d299ce58..00000000 --- a/src/main/services/repack-tracker/gog.ts +++ /dev/null @@ -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[] = []; - 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); - } -}; diff --git a/src/main/services/repack-tracker/helpers.ts b/src/main/services/repack-tracker/helpers.ts deleted file mode 100644 index 04256dd5..00000000 --- a/src/main/services/repack-tracker/helpers.ts +++ /dev/null @@ -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[]) => - 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; -}; diff --git a/src/main/services/repack-tracker/index.ts b/src/main/services/repack-tracker/index.ts deleted file mode 100644 index 27569386..00000000 --- a/src/main/services/repack-tracker/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./1337x"; -export * from "./xatab"; -export * from "./gog"; -export * from "./online-fix"; diff --git a/src/main/services/repack-tracker/online-fix.ts b/src/main/services/repack-tracker/online-fix.ts deleted file mode 100644 index 265f6b70..00000000 --- a/src/main/services/repack-tracker/online-fix.ts +++ /dev/null @@ -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 => { - 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[] = []; - 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); -}; diff --git a/src/main/services/repack-tracker/xatab.ts b/src/main/services/repack-tracker/xatab.ts deleted file mode 100644 index e765bebf..00000000 --- a/src/main/services/repack-tracker/xatab.ts +++ /dev/null @@ -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 => { - const data = await requestWebPage(`https://byxatab.com/page/${page}`); - - const { window } = new JSDOM(data); - - const repacks: QueryDeepPartialEntity[] = []; - - 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); -}; diff --git a/src/main/services/search-engine.ts b/src/main/services/search-engine.ts new file mode 100644 index 00000000..87b268d3 --- /dev/null +++ b/src/main/services/search-engine.ts @@ -0,0 +1,36 @@ +import flexSearch from "flexsearch"; + +import { repackRepository } from "@main/repository"; +import { formatName } from "@shared"; +import type { GameRepack } from "@types"; + +export class SearchEngine { + public static repacks: GameRepack[] = []; + + private static repacksIndex = new flexSearch.Index(); + + public static searchRepacks(query: string): GameRepack[] { + return this.repacksIndex + .search(formatName(query)) + .map((index) => this.repacks[index]); + } + + public static async updateRepacks() { + this.repacks = []; + + const repacks = await repackRepository.find({ + order: { + createdAt: "desc", + }, + }); + + for (let i = 0; i < repacks.length; i++) { + const repack = repacks[i]; + + const formattedTitle = formatName(repack.title); + + this.repacks = [...this.repacks, { ...repack, title: formattedTitle }]; + this.repacksIndex.add(i, formattedTitle); + } + } +} diff --git a/src/main/services/update-resolver.ts b/src/main/services/update-resolver.ts deleted file mode 100644 index ab1c25ef..00000000 --- a/src/main/services/update-resolver.ts +++ /dev/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(); - } - }); -}; diff --git a/src/main/vite-env.d.ts b/src/main/vite-env.d.ts index 7542ff52..23a0b1f5 100644 --- a/src/main/vite-env.d.ts +++ b/src/main/vite-env.d.ts @@ -1,4 +1,5 @@ /// +/// interface ImportMetaEnv { readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string; diff --git a/src/main/workers/index.ts b/src/main/workers/index.ts new file mode 100644 index 00000000..00d51f91 --- /dev/null +++ b/src/main/workers/index.ts @@ -0,0 +1,13 @@ +import path from "node:path"; +import steamGamesWorkerPath from "./steam-games.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"), + }, +}); diff --git a/src/main/workers/steam-games.worker.ts b/src/main/workers/steam-games.worker.ts new file mode 100644 index 00000000..4a41e06e --- /dev/null +++ b/src/main/workers/steam-games.worker.ts @@ -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: "forward", +}); + +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.query ?? "", 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); diff --git a/src/main/workers/torrent-parser.worker.ts b/src/main/workers/torrent-parser.worker.ts deleted file mode 100644 index 9425c19a..00000000 --- a/src/main/workers/torrent-parser.worker.ts +++ /dev/null @@ -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); - } -}); diff --git a/src/preload/index.ts b/src/preload/index.ts index 74f2e767..9bd1f509 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -32,8 +32,7 @@ contextBridge.exposeInMainWorld("electron", { /* Catalogue */ searchGames: (query: string) => ipcRenderer.invoke("searchGames", query), - getCatalogue: (category: CatalogueCategory) => - ipcRenderer.invoke("getCatalogue", category), + getCatalogue: () => ipcRenderer.invoke("getCatalogue"), getGameShopDetails: (objectID: string, shop: GameShop, language: string) => ipcRenderer.invoke("getGameShopDetails", objectID, shop, language), getRandomGame: () => ipcRenderer.invoke("getRandomGame"), @@ -52,6 +51,15 @@ contextBridge.exposeInMainWorld("electron", { authenticateRealDebrid: (apiToken: string) => 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), + /* Library */ addGameToLibrary: ( objectID: string, diff --git a/src/renderer/src/components/hero/hero.css.ts b/src/renderer/src/components/hero/hero.css.ts index ea1a8628..fb8f6833 100644 --- a/src/renderer/src/components/hero/hero.css.ts +++ b/src/renderer/src/components/hero/hero.css.ts @@ -23,6 +23,7 @@ export const heroMedia = style({ width: "100%", height: "100%", transition: "all ease 0.2s", + imageRendering: "revert", selectors: { [`${hero}:hover &`]: { transform: "scale(1.02)", diff --git a/src/renderer/src/components/modal/modal.css.ts b/src/renderer/src/components/modal/modal.css.ts index 557b5a0a..0e0df4c3 100644 --- a/src/renderer/src/components/modal/modal.css.ts +++ b/src/renderer/src/components/modal/modal.css.ts @@ -21,7 +21,7 @@ export const modal = recipe({ animationName: fadeIn, animationDuration: "0.3s", backgroundColor: vars.color.background, - borderRadius: "5px", + borderRadius: "4px", maxWidth: "600px", color: vars.color.body, maxHeight: "100%", diff --git a/src/renderer/src/components/text-field/text-field.css.ts b/src/renderer/src/components/text-field/text-field.css.ts index 90dfdbe6..91dab423 100644 --- a/src/renderer/src/components/text-field/text-field.css.ts +++ b/src/renderer/src/components/text-field/text-field.css.ts @@ -42,18 +42,33 @@ export const textField = recipe({ }, }); -export const textFieldInput = style({ - backgroundColor: "transparent", - border: "none", - width: "100%", - height: "100%", - outline: "none", - color: "#DADBE1", - cursor: "default", - fontFamily: "inherit", - textOverflow: "ellipsis", - padding: `${SPACING_UNIT}px`, - ":focus": { - cursor: "text", +export const textFieldInput = recipe({ + base: { + backgroundColor: "transparent", + border: "none", + width: "100%", + height: "100%", + outline: "none", + color: "#DADBE1", + cursor: "default", + fontFamily: "inherit", + textOverflow: "ellipsis", + padding: `${SPACING_UNIT}px`, + ":focus": { + cursor: "text", + }, + }, + variants: { + readOnly: { + true: { + textOverflow: "inherit", + }, + }, }, }); + +export const togglePasswordButton = style({ + cursor: "pointer", + color: vars.color.muted, + padding: `${SPACING_UNIT}px`, +}); diff --git a/src/renderer/src/components/text-field/text-field.tsx b/src/renderer/src/components/text-field/text-field.tsx index ce086acc..c7fe501e 100644 --- a/src/renderer/src/components/text-field/text-field.tsx +++ b/src/renderer/src/components/text-field/text-field.tsx @@ -1,6 +1,8 @@ -import { useId, useState } from "react"; +import { useId, useMemo, useState } from "react"; import type { RecipeVariants } from "@vanilla-extract/recipes"; import * as styles from "./text-field.css"; +import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; export interface TextFieldProps extends React.DetailedHTMLProps< @@ -28,9 +30,20 @@ export function TextField({ containerProps, ...props }: TextFieldProps) { - const [isFocused, setIsFocused] = useState(false); const id = useId(); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const [isFocused, setIsFocused] = useState(false); + + const { t } = useTranslation("forms"); + + const showPasswordToggleButton = props.type === "password"; + + const inputType = useMemo(() => { + if (props.type === "password" && isPasswordVisible) return "text"; + return props.type ?? "text"; + }, [props.type, isPasswordVisible]); + return (
{label && } @@ -41,12 +54,27 @@ export function TextField({ > setIsFocused(true)} onBlur={() => setIsFocused(false)} {...props} + type={inputType} /> + + {showPasswordToggleButton && ( + + )}
{hint && {hint}} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index a6cd6a03..e29ec52c 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -12,6 +12,7 @@ import type { UserPreferences, StartGameDownloadPayload, RealDebridUser, + DownloadSource, } from "@types"; import type { DiskSpace } from "check-disk-space"; @@ -33,7 +34,7 @@ declare global { /* Catalogue */ searchGames: (query: string) => Promise; - getCatalogue: (category: CatalogueCategory) => Promise; + getCatalogue: () => Promise; getGameShopDetails: ( objectID: string, shop: GameShop, @@ -58,7 +59,7 @@ declare global { shop: GameShop, executablePath: string | null ) => Promise; - getLibrary: () => Promise; + getLibrary: () => Promise[]>; openGameInstaller: (gameId: number) => Promise; openGame: (gameId: number, executablePath: string) => Promise; closeGame: (gameId: number) => Promise; @@ -77,6 +78,14 @@ declare global { autoLaunch: (enabled: boolean) => Promise; authenticateRealDebrid: (apiToken: string) => Promise; + /* Download sources */ + getDownloadSources: () => Promise; + validateDownloadSource: ( + url: string + ) => Promise<{ name: string; downloadCount: number }>; + addDownloadSource: (url: string) => Promise; + removeDownloadSource: (id: number) => Promise; + /* Hardware */ getDiskFreeSpace: (path: string) => Promise; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index 975940b9..3a64455d 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -40,3 +40,7 @@ export const buildGameDetailsPath = ( const searchParams = new URLSearchParams({ title: game.title, ...params }); return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`; }; + +export const numberFormatter = new Intl.NumberFormat("en-US", { + maximumSignificantDigits: 3, +}); diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index e3e221e1..2a6b1c6f 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -99,12 +99,7 @@ export function Downloads() { } if (game.progress === 1) { - return ( - <> -

{game.repack?.title}

-

{t("completed")}

- - ); + return

{t("completed")}

; } if (game.status === "paused") { diff --git a/src/renderer/src/pages/game-details/game-details.context.tsx b/src/renderer/src/pages/game-details/game-details.context.tsx index 87cd97a6..2c3fd61a 100644 --- a/src/renderer/src/pages/game-details/game-details.context.tsx +++ b/src/renderer/src/pages/game-details/game-details.context.tsx @@ -21,6 +21,7 @@ export interface GameDetailsContext { game: Game | null; shopDetails: ShopDetails | null; repacks: GameRepack[]; + shop: GameShop; gameTitle: string; isGameRunning: boolean; isLoading: boolean; @@ -35,6 +36,7 @@ export const gameDetailsContext = createContext({ game: null, shopDetails: null, repacks: [], + shop: "steam", gameTitle: "", isGameRunning: false, isLoading: false, @@ -92,7 +94,7 @@ export function GameDetailsContextProvider({ }, [updateGame, isGameDownloading, lastPacket?.game.status]); useEffect(() => { - Promise.all([ + Promise.allSettled([ window.electron.getGameShopDetails( objectID!, shop as GameShop, @@ -100,9 +102,12 @@ export function GameDetailsContextProvider({ ), window.electron.searchGameRepacks(gameTitle), ]) - .then(([appDetails, repacks]) => { - if (appDetails) setGameDetails(appDetails); - setRepacks(repacks); + .then(([appDetailsResult, repacksResult]) => { + if (appDetailsResult.status === "fulfilled") + setGameDetails(appDetailsResult.value); + + if (repacksResult.status === "fulfilled") + setRepacks(repacksResult.value); }) .finally(() => { setIsLoading(false); @@ -174,6 +179,7 @@ export function GameDetailsContextProvider({ value={{ game, shopDetails, + shop: shop as GameShop, repacks, gameTitle, isGameRunning, diff --git a/src/renderer/src/pages/game-details/modals/installation-guides/dodi-installation-guide.tsx b/src/renderer/src/pages/game-details/modals/installation-guides/dodi-installation-guide.tsx index 548579c6..1760bf68 100644 --- a/src/renderer/src/pages/game-details/modals/installation-guides/dodi-installation-guide.tsx +++ b/src/renderer/src/pages/game-details/modals/installation-guides/dodi-installation-guide.tsx @@ -46,7 +46,9 @@ export function DODIInstallationGuide({ flexDirection: "column", }} > -

+

diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.css.ts b/src/renderer/src/pages/game-details/sidebar/sidebar.css.ts index e6d8b60a..1afb864d 100644 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.css.ts +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.css.ts @@ -6,6 +6,7 @@ export const contentSidebar = style({ borderLeft: `solid 1px ${vars.color.border};`, width: "100%", height: "100%", + position: "relative", "@media": { "(min-width: 768px)": { width: "100%", @@ -86,6 +87,14 @@ export const howLongToBeatCategorySkeleton = style({ height: "76px", }); +export const technicalDetailsContainer = style({ + padding: `0 ${SPACING_UNIT * 2}px`, + color: vars.color.body, + userSelect: "text", + position: "absolute", + bottom: `${SPACING_UNIT}px`, +}); + globalStyle(`${requirementsDetails} a`, { display: "flex", color: vars.color.body, diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index 780f5964..eaa5c722 100644 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -16,7 +16,8 @@ export function Sidebar() { const [activeRequirement, setActiveRequirement] = useState("minimum"); - const { gameTitle, shopDetails, objectID } = useContext(gameDetailsContext); + const { gameTitle, shopDetails, shop, objectID } = + useContext(gameDetailsContext); const { t } = useTranslation("game_details"); @@ -74,6 +75,15 @@ export function Sidebar() { }), }} /> + +

+

+ shop: "{shop}" +

+

+ objectID: "{objectID}" +

+
); } diff --git a/src/renderer/src/pages/home/home.css.ts b/src/renderer/src/pages/home/home.css.ts index 8caeffcf..b44da04a 100644 --- a/src/renderer/src/pages/home/home.css.ts +++ b/src/renderer/src/pages/home/home.css.ts @@ -1,15 +1,11 @@ import { style } from "@vanilla-extract/css"; import { SPACING_UNIT, vars } from "../../theme.css"; -export const homeCategories = style({ - display: "flex", - gap: `${SPACING_UNIT}px`, -}); - export const homeHeader = style({ display: "flex", gap: `${SPACING_UNIT}px`, justifyContent: "space-between", + alignItems: "center", }); export const content = style({ diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index 85f72250..4ae2184e 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -1,15 +1,11 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import { Button, GameCard, Hero } from "@renderer/components"; -import { - Steam250Game, - type CatalogueCategory, - type CatalogueEntry, -} from "@types"; +import type { Steam250Game, CatalogueEntry } from "@types"; import starsAnimation from "@renderer/assets/lottie/stars.json"; @@ -18,8 +14,6 @@ import { vars } from "../../theme.css"; import Lottie from "lottie-react"; import { buildGameDetailsPath } from "@renderer/helpers"; -const categories: CatalogueCategory[] = ["trending", "recently_added"]; - export function Home() { const { t } = useTranslation("home"); const navigate = useNavigate(); @@ -27,22 +21,15 @@ export function Home() { const [isLoading, setIsLoading] = useState(false); const [randomGame, setRandomGame] = useState(null); - const [searchParams] = useSearchParams(); + const [catalogue, setCatalogue] = useState([]); - const [catalogue, setCatalogue] = useState< - Record - >({ - trending: [], - recently_added: [], - }); - - const getCatalogue = useCallback((category: CatalogueCategory) => { + const getCatalogue = useCallback(() => { setIsLoading(true); window.electron - .getCatalogue(category) + .getCatalogue() .then((catalogue) => { - setCatalogue((prev) => ({ ...prev, [category]: catalogue })); + setCatalogue(catalogue); }) .catch(() => {}) .finally(() => { @@ -50,15 +37,6 @@ export function Home() { }); }, []); - const currentCategory = searchParams.get("category") || categories[0]; - - const handleSelectCategory = (category: CatalogueCategory) => { - if (category !== currentCategory) { - getCatalogue(category); - navigate(`/?category=${category}`); - } - }; - const getRandomGame = useCallback(() => { window.electron.getRandomGame().then((game) => { if (game) setRandomGame(game); @@ -80,9 +58,10 @@ export function Home() { useEffect(() => { setIsLoading(true); - getCatalogue(currentCategory as CatalogueCategory); + getCatalogue(); + getRandomGame(); - }, [getCatalogue, currentCategory, getRandomGame]); + }, [getCatalogue, getRandomGame]); return ( @@ -92,17 +71,7 @@ export function Home() {
-
- {categories.map((category) => ( - - ))} -
+

{t("trending")}

-

{t(currentCategory)}

-
{isLoading ? Array.from({ length: 12 }).map((_, index) => ( )) - : catalogue[currentCategory as CatalogueCategory].map((result) => ( + : catalogue.map((result) => ( void; + onAddDownloadSource: () => void; +} + +export function AddDownloadSourceModal({ + visible, + onClose, + onAddDownloadSource, +}: AddDownloadSourceModalProps) { + const [value, setValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const [validationResult, setValidationResult] = useState<{ + name: string; + downloadCount: number; + } | null>(null); + + const { t } = useTranslation("settings"); + + const handleValidateDownloadSource = async () => { + setIsLoading(true); + + try { + const result = await window.electron.validateDownloadSource(value); + setValidationResult(result); + console.log(result); + } finally { + setIsLoading(false); + } + }; + + const handleAddDownloadSource = async () => { + await window.electron.addDownloadSource(value); + onClose(); + onAddDownloadSource(); + }; + + return ( + +
+
+ setValue(e.target.value)} + /> + + +
+ + {validationResult && ( +
+
+

{validationResult?.name}

+ + Found {numberFormatter.format(validationResult?.downloadCount)}{" "} + download options + +
+ + +
+ )} +
+
+ ); +} diff --git a/src/renderer/src/pages/settings/settings-download-sources.css.ts b/src/renderer/src/pages/settings/settings-download-sources.css.ts new file mode 100644 index 00000000..ad5a618f --- /dev/null +++ b/src/renderer/src/pages/settings/settings-download-sources.css.ts @@ -0,0 +1,24 @@ +import { style } from "@vanilla-extract/css"; +import { SPACING_UNIT, vars } from "../../theme.css"; + +export const downloadSourceField = style({ + display: "flex", + gap: `${SPACING_UNIT}px`, +}); + +export const downloadSourceItem = style({ + display: "flex", + flexDirection: "column", + backgroundColor: vars.color.darkBackground, + borderRadius: "8px", + padding: `${SPACING_UNIT * 2}px`, + gap: `${SPACING_UNIT}px`, + border: `solid 1px ${vars.color.border}`, +}); + +export const downloadSourceItemHeader = style({ + marginBottom: `${SPACING_UNIT}px`, + display: "flex", + flexDirection: "column", + gap: `${SPACING_UNIT / 2}px`, +}); diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx new file mode 100644 index 00000000..fbb766d4 --- /dev/null +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from "react"; + +import { TextField, Button } from "@renderer/components"; +import { useTranslation } from "react-i18next"; + +import * as styles from "./settings-download-sources.css"; +import type { DownloadSource } from "@types"; +import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react"; +import { AddDownloadSourceModal } from "./add-download-source-modal"; +import { useToast } from "@renderer/hooks"; +import { numberFormatter } from "@renderer/helpers"; + +export function SettingsDownloadSources() { + const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] = + useState(false); + const [downloadSources, setDownloadSources] = useState([]); + + const { t } = useTranslation("settings"); + + const { showSuccessToast } = useToast(); + + const getDownloadSources = async () => { + return window.electron.getDownloadSources().then((sources) => { + setDownloadSources(sources); + }); + }; + + useEffect(() => { + getDownloadSources(); + }, []); + + const handleRemoveSource = async (id: number) => { + await window.electron.removeDownloadSource(id); + showSuccessToast("Removed download source"); + + getDownloadSources(); + }; + + const handleAddDownloadSource = async () => { + await getDownloadSources(); + showSuccessToast("Download source successfully added"); + }; + + return ( + <> + setShowAddDownloadSourceModal(false)} + onAddDownloadSource={handleAddDownloadSource} + /> + +

+ {t("download_sources_description")} +

+ + {downloadSources.map((downloadSource) => ( +
+
+

{downloadSource.name}

+ + {t("download_options", { + count: downloadSource.repackCount, + countFormatted: numberFormatter.format( + downloadSource.repackCount + ), + })} + +
+ +
+ + + +
+
+ ))} + + + + ); +} diff --git a/src/renderer/src/pages/settings/settings.tsx b/src/renderer/src/pages/settings/settings.tsx index 7aa4e845..c4b5964b 100644 --- a/src/renderer/src/pages/settings/settings.tsx +++ b/src/renderer/src/pages/settings/settings.tsx @@ -9,13 +9,19 @@ import { SettingsGeneral } from "./settings-general"; import { SettingsBehavior } from "./settings-behavior"; import { useAppDispatch } from "@renderer/hooks"; import { setUserPreferences } from "@renderer/features"; +import { SettingsDownloadSources } from "./settings-download-sources"; export function Settings() { const { t } = useTranslation("settings"); const dispatch = useAppDispatch(); - const categories = [t("general"), t("behavior"), "Real-Debrid"]; + const categories = [ + t("general"), + t("behavior"), + t("download_sources"), + "Real-Debrid", + ]; const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0); @@ -41,6 +47,10 @@ export function Settings() { ); } + if (currentCategoryIndex === 2) { + return ; + } + return ( ); diff --git a/src/renderer/src/pages/shared-modals/binary-not-found-modal.tsx b/src/renderer/src/pages/shared-modals/binary-not-found-modal.tsx index ec68b512..06a216cb 100644 --- a/src/renderer/src/pages/shared-modals/binary-not-found-modal.tsx +++ b/src/renderer/src/pages/shared-modals/binary-not-found-modal.tsx @@ -6,10 +6,10 @@ interface BinaryNotFoundModalProps { onClose: () => void; } -export const BinaryNotFoundModal = ({ +export function BinaryNotFoundModal({ visible, onClose, -}: BinaryNotFoundModalProps) => { +}: BinaryNotFoundModalProps) { const { t } = useTranslation("binary_not_found_modal"); return ( @@ -22,4 +22,4 @@ export const BinaryNotFoundModal = ({ {t("instructions")} ); -}; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index c925d5b5..dc4b57d0 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -18,3 +18,31 @@ export const formatBytes = (bytes: number): string => { return `${Math.trunc(formatedByte * 10) / 10} ${FORMAT[base]}`; }; + +export const pipe = + (...fns: ((arg: T) => any)[]) => + (arg: T) => + fns.reduce((prev, fn) => fn(prev), arg); + +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 formatName = pipe( + removeReleaseYearFromName, + removeSymbolsFromName, + removeSpecialEditionFromName, + removeDuplicateSpaces, + (str) => str.trim() +); diff --git a/src/types/index.ts b/src/types/index.ts index 8f9a6157..8d50eb67 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,7 +3,6 @@ import type { Downloader } from "@shared"; import { ProgressInfo, UpdateInfo } from "electron-updater"; export type GameShop = "steam" | "epic"; -export type CatalogueCategory = "recently_added" | "trending"; export interface SteamGenre { id: string; @@ -61,7 +60,6 @@ export interface GameRepack { id: number; title: string; magnet: string; - page: number; repacker: string; fileSize: string | null; uploadDate: Date | string | null; @@ -97,7 +95,6 @@ export interface Game extends Omit { folderName: string; downloadPath: string | null; repacks: GameRepack[]; - repack: GameRepack | null; progress: number; bytesDownloaded: number; playTimeInMilliseconds: number; @@ -233,3 +230,12 @@ export interface RealDebridUser { export type AppUpdaterEvents = | { type: "update-available"; info: Partial } | { type: "update-downloaded" }; + +export interface DownloadSource { + id: number; + name: string; + url: string; + repackCount: number; + createdAt: Date; + updatedAt: Date; +} diff --git a/yarn.lock b/yarn.lock index 0edd5ade..bfdb4c24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1309,10 +1309,10 @@ dependencies: undici-types "~5.26.4" -"@types/node@^18.11.18": - version "18.19.33" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.33.tgz#98cd286a1b8a5e11aa06623210240bcc28e95c48" - integrity sha512-NR9+KrpSajr2qBVp/Yt5TU/rp+b5Mayi3+OlMlcg2cVCfRmcG5PWZ7S4+MG9PZ5gWBoc9Pd0BKSRViuBCRPu0A== +"@types/node@^20.9.0": + version "20.13.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.13.0.tgz#011a76bc1e71ae9a026dddcfd7039084f752c4b6" + integrity sha512-FM6AOb3khNkNIXPnHFDYaHerSv8uN22C91z098AnGccVu+Pcdhi+pNUFDi0iLmPIsVE0JBD0KVS7mzUYt4nRzQ== dependencies: undici-types "~5.26.4" @@ -1382,6 +1382,11 @@ resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== +"@types/user-agents@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@types/user-agents/-/user-agents-1.0.4.tgz#49bf6760d9ca3858d91e6258835bf2c329b87cf0" + integrity sha512-AjeFc4oX5WPPflgKfRWWJfkEk7Wu82fnj1rROPsiqFt6yElpdGFg8Srtm/4PU4rA9UiDUZlruGPgcwTMQlwq4w== + "@types/verror@^1.10.3": version "1.10.10" resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.10.tgz#d5a4b56abac169bfbc8b23d291363a682e6fa087" @@ -2719,13 +2724,13 @@ electron-vite@^2.0.0: magic-string "^0.30.5" picocolors "^1.0.0" -electron@^28.2.0: - version "28.3.1" - resolved "https://registry.yarnpkg.com/electron/-/electron-28.3.1.tgz#babb3ff8e246336e9cd1c1966f16a55ba723ea06" - integrity sha512-aF9fONuhVDJlctJS7YOw76ynxVAQdfIWmlhRMKits24tDcdSL0eMHUS0wWYiRfGWbQnUKB6V49Rf17o32f4/fg== +electron@^30.0.9: + version "30.0.9" + resolved "https://registry.yarnpkg.com/electron/-/electron-30.0.9.tgz#b11400e4642a4b635e79244ba365f1d401ee60b5" + integrity sha512-ArxgdGHVu3o5uaP+Tqj8cJDvU03R6vrGrOqiMs7JXLnvQHMqXJIIxmFKQAIdJW8VoT3ac3hD21tA7cPO10RLow== dependencies: "@electron/get" "^2.0.0" - "@types/node" "^18.11.18" + "@types/node" "^20.9.0" extract-zip "^2.0.1" emoji-regex@^8.0.0: @@ -4567,6 +4572,14 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +nice-napi@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nice-napi/-/nice-napi-1.0.2.tgz#dc0ab5a1eac20ce548802fc5686eaa6bc654927b" + integrity sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA== + dependencies: + node-addon-api "^3.0.0" + node-gyp-build "^4.2.2" + no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -4587,6 +4600,11 @@ node-addon-api@^1.6.3: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.2.tgz#3df30b95720b53c24e59948b49532b662444f54d" integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg== +node-addon-api@^3.0.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" + integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== + node-domexception@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" @@ -4608,6 +4626,11 @@ node-fetch@^3.3.0: fetch-blob "^3.1.4" formdata-polyfill "^4.0.10" +node-gyp-build@^4.2.2: + version "4.8.1" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.1.tgz#976d3ad905e71b76086f4f0b0d3637fe79b6cda5" + integrity sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw== + node-releases@^2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" @@ -4867,6 +4890,13 @@ picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +piscina@^4.5.1: + version "4.5.1" + resolved "https://registry.yarnpkg.com/piscina/-/piscina-4.5.1.tgz#103f4237ce88c3f9e8b63c278673bcbddc10a9a2" + integrity sha512-DVhySLPfqAW+uRH9dF0bjA2xEWr5ANLAzkYXx5adSLMFnwssSIVJYhg0FlvgYsnT/khILQJ3WkjqbAlBvt+maw== + optionalDependencies: + nice-napi "^1.0.2" + pkg-types@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.1.1.tgz#07b626880749beb607b0c817af63aac1845a73f2" @@ -6269,3 +6299,8 @@ yocto-queue@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==