first commit

This commit is contained in:
Hydra
2024-04-14 04:20:27 +01:00
commit 992ec5ab49
145 changed files with 37479 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import { formatName, repackerFormatter } from "@main/helpers";
import { getTrendingGames } from "@main/services";
import type { CatalogueCategory, CatalogueEntry } from "@types";
import { stateManager } from "@main/state-manager";
import { searchGames } from "../helpers/search-games";
import { registerEvent } from "../register-event";
const repacks = stateManager.getValue("repacks");
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
category: CatalogueCategory
) => {
const trendingGames = await getTrendingGames();
let i = 0;
const results: CatalogueEntry[] = [];
const getStringForLookup = (index: number) => {
if (category === "trending") return trendingGames[index];
const repack = repacks[index];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
return formatName(formatter(repack.title));
};
if (!repacks.length) return [];
const resultSize = 12;
const requestSize = resultSize * 2;
let lookupRequest = [];
while (results.length < resultSize) {
const stringForLookup = getStringForLookup(i);
if (!stringForLookup) {
i++;
continue;
}
lookupRequest.push(searchGames(stringForLookup));
i++;
if (lookupRequest.length < requestSize) {
continue;
}
const games = (await Promise.all(lookupRequest)).map((value) =>
value.at(0)
);
for (const game of games) {
const isAlreadyIncluded = results.some(
(result) => result.objectID === game?.objectID
);
if (!game || !game.repacks.length || isAlreadyIncluded) {
continue;
}
results.push(game);
}
lookupRequest = [];
}
return results.slice(0, resultSize);
};
registerEvent(getCatalogue, {
name: "getCatalogue",
memoize: true,
});

View File

@@ -0,0 +1,72 @@
import { gameShopCacheRepository } from "@main/repository";
import { getSteamAppDetails } from "@main/services";
import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
import { registerEvent } from "../register-event";
import { searchRepacks } from "../helpers/search-games";
const getGameShopDetails = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
shop: GameShop,
language: string
): Promise<ShopDetails | null> => {
if (shop === "steam") {
const cachedData = await gameShopCacheRepository.findOne({
where: { objectID, language },
});
const result = Promise.all([
getSteamAppDetails(objectID, "english"),
getSteamAppDetails(objectID, language),
]).then(([appDetails, localizedAppDetails]) => {
if (appDetails && localizedAppDetails) {
gameShopCacheRepository.upsert(
{
objectID,
shop: "steam",
language,
serializedData: JSON.stringify({
...localizedAppDetails,
name: appDetails.name,
}),
},
["objectID"]
);
}
return [appDetails, localizedAppDetails];
});
if (cachedData) {
const cachedDetails = JSON.parse(
cachedData.serializedData
) as SteamAppDetails;
return {
...cachedDetails,
repacks: searchRepacks(cachedDetails.name),
objectID,
} as ShopDetails;
}
return result.then(([appDetails, localizedAppDetails]) => {
if (!appDetails || !localizedAppDetails) return null;
return {
...localizedAppDetails,
name: appDetails.name,
repacks: searchRepacks(appDetails.name),
objectID,
} as ShopDetails;
});
}
throw new Error("Not implemented");
};
registerEvent(getGameShopDetails, {
name: "getGameShopDetails",
memoize: true,
});

View File

@@ -0,0 +1,29 @@
import shuffle from "lodash/shuffle";
import { getRandomSteam250List } from "@main/services";
import { registerEvent } from "../register-event";
import { searchGames, searchRepacks } from "../helpers/search-games";
import { formatName } from "@main/helpers";
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
return getRandomSteam250List().then(async (games) => {
const shuffledList = shuffle(games);
for (const game of shuffledList) {
const repacks = searchRepacks(formatName(game));
if (repacks.length) {
const results = await searchGames(game);
if (results.length) {
return results[0].objectID;
}
}
}
});
};
registerEvent(getRandomGame, {
name: "getRandomGame",
});

View File

@@ -0,0 +1,10 @@
import { registerEvent } from "../register-event";
import { searchGames } from "../helpers/search-games";
registerEvent(
(_event: Electron.IpcMainInvokeEvent, query: string) => searchGames(query),
{
name: "searchGames",
memoize: true,
}
);

View File

