mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-01-23 21:44:55 +03:00
Merge branch 'main' into feature/game-achievements
# Conflicts: # src/renderer/src/context/game-details/game-details.context.tsx # src/renderer/src/main.tsx
This commit is contained in:
commit
eda47fc6af
@ -23,6 +23,7 @@
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Hydra Catalogue](./docs/screenshot.png)
|
||||
|
@ -23,6 +23,7 @@
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Hydra Katalog](./docs/screenshot.png)
|
||||
|
186
README.da.md
Normal file
186
README.da.md
Normal file
@ -0,0 +1,186 @@
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[<img src="./resources/icon.png" width="144"/>](https://hydralauncher.site)
|
||||
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra er en spil launcher med sin egen indbyggede bittorrent klient.</strong>
|
||||
</p>
|
||||
|
||||
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
|
||||
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
|
||||
|
||||
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md)
|
||||
[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md)
|
||||
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md)
|
||||
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md)
|
||||
[![be](https://img.shields.io/badge/lang-be-orange)](README.be.md)
|
||||
[![es](https://img.shields.io/badge/lang-es-red)](README.es.md)
|
||||
[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md)
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
|
||||
![Hydra Catalogue](./docs/screenshot.png)
|
||||
|
||||
</div>
|
||||
|
||||
## Indholdsfortegnelse
|
||||
|
||||
- [Indholdsfortegnelse](#indholdsfortegnelse)
|
||||
- [Om](#om)
|
||||
- [Funktioner](#funktioner)
|
||||
- [Installation](#installation)
|
||||
- [Bidrag](#-bidrag)
|
||||
- [Bliv medlem af vores Telegram kanal](#-join-our-telegram)
|
||||
- [Fork og klon dit repo](#fork-and-clone-your-repository)
|
||||
- [Måder du kan bidrage](#ways-you-can-contribute)
|
||||
- [Projekt Struktur](#project-structure)
|
||||
- [Byg fra kildekode](#build-from-source)
|
||||
- [Installér Node.js](#install-nodejs)
|
||||
- [Installér Yarn](#install-yarn)
|
||||
- [Installér Node Afhængigheder](#install-node-dependencies)
|
||||
- [Installér Python 3.9](#install-python-39)
|
||||
- [Installér Python Afhængigheder](#install-python-dependencies)
|
||||
- [Miljøvariabler](#environment-variables)
|
||||
- [Køre](#running)
|
||||
- [Bygge](#build)
|
||||
- [Bygge bittorrent klienten](#build-the-bittorrent-client)
|
||||
- [Bygge Electron applikationen](#build-the-electron-application)
|
||||
- [Bidragere](#contributors)
|
||||
- [Licens](#license)
|
||||
|
||||
## Om
|
||||
|
||||
**Hydra** er en **Spil Launcher** med sin egen indbyggede **BitTorrent Klient**.
|
||||
<br>
|
||||
Launcheren er skrevet i TypeScript (Electron) og Python, som håndterer torrenting system ved brug af libtorrent.
|
||||
|
||||
## Funktioner
|
||||
|
||||
- Sin egen indbyggede bittorrent klient
|
||||
- How Long To Beat (HLTB) integration på spil siden
|
||||
- Downloadsti tilpasning
|
||||
- Windows og Linux understøttelse
|
||||
- Konstant opdateret
|
||||
- Og mere ...
|
||||
|
||||
## Installation
|
||||
|
||||
Følg trinene her under for at installere:
|
||||
|
||||
1. Download den seneste version af Hydra fra [Releases](https://github.com/hydralauncher/hydra/releases/latest) siden.
|
||||
- Download kun .exe hvis du vil installere Hydra på Windows.
|
||||
- Download .deb, .rpm eller .zip hvis du vil installere Hydra på Linux. (afhænger af din Linux distro)
|
||||
2. Kør den downloadede fil.
|
||||
3. Nyd Hydra!
|
||||
|
||||
## <a name="bidrag"> Bidrag
|
||||
|
||||
### <a name="join-our-telegram"></a> Bliv medlem af vores Telegram kanal
|
||||
|
||||
Vi holder vores diskusioner i vores [Telegram](https://t.me/hydralauncher) kanal.
|
||||
|
||||
### Fork og klon dit repo
|
||||
|
||||
1. Fork repoet [(klik her for at forke nu)](https://github.com/hydralauncher/hydra/fork)
|
||||
2. Klon din forkede kode `git clone https://github.com/dit_brugernavn/hydra`
|
||||
3. Lav en ny branch
|
||||
4. Skub dine commits
|
||||
5. Indsend en ny Pull Request
|
||||
|
||||
### Måder du kan bidrage
|
||||
|
||||
- Oversættelse: Vi vil gerne have at Hydra er tilgængeligt for så mange folk som overhovedet muligt. Du er velkommen til at hjælpe med at oversætte til nye sprog eller at opdatere og forbedre de sprog som allerede er tilgængelige i Hydra.
|
||||
- Kode: Hydra er lavet med Typescript, Electron og en lille smule Python. Hvis du har lyst til at bidrage, kan du blive medlem af vores [Telegram](https://t.me/hydralauncher) kanal! (Alt kommunikation foregår hovedsageligt på Engelsk, Brasiliansk eller Russisk)
|
||||
|
||||
### Projekt struktur
|
||||
|
||||
- torrent-client: Vi bruger libtorrent, et Python bibliotek, til at administrere torrent downloads
|
||||
- src/renderer: UI'en i applikationen
|
||||
- src/main: her har vi al logikken
|
||||
|
||||
## Byg fra kildekode
|
||||
|
||||
### Installér Node.js
|
||||
|
||||
Vær sikker på at du har Node.js installeret på din maskine. Hvis ikke, kan du downloade og installere det fra [nodejs.org](https://nodejs.org/).
|
||||
|
||||
### Installér Yarn
|
||||
|
||||
Yarn er et pakkehåndteringsprogram til Node.js. Hvis du ikke har installeret Yarn endnu, så kan du gøre det ved at følge instruktionerne på [yarnpkg.com](https://classic.yarnpkg.com/lang/en/docs/install/).
|
||||
|
||||
### Installér Node Afhængigheder
|
||||
|
||||
Navigér til projekt mappen og installér Node afhængighederne ved bruge af Yarn:
|
||||
|
||||
```bash
|
||||
cd hydra
|
||||
yarn
|
||||
```
|
||||
|
||||
### Installér Python 3.9
|
||||
|
||||
Vær sikker på at du har Python 3.9 installeret på din maskine. Du kan downloade og installere det her: [python.org](https://www.python.org/downloads/release/python-3913/).
|
||||
|
||||
### Installér Python Afhængigheder
|
||||
|
||||
Installér de påkrævede Python afhængigheder ved brug af pip:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Miljøvariabler
|
||||
|
||||
Du får brug for en SteamGridDB API nøgle for at kunne hente spil ikonerne under installationen.
|
||||
|
||||
Når du har det, kan du kopiere og omdøbe `.env.example` filen til `.env` og indsætte nøglen som `STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## Køre
|
||||
|
||||
Når alt er sat op, kan du køre den følgende kommando for at starte både Electron processen og bittorrent klienten:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Bygge
|
||||
|
||||
### Byg bittorrent klienten
|
||||
|
||||
Byg bittorrent klienten ved brug af følgende kommando:
|
||||
|
||||
```bash
|
||||
python torrent-client/setup.py build
|
||||
```
|
||||
|
||||
### Byg Electron applikationen
|
||||
|
||||
Byg Electron applikationen ved brug af følgende kommando:
|
||||
|
||||
På Windows:
|
||||
|
||||
```bash
|
||||
yarn build:win
|
||||
```
|
||||
|
||||
På Linux:
|
||||
|
||||
```bash
|
||||
yarn build:linux
|
||||
```
|
||||
|
||||
## Bidragere
|
||||
|
||||
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=hydralauncher/hydra" />
|
||||
</a>
|
||||
|
||||
## Licens
|
||||
|
||||
Hydra benytter sig af [MIT Licensen](LICENSE).
|
@ -23,6 +23,7 @@
|
||||
[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md)
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Hydra Katalog](./docs/screenshot.png)
|
||||
|
@ -23,6 +23,7 @@
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Hydra Catalogue](./docs/screenshot.png)
|
||||
|
@ -23,6 +23,7 @@
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Catalogue Hydra](./docs/screenshot.png)
|
||||
|
@ -23,6 +23,7 @@
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Hydra Catalogue](./docs/screenshot.png)
|
||||
|
@ -23,6 +23,7 @@
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Hydra Catalogue](./docs/screenshot.png)
|
||||
|
@ -23,6 +23,7 @@
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Hydra Catalogue](./docs/screenshot.png)
|
||||
|
@ -23,6 +23,7 @@
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Hydra Catalogue](./docs/screenshot.png)
|
||||
|
@ -23,6 +23,7 @@
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Hydra Catalogue](./docs/screenshot.png)
|
||||
|
@ -23,6 +23,7 @@
|
||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||
|
||||
![Hydra Catalogue](./docs/screenshot.png)
|
||||
|
@ -1,4 +1,4 @@
|
||||
appId: site.hydralauncher.hydra
|
||||
appId: gg.hydralauncher.hydra
|
||||
productName: Hydra
|
||||
directories:
|
||||
buildResources: build
|
||||
|
@ -51,6 +51,7 @@
|
||||
"color.js": "^1.2.0",
|
||||
"create-desktop-shortcuts": "^1.11.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dexie": "^4.0.8",
|
||||
"electron-log": "^5.1.4",
|
||||
"electron-updater": "^6.1.8",
|
||||
"fetch-cookie": "^3.0.1",
|
||||
|
@ -1,8 +1,8 @@
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi, RepacksManager } from "@main/services";
|
||||
import { CatalogueCategory, formatName, steamUrlBuilder } from "@shared";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { CatalogueCategory, steamUrlBuilder } from "@shared";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
|
||||
const getCatalogue = async (
|
||||
@ -26,14 +26,9 @@ const getCatalogue = async (
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
const repacks = RepacksManager.search({
|
||||
query: formatName(steamGame.name),
|
||||
});
|
||||
|
||||
return {
|
||||
title: steamGame.name,
|
||||
shop: game.shop,
|
||||
repacks,
|
||||
cover: steamUrlBuilder.library(game.objectId),
|
||||
objectID: game.objectId,
|
||||
};
|
||||
|
@ -45,15 +45,17 @@ const getGameShopDetails = async (
|
||||
|
||||
const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
|
||||
(result) => {
|
||||
gameShopCacheRepository.upsert(
|
||||
{
|
||||
objectID,
|
||||
shop: "steam",
|
||||
language,
|
||||
serializedData: JSON.stringify(result),
|
||||
},
|
||||
["objectID"]
|
||||
);
|
||||
if (result) {
|
||||
gameShopCacheRepository.upsert(
|
||||
{
|
||||
objectID,
|
||||
shop: "steam",
|
||||
language,
|
||||
serializedData: JSON.stringify(result),
|
||||
},
|
||||
["objectID"]
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
@ -2,8 +2,7 @@ import type { CatalogueEntry } from "@types";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
|
||||
import { RepacksManager } from "@main/services";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
const getGames = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@ -15,13 +14,14 @@ const getGames = async (
|
||||
{ name: "list" }
|
||||
);
|
||||
|
||||
const entries = RepacksManager.findRepacksForCatalogueEntries(
|
||||
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
|
||||
);
|
||||
|
||||
return {
|
||||
results: entries,
|
||||
cursor: cursor + entries.length,
|
||||
results: steamGames.map((steamGame) => ({
|
||||
title: steamGame.name,
|
||||
shop: "steam",
|
||||
cover: steamUrlBuilder.library(steamGame.id),
|
||||
objectID: steamGame.id,
|
||||
})),
|
||||
cursor: cursor + steamGames.length,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -3,32 +3,15 @@ import { shuffle } from "lodash-es";
|
||||
import { getSteam250List } from "@main/services";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { getSteamGameById } from "../helpers/search-games";
|
||||
import type { Steam250Game } from "@types";
|
||||
|
||||
const state = { games: Array<Steam250Game>(), index: 0 };
|
||||
|
||||
const filterGames = async (games: Steam250Game[]) => {
|
||||
const results: Steam250Game[] = [];
|
||||
|
||||
for (const game of games) {
|
||||
const steamGame = await getSteamGameById(game.objectID);
|
||||
|
||||
if (steamGame?.repacks.length) {
|
||||
results.push(game);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
if (state.games.length == 0) {
|
||||
const steam250List = await getSteam250List();
|
||||
|
||||
const filteredSteam250List = await filterGames(steam250List);
|
||||
|
||||
state.games = shuffle(filteredSteam250List);
|
||||
state.games = shuffle(steam250List);
|
||||
}
|
||||
|
||||
if (state.games.length == 0) {
|
||||
|
@ -1,9 +0,0 @@
|
||||
import { RepacksManager } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const searchGameRepacks = (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
query: string
|
||||
) => RepacksManager.search({ query });
|
||||
|
||||
registerEvent("searchGameRepacks", searchGameRepacks);
|
@ -1,7 +1,7 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
|
||||
import { CatalogueEntry } from "@types";
|
||||
import { HydraApi, RepacksManager } from "@main/services";
|
||||
import { HydraApi } from "@main/services";
|
||||
|
||||
const searchGamesEvent = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@ -11,15 +11,13 @@ const searchGamesEvent = async (
|
||||
{ objectId: string; title: string; shop: string }[]
|
||||
>("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false });
|
||||
|
||||
const steamGames = games.map((game) => {
|
||||
return games.map((game) => {
|
||||
return convertSteamGameToCatalogueEntry({
|
||||
id: Number(game.objectId),
|
||||
name: game.title,
|
||||
clientIcon: null,
|
||||
});
|
||||
});
|
||||
|
||||
return RepacksManager.findRepacksForCatalogueEntries(steamGames);
|
||||
};
|
||||
|
||||
registerEvent("searchGames", searchGamesEvent);
|
||||
|
@ -1,42 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import { DownloadSource } from "@main/entity";
|
||||
import axios from "axios";
|
||||
import { downloadSourceSchema } from "../helpers/validators";
|
||||
import { insertDownloadsFromSource } from "@main/helpers";
|
||||
import { RepacksManager } from "@main/services";
|
||||
|
||||
const addDownloadSource = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
url: string
|
||||
) => {
|
||||
const response = await axios.get(url);
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const downloadSource = await dataSource.transaction(
|
||||
async (transactionalEntityManager) => {
|
||||
const downloadSource = await transactionalEntityManager
|
||||
.getRepository(DownloadSource)
|
||||
.save({
|
||||
url,
|
||||
name: source.name,
|
||||
downloadCount: source.downloads.length,
|
||||
});
|
||||
|
||||
await insertDownloadsFromSource(
|
||||
transactionalEntityManager,
|
||||
downloadSource,
|
||||
source.downloads
|
||||
);
|
||||
|
||||
return downloadSource;
|
||||
}
|
||||
);
|
||||
|
||||
await RepacksManager.updateRepacks();
|
||||
|
||||
return downloadSource;
|
||||
};
|
||||
|
||||
registerEvent("addDownloadSource", addDownloadSource);
|
@ -0,0 +1,9 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { knexClient } from "@main/knex-client";
|
||||
|
||||
const deleteDownloadSource = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
id: number
|
||||
) => knexClient("download_source").where({ id }).delete();
|
||||
|
||||
registerEvent("deleteDownloadSource", deleteDownloadSource);
|
@ -1,11 +1,7 @@
|
||||
import { downloadSourceRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { knexClient } from "@main/knex-client";
|
||||
|
||||
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
|
||||
downloadSourceRepository.find({
|
||||
order: {
|
||||
createdAt: "DESC",
|
||||
},
|
||||
});
|
||||
knexClient.select("*").from("download_source");
|
||||
|
||||
registerEvent("getDownloadSources", getDownloadSources);
|
||||
|
@ -1,13 +0,0 @@
|
||||
import { downloadSourceRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { RepacksManager } from "@main/services";
|
||||
|
||||
const removeDownloadSource = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
id: number
|
||||
) => {
|
||||
await downloadSourceRepository.delete(id);
|
||||
await RepacksManager.updateRepacks();
|
||||
};
|
||||
|
||||
registerEvent("removeDownloadSource", removeDownloadSource);
|
@ -1,7 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { fetchDownloadSourcesAndUpdate } from "@main/helpers";
|
||||
|
||||
const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
|
||||
fetchDownloadSourcesAndUpdate();
|
||||
|
||||
registerEvent("syncDownloadSources", syncDownloadSources);
|
@ -1,27 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { downloadSourceRepository } from "@main/repository";
|
||||
import { RepacksManager } from "@main/services";
|
||||
import { downloadSourceWorker } from "@main/workers";
|
||||
|
||||
const validateDownloadSource = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
url: string
|
||||
) => {
|
||||
const existingSource = await downloadSourceRepository.findOne({
|
||||
where: { url },
|
||||
});
|
||||
|
||||
if (existingSource)
|
||||
throw new Error("Source with the same url already exists");
|
||||
|
||||
const repacks = RepacksManager.repacks;
|
||||
|
||||
return downloadSourceWorker.run(
|
||||
{ url, repacks },
|
||||
{
|
||||
name: "validateDownloadSource",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("validateDownloadSource", validateDownloadSource);
|
@ -1,7 +1,6 @@
|
||||
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
|
||||
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { RepacksManager } from "@main/services";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
export interface SearchGamesArgs {
|
||||
@ -17,7 +16,6 @@ export const convertSteamGameToCatalogueEntry = (
|
||||
title: game.name,
|
||||
shop: "steam" as GameShop,
|
||||
cover: steamUrlBuilder.library(String(game.id)),
|
||||
repacks: [],
|
||||
});
|
||||
|
||||
export const getSteamGameById = async (
|
||||
@ -29,9 +27,5 @@ export const getSteamGameById = async (
|
||||
|
||||
if (!steamGame) return null;
|
||||
|
||||
const catalogueEntry = convertSteamGameToCatalogueEntry(steamGame);
|
||||
|
||||
const result = RepacksManager.findRepacksForCatalogueEntry(catalogueEntry);
|
||||
|
||||
return result;
|
||||
return convertSteamGameToCatalogueEntry(steamGame);
|
||||
};
|
||||
|
@ -1,13 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const downloadSourceSchema = z.object({
|
||||
name: z.string().max(255),
|
||||
downloads: z.array(
|
||||
z.object({
|
||||
title: z.string().max(255),
|
||||
uris: z.array(z.string()),
|
||||
uploadDate: z.string().max(255),
|
||||
fileSize: z.string().max(255),
|
||||
})
|
||||
),
|
||||
});
|
@ -7,7 +7,6 @@ import "./catalogue/get-games";
|
||||
import "./catalogue/get-how-long-to-beat";
|
||||
import "./catalogue/get-random-game";
|
||||
import "./catalogue/search-games";
|
||||
import "./catalogue/search-game-repacks";
|
||||
import "./catalogue/get-game-stats";
|
||||
import "./catalogue/get-trending-games";
|
||||
import "./catalogue/get-game-achievements";
|
||||
@ -38,11 +37,8 @@ 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/delete-download-source";
|
||||
import "./download-sources/get-download-sources";
|
||||
import "./download-sources/validate-download-source";
|
||||
import "./download-sources/add-download-source";
|
||||
import "./download-sources/remove-download-source";
|
||||
import "./download-sources/sync-download-sources";
|
||||
import "./auth/sign-out";
|
||||
import "./auth/open-auth-window";
|
||||
import "./auth/get-session-hash";
|
||||
@ -61,6 +57,7 @@ import "./profile/update-profile";
|
||||
import "./profile/process-profile-image";
|
||||
import "./profile/send-friend-request";
|
||||
import "./profile/sync-friend-requests";
|
||||
import "./notifications/publish-new-repacks-notification";
|
||||
import { isPortableVersion } from "@main/helpers";
|
||||
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
|
@ -3,7 +3,6 @@ import { gameRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
import type { GameShop } from "@types";
|
||||
import { getFileBase64 } from "@main/helpers";
|
||||
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { createGame } from "@main/services/library-sync";
|
||||
@ -37,20 +36,12 @@ const addGameToLibrary = async (
|
||||
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
||||
: null;
|
||||
|
||||
await gameRepository
|
||||
.insert({
|
||||
title,
|
||||
iconUrl,
|
||||
objectID,
|
||||
shop,
|
||||
})
|
||||
.then(() => {
|
||||
if (iconUrl) {
|
||||
getFileBase64(iconUrl).then((base64) =>
|
||||
gameRepository.update({ objectID }, { iconUrl: base64 })
|
||||
);
|
||||
}
|
||||
});
|
||||
await gameRepository.insert({
|
||||
title,
|
||||
iconUrl,
|
||||
objectID,
|
||||
shop,
|
||||
});
|
||||
}
|
||||
|
||||
updateLocalUnlockedAchivements(true, objectID);
|
||||
|
@ -0,0 +1,29 @@
|
||||
import { Notification } from "electron";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { userPreferencesRepository } from "@main/repository";
|
||||
import { t } from "i18next";
|
||||
|
||||
const publishNewRepacksNotification = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
newRepacksCount: number
|
||||
) => {
|
||||
if (newRepacksCount < 1) return;
|
||||
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (userPreferences?.repackUpdatesNotificationsEnabled) {
|
||||
new Notification({
|
||||
title: t("repack_list_updated", {
|
||||
ns: "notifications",
|
||||
}),
|
||||
body: t("repack_count", {
|
||||
ns: "notifications",
|
||||
count: newRepacksCount,
|
||||
}),
|
||||
}).show();
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("publishNewRepacksNotification", publishNewRepacksNotification);
|
@ -1,7 +1,6 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
import type { StartGameDownloadPayload } from "@types";
|
||||
import { getFileBase64 } from "@main/helpers";
|
||||
import { DownloadManager, HydraApi, logger } from "@main/services";
|
||||
|
||||
import { Not } from "typeorm";
|
||||
@ -9,36 +8,25 @@ import { steamGamesWorker } from "@main/workers";
|
||||
import { createGame } from "@main/services/library-sync";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import { DownloadQueue, Game, Repack } from "@main/entity";
|
||||
import { DownloadQueue, Game } from "@main/entity";
|
||||
|
||||
const startGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
payload: StartGameDownloadPayload
|
||||
) => {
|
||||
const { repackId, objectID, title, shop, downloadPath, downloader, uri } =
|
||||
payload;
|
||||
const { objectID, title, shop, downloadPath, downloader, uri } = payload;
|
||||
|
||||
return dataSource.transaction(async (transactionalEntityManager) => {
|
||||
const gameRepository = transactionalEntityManager.getRepository(Game);
|
||||
const repackRepository = transactionalEntityManager.getRepository(Repack);
|
||||
const downloadQueueRepository =
|
||||
transactionalEntityManager.getRepository(DownloadQueue);
|
||||
|
||||
const [game, repack] = await Promise.all([
|
||||
gameRepository.findOne({
|
||||
where: {
|
||||
objectID,
|
||||
shop,
|
||||
},
|
||||
}),
|
||||
repackRepository.findOne({
|
||||
where: {
|
||||
id: repackId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!repack) return;
|
||||
const game = await gameRepository.findOne({
|
||||
where: {
|
||||
objectID,
|
||||
shop,
|
||||
},
|
||||
});
|
||||
|
||||
await DownloadManager.pauseDownload();
|
||||
|
||||
@ -71,26 +59,16 @@ const startGameDownload = async (
|
||||
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
||||
: null;
|
||||
|
||||
await gameRepository
|
||||
.insert({
|
||||
title,
|
||||
iconUrl,
|
||||
objectID,
|
||||
downloader,
|
||||
shop,
|
||||
status: "active",
|
||||
downloadPath,
|
||||
uri,
|
||||
})
|
||||
.then((result) => {
|
||||
if (iconUrl) {
|
||||
getFileBase64(iconUrl).then((base64) =>
|
||||
gameRepository.update({ objectID }, { iconUrl: base64 })
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
await gameRepository.insert({
|
||||
title,
|
||||
iconUrl,
|
||||
objectID,
|
||||
downloader,
|
||||
shop,
|
||||
status: "active",
|
||||
downloadPath,
|
||||
uri,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedGame = await gameRepository.findOne({
|
||||
|
@ -73,7 +73,6 @@ const getUser = async (
|
||||
recentGames,
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
@ -1,76 +0,0 @@
|
||||
import { dataSource } from "@main/data-source";
|
||||
import { DownloadSource, Repack } from "@main/entity";
|
||||
import { downloadSourceSchema } from "@main/events/helpers/validators";
|
||||
import { downloadSourceRepository } from "@main/repository";
|
||||
import { RepacksManager } from "@main/services";
|
||||
import { downloadSourceWorker } from "@main/workers";
|
||||
import { chunk } from "lodash-es";
|
||||
import type { EntityManager } from "typeorm";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { z } from "zod";
|
||||
|
||||
export const insertDownloadsFromSource = async (
|
||||
trx: EntityManager,
|
||||
downloadSource: DownloadSource,
|
||||
downloads: z.infer<typeof downloadSourceSchema>["downloads"]
|
||||
) => {
|
||||
const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map(
|
||||
(download) => ({
|
||||
title: download.title,
|
||||
uris: JSON.stringify(download.uris),
|
||||
magnet: download.uris[0]!,
|
||||
fileSize: download.fileSize,
|
||||
repacker: downloadSource.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSource: { id: downloadSource.id },
|
||||
})
|
||||
);
|
||||
|
||||
const downloadsChunks = chunk(repacks, 800);
|
||||
|
||||
for (const chunk of downloadsChunks) {
|
||||
await trx
|
||||
.getRepository(Repack)
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values(chunk)
|
||||
.updateEntity(false)
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchDownloadSourcesAndUpdate = async () => {
|
||||
const downloadSources = await downloadSourceRepository.find({
|
||||
order: {
|
||||
id: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const results = await downloadSourceWorker.run(downloadSources, {
|
||||
name: "getUpdatedRepacks",
|
||||
});
|
||||
|
||||
await dataSource.transaction(async (transactionalEntityManager) => {
|
||||
for (const result of results) {
|
||||
if (result.etag !== null) {
|
||||
await transactionalEntityManager.getRepository(DownloadSource).update(
|
||||
{ id: result.id },
|
||||
{
|
||||
etag: result.etag,
|
||||
status: result.status,
|
||||
downloadCount: result.downloads.length,
|
||||
}
|
||||
);
|
||||
|
||||
await insertDownloadsFromSource(
|
||||
transactionalEntityManager,
|
||||
result,
|
||||
result.downloads
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await RepacksManager.updateRepacks();
|
||||
});
|
||||
};
|
@ -7,16 +7,6 @@ export const getFileBuffer = async (url: string) =>
|
||||
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
|
||||
);
|
||||
|
||||
export const getFileBase64 = async (url: string) =>
|
||||
fetch(url, { method: "GET" }).then((response) =>
|
||||
response.arrayBuffer().then((buffer) => {
|
||||
const base64 = Buffer.from(buffer).toString("base64");
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
return `data:${contentType};base64,${base64}`;
|
||||
})
|
||||
);
|
||||
|
||||
export const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@ -36,6 +26,4 @@ export const requestWebPage = async (url: string) => {
|
||||
};
|
||||
|
||||
export const isPortableVersion = () =>
|
||||
process.env.PORTABLE_EXECUTABLE_FILE != null;
|
||||
|
||||
export * from "./download-source";
|
||||
process.env.PORTABLE_EXECUTABLE_FILE !== null;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, net, protocol } from "electron";
|
||||
import { app, BrowserWindow, net, protocol, session } from "electron";
|
||||
import { init } from "@sentry/electron/main";
|
||||
import updater from "electron-updater";
|
||||
import i18n from "i18next";
|
||||
@ -68,14 +68,13 @@ const runMigrations = async () => {
|
||||
});
|
||||
|
||||
await knexClient.migrate.latest(migrationConfig);
|
||||
await knexClient.destroy();
|
||||
};
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(async () => {
|
||||
electronApp.setAppUserModelId("site.hydralauncher.hydra");
|
||||
electronApp.setAppUserModelId("gg.hydralauncher.hydra");
|
||||
|
||||
protocol.handle("local", (request) => {
|
||||
const filePath = request.url.slice("local:".length);
|
||||
@ -105,6 +104,46 @@ app.whenReady().then(async () => {
|
||||
WindowManager.createMainWindow();
|
||||
WindowManager.createNotificationWindow();
|
||||
WindowManager.createSystemTray(userPreferences?.language || "en");
|
||||
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
callback({
|
||||
requestHeaders: {
|
||||
...details.requestHeaders,
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
const headers = {
|
||||
"access-control-allow-origin": ["*"],
|
||||
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],
|
||||
"access-control-expose-headers": ["ETag"],
|
||||
"access-control-allow-headers": [
|
||||
"Content-Type, Authorization, X-Requested-With, If-None-Match",
|
||||
],
|
||||
"access-control-allow-credentials": ["true"],
|
||||
};
|
||||
|
||||
if (details.method === "OPTIONS") {
|
||||
callback({
|
||||
cancel: false,
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
...headers,
|
||||
},
|
||||
statusLine: "HTTP/1.1 200 OK",
|
||||
});
|
||||
} else {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
|
@ -1,25 +1,14 @@
|
||||
import {
|
||||
DownloadManager,
|
||||
RepacksManager,
|
||||
PythonInstance,
|
||||
startMainLoop,
|
||||
} from "./services";
|
||||
import { DownloadManager, PythonInstance, startMainLoop } from "./services";
|
||||
import {
|
||||
downloadQueueRepository,
|
||||
repackRepository,
|
||||
userPreferencesRepository,
|
||||
} from "./repository";
|
||||
import { UserPreferences } from "./entity";
|
||||
import { RealDebridClient } from "./services/real-debrid";
|
||||
import { fetchDownloadSourcesAndUpdate } from "./helpers";
|
||||
import { publishNewRepacksNotifications } from "./services/notifications";
|
||||
import { MoreThan } from "typeorm";
|
||||
import { HydraApi } from "./services/hydra-api";
|
||||
import { uploadGamesBatch } from "./services/library-sync";
|
||||
|
||||
const loadState = async (userPreferences: UserPreferences | null) => {
|
||||
RepacksManager.updateRepacks();
|
||||
|
||||
import("./events");
|
||||
|
||||
if (userPreferences?.realDebridApiToken) {
|
||||
@ -46,18 +35,6 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
||||
}
|
||||
|
||||
startMainLoop();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
fetchDownloadSourcesAndUpdate().then(async () => {
|
||||
const newRepacksCount = await repackRepository.count({
|
||||
where: {
|
||||
createdAt: MoreThan(now),
|
||||
},
|
||||
});
|
||||
|
||||
if (newRepacksCount > 0) publishNewRepacksNotifications(newRepacksCount);
|
||||
});
|
||||
};
|
||||
|
||||
userPreferencesRepository
|
||||
|
@ -7,5 +7,4 @@ export * from "./download";
|
||||
export * from "./how-long-to-beat";
|
||||
export * from "./process-watcher";
|
||||
export * from "./main-loop";
|
||||
export * from "./repacks-manager";
|
||||
export * from "./hydra-api";
|
||||
|
@ -49,24 +49,6 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const publishNewRepacksNotifications = async (count: number) => {
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (userPreferences?.repackUpdatesNotificationsEnabled) {
|
||||
new Notification({
|
||||
title: t("repack_list_updated", {
|
||||
ns: "notifications",
|
||||
}),
|
||||
body: t("repack_count", {
|
||||
ns: "notifications",
|
||||
count: count,
|
||||
}),
|
||||
}).show();
|
||||
}
|
||||
};
|
||||
|
||||
export const publishNotificationUpdateReadyToInstall = async (
|
||||
version: string
|
||||
) => {
|
||||
|
@ -1,63 +0,0 @@
|
||||
import { repackRepository } from "@main/repository";
|
||||
import { formatName } from "@shared";
|
||||
import { CatalogueEntry, GameRepack } from "@types";
|
||||
import flexSearch from "flexsearch";
|
||||
|
||||
export class RepacksManager {
|
||||
public static repacks: GameRepack[] = [];
|
||||
private static repacksIndex = new flexSearch.Index();
|
||||
|
||||
public static async updateRepacks() {
|
||||
this.repacks = await repackRepository
|
||||
.find({
|
||||
order: {
|
||||
createdAt: "DESC",
|
||||
},
|
||||
})
|
||||
.then((repacks) =>
|
||||
repacks.map((repack) => {
|
||||
const uris: string[] = [];
|
||||
const magnet = repack?.magnet;
|
||||
|
||||
if (magnet) uris.push(magnet);
|
||||
|
||||
return {
|
||||
...repack,
|
||||
uris: [...uris, ...JSON.parse(repack.uris)],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
for (let i = 0; i < this.repacks.length; i++) {
|
||||
this.repacksIndex.remove(i);
|
||||
}
|
||||
|
||||
this.repacksIndex = new flexSearch.Index();
|
||||
|
||||
for (let i = 0; i < this.repacks.length; i++) {
|
||||
const repack = this.repacks[i];
|
||||
|
||||
const formattedTitle = formatName(repack.title);
|
||||
|
||||
this.repacksIndex.add(i, formattedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
public static search(options: flexSearch.SearchOptions) {
|
||||
return this.repacksIndex
|
||||
.search({ ...options, query: formatName(options.query ?? "") })
|
||||
.map((index) => this.repacks[index]);
|
||||
}
|
||||
|
||||
public static findRepacksForCatalogueEntry(entry: CatalogueEntry) {
|
||||
const repacks = this.search({ query: formatName(entry.title) });
|
||||
return { ...entry, repacks };
|
||||
}
|
||||
|
||||
public static findRepacksForCatalogueEntries(entries: CatalogueEntry[]) {
|
||||
return entries.map((entry) => {
|
||||
const repacks = this.search({ query: formatName(entry.title) });
|
||||
return { ...entry, repacks };
|
||||
});
|
||||
}
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
import { downloadSourceSchema } from "@main/events/helpers/validators";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import type { DownloadSource, GameRepack } from "@types";
|
||||
import axios, { AxiosError, AxiosHeaders } from "axios";
|
||||
import { z } from "zod";
|
||||
|
||||
export type DownloadSourceResponse = z.infer<typeof downloadSourceSchema> & {
|
||||
etag: string | null;
|
||||
status: DownloadSourceStatus;
|
||||
};
|
||||
|
||||
export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
|
||||
const results: DownloadSourceResponse[] = [];
|
||||
|
||||
for (const downloadSource of downloadSources) {
|
||||
const headers = new AxiosHeaders();
|
||||
|
||||
if (downloadSource.etag) {
|
||||
headers.set("If-None-Match", downloadSource.etag);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(downloadSource.url, {
|
||||
headers,
|
||||
});
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
results.push({
|
||||
...downloadSource,
|
||||
downloads: source.downloads,
|
||||
etag: response.headers["etag"],
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const isNotModified = (err as AxiosError).response?.status === 304;
|
||||
|
||||
results.push({
|
||||
...downloadSource,
|
||||
downloads: [],
|
||||
etag: null,
|
||||
status: isNotModified
|
||||
? DownloadSourceStatus.UpToDate
|
||||
: DownloadSourceStatus.Errored,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const validateDownloadSource = async ({
|
||||
url,
|
||||
repacks,
|
||||
}: {
|
||||
url: string;
|
||||
repacks: GameRepack[];
|
||||
}) => {
|
||||
const response = await axios.get(url);
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const existingUris = source.downloads
|
||||
.flatMap((download) => download.uris)
|
||||
.filter((uri) => repacks.some((repack) => repack.magnet === uri));
|
||||
|
||||
return {
|
||||
name: source.name,
|
||||
downloadCount: source.downloads.length - existingUris.length,
|
||||
};
|
||||
};
|
@ -1,6 +1,5 @@
|
||||
import path from "node:path";
|
||||
import steamGamesWorkerPath from "./steam-games.worker?modulePath";
|
||||
import downloadSourceWorkerPath from "./download-source.worker?modulePath";
|
||||
|
||||
import Piscina from "piscina";
|
||||
|
||||
@ -13,7 +12,3 @@ export const steamGamesWorker = new Piscina({
|
||||
},
|
||||
maxThreads: 1,
|
||||
});
|
||||
|
||||
export const downloadSourceWorker = new Piscina({
|
||||
filename: downloadSourceWorkerPath,
|
||||
});
|
||||
|
@ -81,13 +81,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
|
||||
/* Download sources */
|
||||
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
|
||||
validateDownloadSource: (url: string) =>
|
||||
ipcRenderer.invoke("validateDownloadSource", url),
|
||||
addDownloadSource: (url: string) =>
|
||||
ipcRenderer.invoke("addDownloadSource", url),
|
||||
removeDownloadSource: (id: number) =>
|
||||
ipcRenderer.invoke("removeDownloadSource", id),
|
||||
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
|
||||
deleteDownloadSource: (id: number) =>
|
||||
ipcRenderer.invoke("deleteDownloadSource", id),
|
||||
|
||||
/* Library */
|
||||
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
|
||||
@ -203,4 +198,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.on("on-signout", listener);
|
||||
return () => ipcRenderer.removeListener("on-signout", listener);
|
||||
},
|
||||
|
||||
/* Notifications */
|
||||
publishNewRepacksNotification: (newRepacksCount: number) =>
|
||||
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useContext, useEffect, useRef } from "react";
|
||||
|
||||
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
||||
|
||||
@ -26,6 +26,8 @@ import {
|
||||
} from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
import { downloadSourcesWorker } from "./workers";
|
||||
import { repacksContext } from "./context";
|
||||
|
||||
export interface AppProps {
|
||||
children: React.ReactNode;
|
||||
@ -37,8 +39,12 @@ export function App() {
|
||||
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const downloadSourceMigrationLock = useRef(false);
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const { indexRepacks } = useContext(repacksContext);
|
||||
|
||||
const {
|
||||
isFriendsModalVisible,
|
||||
friendRequetsModalTab,
|
||||
@ -197,7 +203,7 @@ export function App() {
|
||||
|
||||
useEffect(() => {
|
||||
new MutationObserver(() => {
|
||||
const modal = document.body.querySelector("[role=modal]");
|
||||
const modal = document.body.querySelector("[role=dialog]");
|
||||
|
||||
dispatch(toggleDraggingDisabled(Boolean(modal)));
|
||||
}).observe(document.body, {
|
||||
@ -206,6 +212,49 @@ export function App() {
|
||||
});
|
||||
}, [dispatch, draggingDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (downloadSourceMigrationLock.current) return;
|
||||
|
||||
downloadSourceMigrationLock.current = true;
|
||||
|
||||
window.electron.getDownloadSources().then(async (downloadSources) => {
|
||||
if (!downloadSources.length) {
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
|
||||
channel.onmessage = (event: MessageEvent<number>) => {
|
||||
const newRepacksCount = event.data;
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
};
|
||||
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
}
|
||||
|
||||
for (const downloadSource of downloadSources) {
|
||||
const channel = new BroadcastChannel(
|
||||
`download_sources:import:${downloadSource.url}`
|
||||
);
|
||||
await new Promise((resolve) => {
|
||||
downloadSourcesWorker.postMessage([
|
||||
"IMPORT_DOWNLOAD_SOURCE",
|
||||
downloadSource.url,
|
||||
]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
indexRepacks();
|
||||
channel.close();
|
||||
};
|
||||
}).catch(() => channel.close());
|
||||
}
|
||||
|
||||
downloadSourceMigrationLock.current = false;
|
||||
});
|
||||
}, [indexRepacks]);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
dispatch(closeToast());
|
||||
}, [dispatch]);
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||
import type { CatalogueEntry, GameStats } from "@types";
|
||||
import type { CatalogueEntry, GameRepack, GameStats } from "@types";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
|
||||
import * as styles from "./game-card.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "../badge/badge";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { repacksContext } from "@renderer/context";
|
||||
|
||||
export interface GameCardProps
|
||||
extends React.DetailedHTMLProps<
|
||||
@ -25,9 +26,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
const { t } = useTranslation("game_card");
|
||||
|
||||
const [stats, setStats] = useState<GameStats | null>(null);
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndexingRepacks) {
|
||||
searchRepacks(game.title).then((repacks) => {
|
||||
setRepacks(repacks);
|
||||
});
|
||||
}
|
||||
}, [game, isIndexingRepacks, searchRepacks]);
|
||||
|
||||
const uniqueRepackers = Array.from(
|
||||
new Set(game.repacks.map(({ repacker }) => repacker))
|
||||
new Set(repacks.map(({ repacker }) => repacker))
|
||||
);
|
||||
|
||||
const handleHover = useCallback(() => {
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
@ -17,6 +23,7 @@ import type {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GameDetailsContext } from "./game-details.context.types";
|
||||
import { SteamContentDescriptor } from "@shared";
|
||||
import { repacksContext } from "../repacks/repacks.context";
|
||||
|
||||
export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
game: null,
|
||||
@ -54,7 +61,6 @@ export function GameDetailsContextProvider({
|
||||
const { objectID, shop } = useParams();
|
||||
|
||||
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
|
||||
@ -67,10 +73,22 @@ export function GameDetailsContextProvider({
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const gameTitle = searchParams.get("title")!;
|
||||
|
||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndexingRepacks) {
|
||||
searchRepacks(gameTitle).then((repacks) => {
|
||||
setRepacks(repacks);
|
||||
});
|
||||
}
|
||||
}, [game, gameTitle, isIndexingRepacks, searchRepacks]);
|
||||
|
||||
const { i18n } = useTranslation("game_details");
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
@ -94,42 +112,41 @@ export function GameDetailsContextProvider({
|
||||
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
window.electron.getGameShopDetails(
|
||||
window.electron
|
||||
.getGameShopDetails(
|
||||
objectID!,
|
||||
shop as GameShop,
|
||||
getSteamLanguage(i18n.language)
|
||||
),
|
||||
window.electron.searchGameRepacks(gameTitle),
|
||||
window.electron.getGameStats(objectID!, shop as GameShop),
|
||||
window.electron.getGameAchievements(objectID!, shop as GameShop),
|
||||
])
|
||||
.then(([appDetailsResult, repacksResult, statsResult, achievements]) => {
|
||||
if (appDetailsResult.status === "fulfilled") {
|
||||
setShopDetails(appDetailsResult.value);
|
||||
)
|
||||
.then((result) => {
|
||||
setShopDetails(result);
|
||||
|
||||
if (
|
||||
appDetailsResult.value?.content_descriptors.ids.includes(
|
||||
SteamContentDescriptor.AdultOnlySexualContent
|
||||
)
|
||||
) {
|
||||
setHasNSFWContentBlocked(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (repacksResult.status === "fulfilled")
|
||||
setRepacks(repacksResult.value);
|
||||
|
||||
if (statsResult.status === "fulfilled") setStats(statsResult.value);
|
||||
|
||||
if (achievements.status === "fulfilled") {
|
||||
setAchievements(achievements.value);
|
||||
if (
|
||||
result?.content_descriptors.ids.includes(
|
||||
SteamContentDescriptor.AdultOnlySexualContent
|
||||
)
|
||||
) {
|
||||
setHasNSFWContentBlocked(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
window.electron
|
||||
.getGameStats(objectID!, shop as GameShop)
|
||||
.then((result) => {
|
||||
setStats(result);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
window.electron
|
||||
.getGameAchievements(objectID!, shop as GameShop)
|
||||
.then((achievements) => {
|
||||
setAchievements(achievements);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
updateGame();
|
||||
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from "./game-details/game-details.context";
|
||||
export * from "./settings/settings.context";
|
||||
export * from "./user-profile/user-profile.context";
|
||||
export * from "./repacks/repacks.context";
|
||||
|
67
src/renderer/src/context/repacks/repacks.context.tsx
Normal file
67
src/renderer/src/context/repacks/repacks.context.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import type { GameRepack } from "@types";
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { repacksWorker } from "@renderer/workers";
|
||||
|
||||
export interface RepacksContext {
|
||||
searchRepacks: (query: string) => Promise<GameRepack[]>;
|
||||
indexRepacks: () => void;
|
||||
isIndexingRepacks: boolean;
|
||||
}
|
||||
|
||||
export const repacksContext = createContext<RepacksContext>({
|
||||
searchRepacks: async () => [] as GameRepack[],
|
||||
indexRepacks: () => {},
|
||||
isIndexingRepacks: false,
|
||||
});
|
||||
|
||||
const { Provider } = repacksContext;
|
||||
export const { Consumer: RepacksContextConsumer } = repacksContext;
|
||||
|
||||
export interface RepacksContextProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RepacksContextProvider({ children }: RepacksContextProps) {
|
||||
const [isIndexingRepacks, setIsIndexingRepacks] = useState(true);
|
||||
|
||||
const searchRepacks = useCallback(async (query: string) => {
|
||||
return new Promise<GameRepack[]>((resolve) => {
|
||||
const channelId = crypto.randomUUID();
|
||||
repacksWorker.postMessage([channelId, query]);
|
||||
|
||||
const channel = new BroadcastChannel(`repacks:search:${channelId}`);
|
||||
channel.onmessage = (event: MessageEvent<GameRepack[]>) => {
|
||||
resolve(event.data);
|
||||
channel.close();
|
||||
};
|
||||
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const indexRepacks = useCallback(() => {
|
||||
setIsIndexingRepacks(true);
|
||||
repacksWorker.postMessage("INDEX_REPACKS");
|
||||
|
||||
repacksWorker.onmessage = () => {
|
||||
setIsIndexingRepacks(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
indexRepacks();
|
||||
}, [indexRepacks]);
|
||||
|
||||
return (
|
||||
<Provider
|
||||
value={{
|
||||
searchRepacks,
|
||||
indexRepacks,
|
||||
isIndexingRepacks,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
}
|
10
src/renderer/src/declaration.d.ts
vendored
10
src/renderer/src/declaration.d.ts
vendored
@ -115,12 +115,7 @@ declare global {
|
||||
|
||||
/* Download sources */
|
||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
||||
validateDownloadSource: (
|
||||
url: string
|
||||
) => Promise<{ name: string; downloadCount: number }>;
|
||||
addDownloadSource: (url: string) => Promise<DownloadSource>;
|
||||
removeDownloadSource: (id: number) => Promise<void>;
|
||||
syncDownloadSources: () => Promise<void>;
|
||||
deleteDownloadSource: (id: number) => Promise<void>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||
@ -184,6 +179,9 @@ declare global {
|
||||
action: FriendRequestAction
|
||||
) => Promise<void>;
|
||||
sendFriendRequest: (userId: string) => Promise<void>;
|
||||
|
||||
/* Notifications */
|
||||
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
13
src/renderer/src/dexie.ts
Normal file
13
src/renderer/src/dexie.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Dexie } from "dexie";
|
||||
|
||||
export const db = new Dexie("Hydra");
|
||||
|
||||
db.version(1).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
|
||||
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
|
||||
});
|
||||
|
||||
export const downloadSourcesTable = db.table("downloadSources");
|
||||
export const repacksTable = db.table("repacks");
|
||||
|
||||
db.open();
|
@ -90,19 +90,9 @@ export function useUserDetails() {
|
||||
username: userDetails?.username || "",
|
||||
});
|
||||
},
|
||||
[updateUserDetails]
|
||||
[updateUserDetails, userDetails?.username]
|
||||
);
|
||||
|
||||
const fetchFriendRequests = useCallback(async () => {
|
||||
return window.electron
|
||||
.getFriendRequests()
|
||||
.then((friendRequests) => {
|
||||
syncFriendRequests();
|
||||
dispatch(setFriendRequests(friendRequests));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [dispatch]);
|
||||
|
||||
const syncFriendRequests = useCallback(async () => {
|
||||
return window.electron
|
||||
.syncFriendRequests()
|
||||
@ -112,6 +102,16 @@ export function useUserDetails() {
|
||||
.catch(() => {});
|
||||
}, [dispatch]);
|
||||
|
||||
const fetchFriendRequests = useCallback(async () => {
|
||||
return window.electron
|
||||
.getFriendRequests()
|
||||
.then((friendRequests) => {
|
||||
syncFriendRequests();
|
||||
dispatch(setFriendRequests(friendRequests));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [dispatch, syncFriendRequests]);
|
||||
|
||||
const showFriendsModal = useCallback(
|
||||
(initialTab: UserFriendModalTab, userId: string) => {
|
||||
dispatch(setFriendsModalVisible({ initialTab, userId }));
|
||||
|
@ -30,6 +30,9 @@ import { store } from "./store";
|
||||
import resources from "@locales";
|
||||
import { Achievemnt } from "./pages/achievement/achievement";
|
||||
|
||||
import "./workers";
|
||||
import { RepacksContextProvider } from "./context";
|
||||
|
||||
Sentry.init({});
|
||||
|
||||
i18n
|
||||
@ -55,20 +58,22 @@ i18n
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<App />}>
|
||||
<Route path="/" Component={Home} />
|
||||
<Route path="/catalogue" Component={Catalogue} />
|
||||
<Route path="/downloads" Component={Downloads} />
|
||||
<Route path="/game/:shop/:objectID" Component={GameDetails} />
|
||||
<Route path="/search" Component={SearchResults} />
|
||||
<Route path="/settings" Component={Settings} />
|
||||
<Route path="/profile/:userId" Component={Profile} />
|
||||
</Route>
|
||||
<Route path="/achievement-notification" Component={Achievemnt} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
<RepacksContextProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<App />}>
|
||||
<Route path="/" Component={Home} />
|
||||
<Route path="/catalogue" Component={Catalogue} />
|
||||
<Route path="/downloads" Component={Downloads} />
|
||||
<Route path="/game/:shop/:objectID" Component={GameDetails} />
|
||||
<Route path="/search" Component={SearchResults} />
|
||||
<Route path="/settings" Component={Settings} />
|
||||
<Route path="/profile/:userId" Component={Profile} />
|
||||
</Route>
|
||||
<Route path="/achievement-notification" Component={Achievemnt} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</RepacksContextProvider>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import parseTorrent from "parse-torrent";
|
||||
|
||||
import { Badge, Button, Modal, TextField } from "@renderer/components";
|
||||
import type { GameRepack } from "@types";
|
||||
@ -33,8 +32,6 @@ export function RepacksModal({
|
||||
const [repack, setRepack] = useState<GameRepack | null>(null);
|
||||
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
|
||||
|
||||
const [infoHash, setInfoHash] = useState<string | null>(null);
|
||||
|
||||
const { repacks, game } = useContext(gameDetailsContext);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
@ -43,18 +40,9 @@ export function RepacksModal({
|
||||
return orderBy(repacks, (repack) => repack.uploadDate, "desc");
|
||||
}, [repacks]);
|
||||
|
||||
const getInfoHash = useCallback(async () => {
|
||||
if (game?.uri?.startsWith("magnet:")) {
|
||||
const torrent = await parseTorrent(game?.uri ?? "");
|
||||
if (torrent.infoHash) setInfoHash(torrent.infoHash);
|
||||
}
|
||||
}, [game]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredRepacks(sortedRepacks);
|
||||
|
||||
if (game?.uri) getInfoHash();
|
||||
}, [sortedRepacks, visible, game, getInfoHash]);
|
||||
}, [sortedRepacks, visible, game]);
|
||||
|
||||
const handleRepackClick = (repack: GameRepack) => {
|
||||
setRepack(repack);
|
||||
@ -77,9 +65,6 @@ export function RepacksModal({
|
||||
};
|
||||
|
||||
const checkIfLastDownloadedOption = (repack: GameRepack) => {
|
||||
if (infoHash) return repack.uris.some((uri) => uri.includes(infoHash));
|
||||
if (!game?.uri) return false;
|
||||
|
||||
return repack.uris.some((uri) => uri.includes(game?.uri ?? ""));
|
||||
};
|
||||
|
||||
|
@ -8,6 +8,9 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import * as yup from "yup";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { DownloadSourceValidationResult } from "@types";
|
||||
import { downloadSourcesWorker } from "@renderer/workers";
|
||||
|
||||
interface AddDownloadSourceModalProps {
|
||||
visible: boolean;
|
||||
@ -39,41 +42,48 @@ export function AddDownloadSourceModal({
|
||||
setValue,
|
||||
setError,
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
name: string;
|
||||
downloadCount: number;
|
||||
} | null>(null);
|
||||
const [validationResult, setValidationResult] =
|
||||
useState<DownloadSourceValidationResult | null>(null);
|
||||
|
||||
const { sourceUrl } = useContext(settingsContext);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (values: FormValues) => {
|
||||
setIsLoading(true);
|
||||
const existingDownloadSource = await downloadSourcesTable
|
||||
.where({ url: values.url })
|
||||
.first();
|
||||
|
||||
try {
|
||||
const result = await window.electron.validateDownloadSource(values.url);
|
||||
setValidationResult(result);
|
||||
if (existingDownloadSource) {
|
||||
setError("url", {
|
||||
type: "server",
|
||||
message: t("source_already_exists"),
|
||||
});
|
||||
|
||||
setUrl(values.url);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message.endsWith("Source with the same url already exists")
|
||||
) {
|
||||
setError("url", {
|
||||
type: "server",
|
||||
message: t("source_already_exists"),
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
downloadSourcesWorker.postMessage([
|
||||
"VALIDATE_DOWNLOAD_SOURCE",
|
||||
values.url,
|
||||
]);
|
||||
|
||||
const channel = new BroadcastChannel(
|
||||
`download_sources:validate:${values.url}`
|
||||
);
|
||||
|
||||
channel.onmessage = (
|
||||
event: MessageEvent<DownloadSourceValidationResult>
|
||||
) => {
|
||||
setValidationResult(event.data);
|
||||
channel.close();
|
||||
};
|
||||
|
||||
setUrl(values.url);
|
||||
},
|
||||
[setError, t]
|
||||
);
|
||||
@ -91,9 +101,21 @@ export function AddDownloadSourceModal({
|
||||
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
await window.electron.addDownloadSource(url);
|
||||
onClose();
|
||||
onAddDownloadSource();
|
||||
setIsLoading(true);
|
||||
|
||||
if (validationResult) {
|
||||
const channel = new BroadcastChannel(`download_sources:import:${url}`);
|
||||
|
||||
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
setIsLoading(false);
|
||||
|
||||
onClose();
|
||||
onAddDownloadSource();
|
||||
channel.close();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -122,7 +144,7 @@ export function AddDownloadSourceModal({
|
||||
theme="outline"
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isLoading}
|
||||
disabled={isSubmitting || isLoading}
|
||||
>
|
||||
{t("validate_download_source")}
|
||||
</Button>
|
||||
|
@ -10,7 +10,9 @@ import { AddDownloadSourceModal } from "./add-download-source-modal";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import { repacksContext, settingsContext } from "@renderer/context";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { downloadSourcesWorker } from "@renderer/workers";
|
||||
|
||||
export function SettingsDownloadSources() {
|
||||
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
|
||||
@ -18,16 +20,23 @@ export function SettingsDownloadSources() {
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
const [isSyncingDownloadSources, setIsSyncingDownloadSources] =
|
||||
useState(false);
|
||||
const [isRemovingDownloadSource, setIsRemovingDownloadSource] =
|
||||
useState(false);
|
||||
|
||||
const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { indexRepacks } = useContext(repacksContext);
|
||||
|
||||
const getDownloadSources = async () => {
|
||||
return window.electron.getDownloadSources().then((sources) => {
|
||||
setDownloadSources(sources);
|
||||
});
|
||||
await downloadSourcesTable
|
||||
.toCollection()
|
||||
.sortBy("createdAt")
|
||||
.then((sources) => {
|
||||
setDownloadSources(sources.reverse());
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -38,14 +47,24 @@ export function SettingsDownloadSources() {
|
||||
if (sourceUrl) setShowAddDownloadSourceModal(true);
|
||||
}, [sourceUrl]);
|
||||
|
||||
const handleRemoveSource = async (id: number) => {
|
||||
await window.electron.removeDownloadSource(id);
|
||||
showSuccessToast(t("removed_download_source"));
|
||||
const handleRemoveSource = (id: number) => {
|
||||
setIsRemovingDownloadSource(true);
|
||||
const channel = new BroadcastChannel(`download_sources:delete:${id}`);
|
||||
|
||||
getDownloadSources();
|
||||
downloadSourcesWorker.postMessage(["DELETE_DOWNLOAD_SOURCE", id]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
showSuccessToast(t("removed_download_source"));
|
||||
|
||||
getDownloadSources();
|
||||
indexRepacks();
|
||||
setIsRemovingDownloadSource(false);
|
||||
channel.close();
|
||||
};
|
||||
};
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
indexRepacks();
|
||||
await getDownloadSources();
|
||||
showSuccessToast(t("added_download_source"));
|
||||
};
|
||||
@ -53,15 +72,17 @@ export function SettingsDownloadSources() {
|
||||
const syncDownloadSources = async () => {
|
||||
setIsSyncingDownloadSources(true);
|
||||
|
||||
window.electron
|
||||
.syncDownloadSources()
|
||||
.then(() => {
|
||||
showSuccessToast(t("download_sources_synced"));
|
||||
getDownloadSources();
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSyncingDownloadSources(false);
|
||||
});
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
showSuccessToast(t("download_sources_synced"));
|
||||
getDownloadSources();
|
||||
setIsSyncingDownloadSources(false);
|
||||
channel.close();
|
||||
};
|
||||
};
|
||||
|
||||
const statusTitle = {
|
||||
@ -88,7 +109,11 @@ export function SettingsDownloadSources() {
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
disabled={!downloadSources.length || isSyncingDownloadSources}
|
||||
disabled={
|
||||
!downloadSources.length ||
|
||||
isSyncingDownloadSources ||
|
||||
isRemovingDownloadSource
|
||||
}
|
||||
onClick={syncDownloadSources}
|
||||
>
|
||||
<SyncIcon />
|
||||
@ -99,6 +124,7 @@ export function SettingsDownloadSources() {
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => setShowAddDownloadSourceModal(true)}
|
||||
disabled={isSyncingDownloadSources}
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{t("add_download_source")}
|
||||
@ -148,6 +174,7 @@ export function SettingsDownloadSources() {
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRemoveSource(downloadSource.id)}
|
||||
disabled={isRemovingDownloadSource}
|
||||
>
|
||||
<NoEntryIcon />
|
||||
{t("remove_download_source")}
|
||||
|
165
src/renderer/src/workers/download-sources.worker.ts
Normal file
165
src/renderer/src/workers/download-sources.worker.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
|
||||
|
||||
import { z } from "zod";
|
||||
import axios, { AxiosError, AxiosHeaders } from "axios";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
|
||||
export const downloadSourceSchema = z.object({
|
||||
name: z.string().max(255),
|
||||
downloads: z.array(
|
||||
z.object({
|
||||
title: z.string().max(255),
|
||||
uris: z.array(z.string()),
|
||||
uploadDate: z.string().max(255),
|
||||
fileSize: z.string().max(255),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
type Payload =
|
||||
| ["IMPORT_DOWNLOAD_SOURCE", string]
|
||||
| ["DELETE_DOWNLOAD_SOURCE", number]
|
||||
| ["VALIDATE_DOWNLOAD_SOURCE", string]
|
||||
| ["SYNC_DOWNLOAD_SOURCES", string];
|
||||
|
||||
self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
const [type, data] = event.data;
|
||||
|
||||
if (type === "VALIDATE_DOWNLOAD_SOURCE") {
|
||||
const response =
|
||||
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
|
||||
|
||||
const { name } = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:validate:${data}`);
|
||||
|
||||
channel.postMessage({
|
||||
name,
|
||||
etag: response.headers["etag"],
|
||||
downloadCount: response.data.downloads.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "DELETE_DOWNLOAD_SOURCE") {
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
await repacksTable.where({ downloadSourceId: data }).delete();
|
||||
await downloadSourcesTable.where({ id: data }).delete();
|
||||
});
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:delete:${data}`);
|
||||
|
||||
channel.postMessage(true);
|
||||
}
|
||||
|
||||
if (type === "IMPORT_DOWNLOAD_SOURCE") {
|
||||
const response =
|
||||
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
|
||||
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
const now = new Date();
|
||||
|
||||
const id = await downloadSourcesTable.add({
|
||||
url: data,
|
||||
name: response.data.name,
|
||||
etag: response.headers["etag"],
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
downloadCount: response.data.downloads.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const downloadSource = await downloadSourcesTable.get(id);
|
||||
|
||||
const repacks = response.data.downloads.map((download) => ({
|
||||
title: download.title,
|
||||
uris: download.uris,
|
||||
fileSize: download.fileSize,
|
||||
repacker: response.data.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSourceId: downloadSource!.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
await repacksTable.bulkAdd(repacks);
|
||||
});
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:import:${data}`);
|
||||
channel.postMessage(true);
|
||||
}
|
||||
|
||||
if (type === "SYNC_DOWNLOAD_SOURCES") {
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${data}`);
|
||||
let newRepacksCount = 0;
|
||||
|
||||
try {
|
||||
const downloadSources = await downloadSourcesTable.toArray();
|
||||
const existingRepacks = await repacksTable.toArray();
|
||||
|
||||
for (const downloadSource of downloadSources) {
|
||||
const headers = new AxiosHeaders();
|
||||
|
||||
if (downloadSource.etag) {
|
||||
headers.set("If-None-Match", downloadSource.etag);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(downloadSource.url, {
|
||||
headers,
|
||||
});
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
await db.transaction(
|
||||
"rw",
|
||||
repacksTable,
|
||||
downloadSourcesTable,
|
||||
async () => {
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
etag: response.headers["etag"],
|
||||
downloadCount: source.downloads.length,
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const repacks = source.downloads
|
||||
.filter(
|
||||
(download) =>
|
||||
!existingRepacks.some(
|
||||
(repack) => repack.title === download.title
|
||||
)
|
||||
)
|
||||
.map((download) => ({
|
||||
title: download.title,
|
||||
uris: download.uris,
|
||||
fileSize: download.fileSize,
|
||||
repacker: source.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSourceId: downloadSource.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
newRepacksCount += repacks.length;
|
||||
|
||||
await repacksTable.bulkAdd(repacks);
|
||||
}
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const isNotModified = (err as AxiosError).response?.status === 304;
|
||||
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
status: isNotModified
|
||||
? DownloadSourceStatus.UpToDate
|
||||
: DownloadSourceStatus.Errored,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
channel.postMessage(newRepacksCount);
|
||||
} catch (err) {
|
||||
channel.postMessage(-1);
|
||||
}
|
||||
}
|
||||
};
|
5
src/renderer/src/workers/index.ts
Normal file
5
src/renderer/src/workers/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import RepacksWorker from "./repacks.worker?worker";
|
||||
import DownloadSourcesWorker from "./download-sources.worker?worker";
|
||||
|
||||
export const repacksWorker = new RepacksWorker();
|
||||
export const downloadSourcesWorker = new DownloadSourcesWorker();
|
50
src/renderer/src/workers/repacks.worker.ts
Normal file
50
src/renderer/src/workers/repacks.worker.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { repacksTable } from "@renderer/dexie";
|
||||
import { formatName } from "@shared";
|
||||
import { GameRepack } from "@types";
|
||||
import flexSearch from "flexsearch";
|
||||
|
||||
const index = new flexSearch.Index();
|
||||
|
||||
const state = {
|
||||
repacks: [] as any[],
|
||||
};
|
||||
|
||||
interface SerializedGameRepack extends Omit<GameRepack, "uris"> {
|
||||
uris: string;
|
||||
}
|
||||
|
||||
self.onmessage = async (
|
||||
event: MessageEvent<[string, string] | "INDEX_REPACKS">
|
||||
) => {
|
||||
if (event.data === "INDEX_REPACKS") {
|
||||
repacksTable
|
||||
.toCollection()
|
||||
.sortBy("uploadDate")
|
||||
.then((results) => {
|
||||
state.repacks = results.reverse();
|
||||
|
||||
for (let i = 0; i < state.repacks.length; i++) {
|
||||
const repack = state.repacks[i];
|
||||
const formattedTitle = formatName(repack.title);
|
||||
index.add(i, formattedTitle);
|
||||
}
|
||||
|
||||
self.postMessage("INDEXING_COMPLETE");
|
||||
});
|
||||
} else {
|
||||
const [requestId, query] = event.data;
|
||||
|
||||
const results = index.search(formatName(query)).map((index) => {
|
||||
const repack = state.repacks.at(index as number) as SerializedGameRepack;
|
||||
|
||||
return {
|
||||
...repack,
|
||||
uris: [...repack.uris, repack.magnet].filter(Boolean),
|
||||
};
|
||||
});
|
||||
|
||||
const channel = new BroadcastChannel(`repacks:search:${requestId}`);
|
||||
|
||||
channel.postMessage(results);
|
||||
}
|
||||
};
|
@ -54,7 +54,6 @@ export interface CatalogueEntry {
|
||||
title: string;
|
||||
/* Epic Games covers cannot be guessed with objectID */
|
||||
cover: string;
|
||||
repacks: GameRepack[];
|
||||
}
|
||||
|
||||
export interface UserGame {
|
||||
@ -81,7 +80,6 @@ export interface Game {
|
||||
status: GameStatus | null;
|
||||
folderName: string;
|
||||
downloadPath: string | null;
|
||||
repacks: GameRepack[];
|
||||
progress: number;
|
||||
bytesDownloaded: number;
|
||||
playTimeInMilliseconds: number;
|
||||
@ -236,6 +234,19 @@ export interface UpdateProfileRequest {
|
||||
bio?: string;
|
||||
}
|
||||
|
||||
export interface DownloadSourceDownload {
|
||||
title: string;
|
||||
uris: string[];
|
||||
uploadDate: string;
|
||||
fileSize: string;
|
||||
}
|
||||
|
||||
export interface DownloadSourceValidationResult {
|
||||
name: string;
|
||||
etag: string;
|
||||
downloadCount: number;
|
||||
}
|
||||
|
||||
export interface DownloadSource {
|
||||
id: number;
|
||||
name: string;
|
||||
|
@ -3638,6 +3638,11 @@ detect-node@^2.0.4:
|
||||
resolved "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz"
|
||||
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
|
||||
|
||||
dexie@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/dexie/-/dexie-4.0.8.tgz#21fca70686bdaa1d86fad45b6b19316f6a084a1d"
|
||||
integrity sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ==
|
||||
|
||||
diff@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
|
||||
|
Loading…
Reference in New Issue
Block a user