@@ -0,0 +1,11 @@
import checkDiskSpace from "check-disk-space";
import { registerEvent } from "../register-event";
import { getDownloadsPath } from "../helpers/get-downloads-path";
const getDiskFreeSpace = async (_event: Electron.IpcMainInvokeEvent) =>
checkDiskSpace(await getDownloadsPath());
registerEvent(getDiskFreeSpace, {
name: "getDiskFreeSpace",
});

View File

@@ -0,0 +1,15 @@
import { userPreferencesRepository } from "@main/repository";
import { defaultDownloadsPath } from "@main/constants";
export const getDownloadsPath = async () => {
const userPreferences = await userPreferencesRepository.findOne({
where: {
id: 1,
},
});
if (userPreferences && userPreferences.downloadsPath)
return userPreferences.downloadsPath;
return defaultDownloadsPath;
};

View File

@@ -0,0 +1,84 @@
import flexSearch from "flexsearch";
import orderBy from "lodash/orderBy";
import type { GameRepack, GameShop, CatalogueEntry } from "@types";
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
import { searchAlgolia } from "@main/services";
import { stateManager } from "@main/state-manager";
const { Index } = flexSearch;
const repacksIndex = new Index();
const repacks = stateManager.getValue("repacks");
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)));
}
export const HITS_PER_PAGE = 12;
export const searchRepacks = (title: string): GameRepack[] => {
const repacks = stateManager.getValue("repacks");
return orderBy(
repacksIndex
.search(formatName(title))
.map((index) => repacks.at(index as number)!),
["uploadDate"],
"desc"
);
};
export const searchGames = async (query: string): Promise<CatalogueEntry[]> => {
const formattedName = formatName(query);
const steamResults = await searchAlgolia<{ objectID: string; name: string }>({
index: "steamdb",
query: formattedName,
params: {
facetFilters: '["appType:Game"]',
hitsPerPage: `${HITS_PER_PAGE}`,
},
headers: {
Referer: "https://steamdb.info/",
},
});
const results = steamResults.hits.map((hit) => ({
objectID: hit.objectID,
title: hit.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", hit.objectID),
}));
const gamesIndex = new Index({
tokenize: "full",
});
for (let i = 0; i < results.length; i++) {
const game = results[i];
gamesIndex.add(i, game.title);
}
const filteredResults = gamesIndex
.search(query)
.map((index) => results[index as number]);
return Promise.all(
filteredResults.map(async (result) => ({
...result,
repacks: searchRepacks(result.title),
}))
).then((resultsWithRepacks) =>
orderBy(
resultsWithRepacks,
[({ repacks }) => repacks.length, "repacks"],
["desc"]
)
);
};

26
src/main/events/index.ts Normal file
View File

@@ -0,0 +1,26 @@
import { app, ipcMain } from "electron";
import { defaultDownloadsPath } from "@main/constants";
import "./torrenting/start-game-download";
import "./catalogue/search-games";
import "./catalogue/get-game-shop-details";
import "./catalogue/get-catalogue";
import "./library/get-library";
import "./hardware/get-disk-free-space";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
import "./misc/get-or-cache-image";
import "./user-preferences/update-user-preferences";
import "./user-preferences/get-user-preferences";
import "./library/get-repackers-friendly-names";
import "./library/get-game-by-object-id";
import "./library/open-game";
import "./misc/show-open-dialog";
import "./library/remove-game";
import "./library/delete-game-folder";
import "./catalogue/get-random-game";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);

View File

@@ -0,0 +1,47 @@
import path from "node:path";
import fs from "node:fs";
import { GameStatus } from "@main/constants";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { logger } from "@main/services";
import { registerEvent } from "../register-event";
const deleteGameFolder = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
id: gameId,
status: GameStatus.Cancelled,
},
});
if (!game) return;
if (game.folderName) {
const folderPath = path.join(await getDownloadsPath(), game.folderName);
if (fs.existsSync(folderPath)) {
return new Promise((resolve, reject) => {
fs.rm(
folderPath,
{ recursive: true, force: true, maxRetries: 5, retryDelay: 200 },
(error) => {
if (error) {
logger.error(error);
reject();
}
resolve(null);
}
);
});
}
}
};
registerEvent(deleteGameFolder, {
name: "deleteGameFolder",
});

View File

@@ -0,0 +1,20 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getGameByObjectID = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string
) =>
gameRepository.findOne({
where: {
objectID,
},
relations: {
repack: true,
},
});
registerEvent(getGameByObjectID, {
name: "getGameByObjectID",
});

View File

@@ -0,0 +1,30 @@
import { gameRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
import sortBy from "lodash/sortBy";
const getLibrary = async (_event: Electron.IpcMainInvokeEvent) =>
gameRepository
.find({
order: {
createdAt: "desc",
},
relations: {
repack: true,
},
})
.then((games) =>
sortBy(
games.map((game) => ({
...game,
repacks: searchRepacks(game.title),
})),
(game) => (game.status !== GameStatus.Cancelled ? 0 : 1)
)
);
registerEvent(getLibrary, {
name: "getLibrary",
});

View File

@@ -0,0 +1,12 @@
import { registerEvent } from "../register-event";
import { stateManager } from "@main/state-manager";
const getRepackersFriendlyNames = async (_event: Electron.IpcMainInvokeEvent) =>
stateManager.getValue("repackersFriendlyNames").reduce((prev, next) => {
return { ...prev, [next.name]: next.friendlyName };
}, {});
registerEvent(getRepackersFriendlyNames, {
name: "getRepackersFriendlyNames",
memoize: true,
});

View File

@@ -0,0 +1,38 @@
import { gameRepository } from "@main/repository";
import path from "node:path";
import fs from "node:fs";
import { registerEvent } from "../register-event";
import { shell } from "electron";
import { getDownloadsPath } from "../helpers/get-downloads-path";
const openGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({ where: { id: gameId } });
if (!game) return;
const gamePath = path.join(
game.downloadPath ?? (await getDownloadsPath()),
game.folderName
);
if (fs.existsSync(gamePath)) {
const setupPath = path.join(gamePath, "setup.exe");
if (fs.existsSync(setupPath)) {
shell.openExternal(setupPath);
} else {
shell.openPath(gamePath);
}
} else {
await gameRepository.delete({
id: gameId,
});
}
};
registerEvent(openGame, {
name: "openGame",
});

View File

@@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
const removeGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => gameRepository.delete({ id: gameId });
registerEvent(removeGame, {
name: "removeGame",
});

View File

@@ -0,0 +1,37 @@
import { imageCacheRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { getImageBase64 } from "@main/helpers";
import { logger } from "@main/services";
const getOrCacheImage = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const cache = await imageCacheRepository.findOne({
where: {
url,
},
});
if (cache) return cache.data;
getImageBase64(url).then((data) =>
imageCacheRepository
.save({
url,
data,
})
.catch(() => {
logger.error(`Failed to cache image "${url}"`, {
method: "getOrCacheImage",
});
})
);
return url;
};
registerEvent(getOrCacheImage, {
name: "getOrCacheImage",
});

View File

@@ -0,0 +1,12 @@
import { dialog } from "electron";
import { WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
const showOpenDialog = async (
_event: Electron.IpcMainInvokeEvent,
options: Electron.OpenDialogOptions
) => dialog.showOpenDialog(WindowManager.mainWindow, options);
registerEvent(showOpenDialog, {
name: "showOpenDialog",
});

View File

@@ -0,0 +1,39 @@
import { ipcMain } from "electron";
import { stateManager } from "@main/state-manager";
interface EventArgs {
name: string;
memoize?: boolean;
}
export const registerEvent = (
listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any,
{ name, memoize = false }: EventArgs
) => {
ipcMain.handle(name, (event: Electron.IpcMainInvokeEvent, ...args) => {
const eventResults = stateManager.getValue("eventResults");
const keys = Array.from(eventResults.keys());
const key = [name, args] as [string, any[]];
const memoizationKey = keys.find(([memoizedEvent, memoizedArgs]) => {
const sameEvent = name === memoizedEvent;
const sameArgs = memoizedArgs.every((arg, index) => arg === args[index]);
return sameEvent && sameArgs;
});
if (memoizationKey) return eventResults.get(memoizationKey);
return Promise.resolve(listener(event, ...args)).then((result) => {
if (memoize) {
eventResults.set(key, JSON.parse(JSON.stringify(result)));
stateManager.setValue("eventResults", eventResults);
}
if (!result) return result;
return JSON.parse(JSON.stringify(result));
});
});
};

View File

@@ -0,0 +1,53 @@
import { GameStatus } from "@main/constants";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { WindowManager, writePipe } from "@main/services";
import { In } from "typeorm";
const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
id: gameId,
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
GameStatus.Paused,
GameStatus.Seeding,
]),
},
});
if (!game) return;
gameRepository
.update(
{
id: game.id,
},
{
status: GameStatus.Cancelled,
downloadPath: null,
bytesDownloaded: 0,
progress: 0,
}
)
.then((result) => {
if (
game.status !== GameStatus.Paused &&
game.status !== GameStatus.Seeding
) {
writePipe.write({ action: "cancel" });
if (result.affected) WindowManager.mainWindow.setProgressBar(-1);
}
});
};
registerEvent(cancelGameDownload, {
name: "cancelGameDownload",
});

View File

@@ -0,0 +1,34 @@
import { WindowManager, writePipe } from "@main/services";
import { registerEvent } from "../register-event";
import { GameStatus } from "../../constants";
import { gameRepository } from "../../repository";
import { In } from "typeorm";
const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
await gameRepository
.update(
{
id: gameId,
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
)
.then((result) => {
if (result.affected) {
writePipe.write({ action: "pause" });
WindowManager.mainWindow.setProgressBar(-1);
}
});
};
registerEvent(pauseGameDownload, {
name: "pauseGameDownload",
});

View File

@@ -0,0 +1,56 @@
import { registerEvent } from "../register-event";
import { GameStatus } from "../../constants";
import { gameRepository } from "../../repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { In } from "typeorm";
import { writePipe } from "@main/services";
const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
id: gameId,
},
relations: { repack: true },
});
if (!game) return;
writePipe.write({ action: "pause" });
if (game.status === GameStatus.Paused) {
const downloadsPath = game.downloadPath ?? (await getDownloadsPath());
writePipe.write({
action: "start",
game_id: gameId,
magnet: game.repack.magnet,
save_path: downloadsPath,
});
await gameRepository.update(
{
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
);
await gameRepository.update(
{ id: game.id },
{
status: GameStatus.DownloadingMetadata,
downloadPath: downloadsPath,
}
);
}
};
registerEvent(resumeGameDownload, {
name: "resumeGameDownload",
});

View File

@@ -0,0 +1,110 @@
import { getSteamGameIconUrl, writePipe } from "@main/services";
import { gameRepository, repackRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { getImageBase64 } from "@main/helpers";
import { In } from "typeorm";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
repackId: number,
objectID: string,
title: string,
gameShop: GameShop
) => {
const [game, repack] = await Promise.all([
gameRepository.findOne({
where: {
objectID,
},
}),
repackRepository.findOne({
where: {
id: repackId,
},
}),
]);
if (!repack) return;
if (game?.status === GameStatus.Downloading) {
return;
}
writePipe.write({ action: "pause" });
const downloadsPath = game?.downloadPath ?? (await getDownloadsPath());
await gameRepository.update(
{
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
);
if (game) {
await gameRepository.update(
{
id: game.id,
},
{
status: GameStatus.DownloadingMetadata,
downloadPath: downloadsPath,
repack: { id: repackId },
}
);
writePipe.write({
action: "start",
game_id: game.id,
magnet: repack.magnet,
save_path: downloadsPath,
});
game.status = GameStatus.DownloadingMetadata;
writePipe.write({
action: "start",
game_id: game.id,
magnet: repack.magnet,
save_path: downloadsPath,
});
return game;
} else {
const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID));
const createdGame = await gameRepository.save({
title,
iconUrl,
objectID,
shop: gameShop,
status: GameStatus.DownloadingMetadata,
downloadPath: downloadsPath,
repack: { id: repackId },
});
writePipe.write({
action: "start",
game_id: createdGame.id,
magnet: repack.magnet,
save_path: downloadsPath,
});
const { repack: _, ...rest } = createdGame;
return rest;
}
};
registerEvent(startGameDownload, {
name: "startGameDownload",
});

View File

@@ -0,0 +1,11 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getUserPreferences = async (_event: Electron.IpcMainInvokeEvent) =>
userPreferencesRepository.findOne({
where: { id: 1 },
});
registerEvent(getUserPreferences, {
name: "getUserPreferences",
});

View File

@@ -0,0 +1,21 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types";
const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent,
preferences: Partial<UserPreferences>
) => {
await userPreferencesRepository.upsert(
{
id: 1,
...preferences,
},
["id"]
);
};
registerEvent(updateUserPreferences, {
name: "updateUserPreferences",
});