mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-21 10:03:56 +00:00
first commit
This commit is contained in:
54
src/main/constants.ts
Normal file
54
src/main/constants.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { app } from "electron";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const repackersOn1337x = [
|
||||
"DODI",
|
||||
"FitGirl",
|
||||
"0xEMPRESS",
|
||||
"KaOsKrew",
|
||||
"TinyRepacks",
|
||||
] as const;
|
||||
|
||||
export const repackers = [
|
||||
...repackersOn1337x,
|
||||
"Xatab",
|
||||
"CPG",
|
||||
"TinyRepacks",
|
||||
"GOG",
|
||||
] as const;
|
||||
|
||||
export const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
|
||||
export enum GameStatus {
|
||||
Seeding = "seeding",
|
||||
Downloading = "downloading",
|
||||
Paused = "paused",
|
||||
CheckingFiles = "checking_files",
|
||||
DownloadingMetadata = "downloading_metadata",
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
export const defaultDownloadsPath = path.join(os.homedir(), "downloads");
|
||||
|
||||
export const databasePath = path.join(
|
||||
app.getPath("appData"),
|
||||
app.getName(),
|
||||
"hydra.db"
|
||||
);
|
||||
|
||||
export const INSTALLATION_ID_LENGTH = 6;
|
||||
export const ACTIVATION_KEY_MULTIPLIER = 7;
|
||||
33
src/main/data-source.ts
Normal file
33
src/main/data-source.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DataSource } from "typeorm";
|
||||
import {
|
||||
Game,
|
||||
GameShopCache,
|
||||
ImageCache,
|
||||
Repack,
|
||||
RepackerFriendlyName,
|
||||
UserPreferences,
|
||||
MigrationScript,
|
||||
} from "@main/entity";
|
||||
import type { SqliteConnectionOptions } from "typeorm/driver/sqlite/SqliteConnectionOptions";
|
||||
|
||||
import { databasePath } from "./constants";
|
||||
|
||||
export const createDataSource = (options: Partial<SqliteConnectionOptions>) =>
|
||||
new DataSource({
|
||||
type: "sqlite",
|
||||
database: databasePath,
|
||||
entities: [
|
||||
Game,
|
||||
ImageCache,
|
||||
Repack,
|
||||
RepackerFriendlyName,
|
||||
UserPreferences,
|
||||
GameShopCache,
|
||||
MigrationScript,
|
||||
],
|
||||
...options,
|
||||
});
|
||||
|
||||
export const dataSource = createDataSource({
|
||||
synchronize: true,
|
||||
});
|
||||
32
src/main/entity/game-shop-cache.entity.ts
Normal file
32
src/main/entity/game-shop-cache.entity.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
@Entity("game_shop_cache")
|
||||
export class GameShopCache {
|
||||
@PrimaryColumn("text", { unique: true })
|
||||
objectID: string;
|
||||
|
||||
@Column("text")
|
||||
shop: GameShop;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
serializedData: string;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
howLongToBeatSerializedData: string;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
language: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
69
src/main/entity/game.entity.ts
Normal file
69
src/main/entity/game.entity.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToOne,
|
||||
JoinColumn,
|
||||
} from "typeorm";
|
||||
import type { GameShop } from "@types";
|
||||
import { Repack } from "./repack.entity";
|
||||
|
||||
@Entity("game")
|
||||
export class Game {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column("text", { unique: true })
|
||||
objectID: string;
|
||||
|
||||
@Column("text")
|
||||
title: string;
|
||||
|
||||
@Column("text")
|
||||
iconUrl: string;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
folderName: string | null;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
downloadPath: string | null;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
executablePath: string | null;
|
||||
|
||||
@Column("int", { default: 0 })
|
||||
playTimeInMilliseconds: number;
|
||||
|
||||
@Column("text")
|
||||
shop: GameShop;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
status: string;
|
||||
|
||||
@Column("float", { default: 0 })
|
||||
progress: number;
|
||||
|
||||
@Column("float", { default: 0 })
|
||||
fileVerificationProgress: number;
|
||||
|
||||
@Column("int", { default: 0 })
|
||||
bytesDownloaded: number;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
lastTimePlayed: Date | null;
|
||||
|
||||
@Column("float", { default: 0 })
|
||||
fileSize: number;
|
||||
|
||||
@OneToOne(() => Repack, { nullable: true })
|
||||
@JoinColumn()
|
||||
repack: Repack;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
25
src/main/entity/image-cache.entity.ts
Normal file
25
src/main/entity/image-cache.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
@Entity("image_cache")
|
||||
export class ImageCache {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column("text", { unique: true })
|
||||
url: string;
|
||||
|
||||
@Column("text")
|
||||
data: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
7
src/main/entity/index.ts
Normal file
7
src/main/entity/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./game.entity";
|
||||
export * from "./image-cache.entity";
|
||||
export * from "./repack.entity";
|
||||
export * from "./repacker-friendly-name.entity";
|
||||
export * from "./user-preferences.entity";
|
||||
export * from "./game-shop-cache.entity";
|
||||
export * from "./migration-script.entity";
|
||||
22
src/main/entity/migration-script.entity.ts
Normal file
22
src/main/entity/migration-script.entity.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
@Entity("migration_script")
|
||||
export class MigrationScript {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column("text", { unique: true })
|
||||
version: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
37
src/main/entity/repack.entity.ts
Normal file
37
src/main/entity/repack.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
@Entity("repack")
|
||||
export class Repack {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column("text", { unique: true })
|
||||
title: string;
|
||||
|
||||
@Column("text", { unique: true })
|
||||
magnet: string;
|
||||
|
||||
@Column("int")
|
||||
page: number;
|
||||
|
||||
@Column("text")
|
||||
repacker: string;
|
||||
|
||||
@Column("text")
|
||||
fileSize: string;
|
||||
|
||||
@Column("datetime")
|
||||
uploadDate: Date | string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
25
src/main/entity/repacker-friendly-name.entity.ts
Normal file
25
src/main/entity/repacker-friendly-name.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
@Entity("repacker_friendly_name")
|
||||
export class RepackerFriendlyName {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column("text", { unique: true })
|
||||
name: string;
|
||||
|
||||
@Column("text")
|
||||
friendlyName: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
31
src/main/entity/user-preferences.entity.ts
Normal file
31
src/main/entity/user-preferences.entity.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
@Entity("user_preferences")
|
||||
export class UserPreferences {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
downloadsPath: string | null;
|
||||
|
||||
@Column("text", { default: "en" })
|
||||
language: string;
|
||||
|
||||
@Column("boolean", { default: false })
|
||||
downloadNotificationsEnabled: boolean;
|
||||
|
||||
@Column("boolean", { default: false })
|
||||
repackUpdatesNotificationsEnabled: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
76
src/main/events/catalogue/get-catalogue.ts
Normal file
76
src/main/events/catalogue/get-catalogue.ts
Normal 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,
|
||||
});
|
||||
72
src/main/events/catalogue/get-game-shop-details.ts
Normal file
72
src/main/events/catalogue/get-game-shop-details.ts
Normal 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];
|
||||
});
|
||||
|
||||
const cachedGame = cachedData?.serializedData
|
||||
? (JSON.parse(cachedData?.serializedData) as SteamAppDetails)
|
||||
: null;
|
||||
|
||||
if (cachedGame) {
|
||||
return {
|
||||
...cachedGame,
|
||||
repacks: searchRepacks(cachedGame.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,
|
||||
});
|
||||
48
src/main/events/catalogue/get-how-long-to-beat.ts
Normal file
48
src/main/events/catalogue/get-how-long-to-beat.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { GameShop, HowLongToBeatCategory } from "@types";
|
||||
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gameShopCacheRepository } from "@main/repository";
|
||||
|
||||
const getHowLongToBeat = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string,
|
||||
shop: GameShop,
|
||||
title: string
|
||||
): Promise<HowLongToBeatCategory[] | null> => {
|
||||
const searchHowLongToBeatPromise = searchHowLongToBeat(title);
|
||||
|
||||
const gameShopCache = await gameShopCacheRepository.findOne({
|
||||
where: { objectID, shop },
|
||||
});
|
||||
|
||||
const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
|
||||
? JSON.parse(gameShopCache?.howLongToBeatSerializedData)
|
||||
: null;
|
||||
if (howLongToBeatCachedData) return howLongToBeatCachedData;
|
||||
|
||||
return searchHowLongToBeatPromise.then(async (response) => {
|
||||
const game = response.data.find(
|
||||
(game) => game.profile_steam === Number(objectID)
|
||||
);
|
||||
|
||||
if (!game) return null;
|
||||
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
|
||||
|
||||
gameShopCacheRepository.upsert(
|
||||
{
|
||||
objectID,
|
||||
shop,
|
||||
howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
|
||||
},
|
||||
["objectID"]
|
||||
);
|
||||
|
||||
return howLongToBeat;
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent(getHowLongToBeat, {
|
||||
name: "getHowLongToBeat",
|
||||
memoize: true,
|
||||
});
|
||||
29
src/main/events/catalogue/get-random-game.ts
Normal file
29
src/main/events/catalogue/get-random-game.ts
Normal 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",
|
||||
});
|
||||
10
src/main/events/catalogue/search-games.ts
Normal file
10
src/main/events/catalogue/search-games.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
11
src/main/events/hardware/get-disk-free-space.ts
Normal file
11
src/main/events/hardware/get-disk-free-space.ts
Normal 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",
|
||||
});
|
||||
44
src/main/events/helpers/generate-lutris-yaml.ts
Normal file
44
src/main/events/helpers/generate-lutris-yaml.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Document as YMLDocument } from "yaml";
|
||||
import { Game } from "@main/entity";
|
||||
import path from "node:path";
|
||||
|
||||
export const generateYML = (game: Game) => {
|
||||
const slugifiedGameTitle = game.title.replace(/\s/g, "-").toLocaleLowerCase();
|
||||
|
||||
const doc = new YMLDocument({
|
||||
name: game.title,
|
||||
game_slug: slugifiedGameTitle,
|
||||
slug: `${slugifiedGameTitle}-installer`,
|
||||
version: "Installer",
|
||||
runner: "wine",
|
||||
script: {
|
||||
game: {
|
||||
prefix: "$GAMEDIR",
|
||||
arch: "win64",
|
||||
working_dir: "$GAMEDIR",
|
||||
},
|
||||
installer: [
|
||||
{
|
||||
task: {
|
||||
name: "create_prefix",
|
||||
arch: "win64",
|
||||
prefix: "$GAMEDIR",
|
||||
},
|
||||
},
|
||||
{
|
||||
task: {
|
||||
executable: path.join(
|
||||
game.downloadPath,
|
||||
game.folderName,
|
||||
"setup.exe"
|
||||
),
|
||||
name: "wineexec",
|
||||
prefix: "$GAMEDIR",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return doc.toString();
|
||||
};
|
||||
15
src/main/events/helpers/get-downloads-path.ts
Normal file
15
src/main/events/helpers/get-downloads-path.ts
Normal 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;
|
||||
};
|
||||
72
src/main/events/helpers/search-games.ts
Normal file
72
src/main/events/helpers/search-games.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import flexSearch from "flexsearch";
|
||||
import orderBy from "lodash/orderBy";
|
||||
|
||||
import type { GameRepack, GameShop, CatalogueEntry } from "@types";
|
||||
|
||||
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
|
||||
import { searchSteamGame } 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 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 searchSteamGame(formattedName);
|
||||
|
||||
const results = steamResults.map((result) => ({
|
||||
objectID: result.objectID,
|
||||
title: result.name,
|
||||
shop: "steam" as GameShop,
|
||||
cover: getSteamAppAsset("library", result.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"]
|
||||
)
|
||||
);
|
||||
};
|
||||
30
src/main/events/index.ts
Normal file
30
src/main/events/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { app, ipcMain } from "electron";
|
||||
import { defaultDownloadsPath } from "@main/constants";
|
||||
|
||||
import "./library/add-game-to-library";
|
||||
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 "./torrenting/start-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-installer";
|
||||
import "./library/open-game";
|
||||
import "./library/close-game";
|
||||
import "./misc/show-open-dialog";
|
||||
import "./library/remove-game";
|
||||
import "./library/delete-game-folder";
|
||||
import "./catalogue/get-random-game";
|
||||
import "./catalogue/get-how-long-to-beat";
|
||||
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
ipcMain.handle("getVersion", () => app.getVersion());
|
||||
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);
|
||||
27
src/main/events/library/add-game-to-library.ts
Normal file
27
src/main/events/library/add-game-to-library.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
import type { GameShop } from "@types";
|
||||
import { getImageBase64 } from "@main/helpers";
|
||||
import { getSteamGameIconUrl } from "@main/services";
|
||||
|
||||
const addGameToLibrary = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string,
|
||||
title: string,
|
||||
gameShop: GameShop,
|
||||
) => {
|
||||
const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID));
|
||||
|
||||
return gameRepository.insert({
|
||||
title,
|
||||
iconUrl,
|
||||
objectID,
|
||||
shop: gameShop,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent(addGameToLibrary, {
|
||||
name: "addGameToLibrary",
|
||||
});
|
||||
35
src/main/events/library/close-game.ts
Normal file
35
src/main/events/library/close-game.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { getProcesses } from "@main/helpers";
|
||||
|
||||
const closeGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number
|
||||
) => {
|
||||
const processes = await getProcesses();
|
||||
const game = await gameRepository.findOne({ where: { id: gameId } });
|
||||
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
const basename = path.win32.basename(game.executablePath);
|
||||
const basenameWithoutExtension = path.win32.basename(
|
||||
game.executablePath,
|
||||
path.extname(game.executablePath)
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
return runningProcess.name === basename;
|
||||
}
|
||||
|
||||
return [basename, basenameWithoutExtension].includes(runningProcess.name);
|
||||
});
|
||||
|
||||
if (gameProcess) return process.kill(gameProcess.pid);
|
||||
return false;
|
||||
};
|
||||
|
||||
registerEvent(closeGame, {
|
||||
name: "closeGame",
|
||||
});
|
||||
47
src/main/events/library/delete-game-folder.ts
Normal file
47
src/main/events/library/delete-game-folder.ts
Normal 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",
|
||||
});
|
||||
20
src/main/events/library/get-game-by-object-id.ts
Normal file
20
src/main/events/library/get-game-by-object-id.ts
Normal 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",
|
||||
});
|
||||
30
src/main/events/library/get-library.ts
Normal file
30
src/main/events/library/get-library.ts
Normal 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",
|
||||
});
|
||||
12
src/main/events/library/get-repackers-friendly-names.ts
Normal file
12
src/main/events/library/get-repackers-friendly-names.ts
Normal 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,
|
||||
});
|
||||
58
src/main/events/library/open-game-installer.ts
Normal file
58
src/main/events/library/open-game-installer.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { generateYML } from "../helpers/generate-lutris-yaml";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { spawnSync, exec } from "node:child_process";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { shell } from "electron";
|
||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
||||
|
||||
const openGameInstaller = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number
|
||||
) => {
|
||||
const game = await gameRepository.findOne({ where: { id: gameId } });
|
||||
|
||||
if (!game) return true;
|
||||
|
||||
const gamePath = path.join(
|
||||
game.downloadPath ?? (await getDownloadsPath()),
|
||||
game.folderName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(gamePath)) {
|
||||
await gameRepository.delete({ id: gameId });
|
||||
return true;
|
||||
}
|
||||
|
||||
const setupPath = path.join(gamePath, "setup.exe");
|
||||
if (!fs.existsSync(setupPath)) {
|
||||
shell.openPath(gamePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
shell.openPath(setupPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (spawnSync("which", ["lutris"]).status === 0) {
|
||||
const ymlPath = path.join(gamePath, "setup.yml");
|
||||
await writeFile(ymlPath, generateYML(game));
|
||||
exec(`lutris --install "${ymlPath}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (spawnSync("which", ["wine"]).status === 0) {
|
||||
exec(`wine "${setupPath}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
registerEvent(openGameInstaller, {
|
||||
name: "openGameInstaller",
|
||||
});
|
||||
18
src/main/events/library/open-game.ts
Normal file
18
src/main/events/library/open-game.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { shell } from "electron";
|
||||
|
||||
const openGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number,
|
||||
executablePath: string
|
||||
) => {
|
||||
await gameRepository.update({ id: gameId }, { executablePath });
|
||||
|
||||
shell.openPath(executablePath);
|
||||
};
|
||||
|
||||
registerEvent(openGame, {
|
||||
name: "openGame",
|
||||
});
|
||||
11
src/main/events/library/remove-game.ts
Normal file
11
src/main/events/library/remove-game.ts
Normal 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",
|
||||
});
|
||||
37
src/main/events/misc/get-or-cache-image.ts
Normal file
37
src/main/events/misc/get-or-cache-image.ts
Normal 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",
|
||||
});
|
||||
12
src/main/events/misc/show-open-dialog.ts
Normal file
12
src/main/events/misc/show-open-dialog.ts
Normal 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",
|
||||
});
|
||||
39
src/main/events/register-event.ts
Normal file
39
src/main/events/register-event.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
};
|
||||
53
src/main/events/torrenting/cancel-game-download.ts
Normal file
53
src/main/events/torrenting/cancel-game-download.ts
Normal 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",
|
||||
});
|
||||
34
src/main/events/torrenting/pause-game-download.ts
Normal file
34
src/main/events/torrenting/pause-game-download.ts
Normal 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",
|
||||
});
|
||||
56
src/main/events/torrenting/resume-game-download.ts
Normal file
56
src/main/events/torrenting/resume-game-download.ts
Normal 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",
|
||||
});
|
||||
110
src/main/events/torrenting/start-game-download.ts
Normal file
110
src/main/events/torrenting/start-game-download.ts
Normal 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",
|
||||
});
|
||||
11
src/main/events/user-preferences/get-user-preferences.ts
Normal file
11
src/main/events/user-preferences/get-user-preferences.ts
Normal 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",
|
||||
});
|
||||
21
src/main/events/user-preferences/update-user-preferences.ts
Normal file
21
src/main/events/user-preferences/update-user-preferences.ts
Normal 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",
|
||||
});
|
||||
98
src/main/helpers/formatters.test.ts
Normal file
98
src/main/helpers/formatters.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, test } from "node:test";
|
||||
import {
|
||||
dodiFormatter,
|
||||
empressFormatter,
|
||||
fitGirlFormatter,
|
||||
kaosKrewFormatter,
|
||||
} from "./formatters";
|
||||
|
||||
describe("testing formatters", () => {
|
||||
describe("testing fitgirl formatter", () => {
|
||||
const fitGirlGames = [
|
||||
"REVEIL (v1.0.3f4 + 0.5 DLC, MULTi14) [FitGirl Repack]",
|
||||
"Dune: Spice Wars - The Ixian Edition (v2.0.0.31558 + DLC, MULTi9) [FitGirl Repack]",
|
||||
"HUMANKIND: Premium Edition (v1.0.22.3819 + 17 DLCs/Bonus Content, MULTi12) [FitGirl Repack, Selective Download - from 7.3 GB]",
|
||||
"Call to Arms: Gates of Hell - Ostfront: WW2 Bundle (v1.034 Hotfix 3 + 3 DLCs, MULTi9) [FitGirl Repack, Selective Download - from 21.8 GB]",
|
||||
"SUPER BOMBERMAN R 2 (v1.2.0, MULTi12) [FitGirl Repack]",
|
||||
"God of Rock (v3110, MULTi11) [FitGirl Repack]",
|
||||
];
|
||||
|
||||
test("should format games correctly", () => {
|
||||
assert.equal(fitGirlGames.map(fitGirlFormatter), [
|
||||
"REVEIL",
|
||||
"Dune: Spice Wars - The Ixian Edition",
|
||||
"HUMANKIND: Premium Edition",
|
||||
"Call to Arms: Gates of Hell - Ostfront: WW2 Bundle",
|
||||
"SUPER BOMBERMAN R 2",
|
||||
"God of Rock",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testing kaoskrew formatter", () => {
|
||||
const kaosKrewGames = [
|
||||
"Song.Of.Horror.Complete.Edition.v1.25.MULTi4.REPACK-KaOs",
|
||||
"Remoteness.REPACK-KaOs",
|
||||
"Persona.5.Royal.v1.0.0.MULTi5.NSW.For.PC.REPACK-KaOs",
|
||||
"The.Wreck.MULTi5.REPACK-KaOs",
|
||||
"Nemezis.Mysterious.Journey.III.v1.04.Deluxe.Edition.REPACK-KaOs",
|
||||
"The.World.Of.Others.v1.05.REPACK-KaOs",
|
||||
];
|
||||
|
||||
test("should format games correctly", () => {
|
||||
assert.equal(kaosKrewGames.map(kaosKrewFormatter), [
|
||||
"Song Of Horror Complete Edition",
|
||||
"Remoteness",
|
||||
"Persona 5 Royal NSW For PC",
|
||||
"The Wreck",
|
||||
"Nemezis Mysterious Journey III Deluxe Edition",
|
||||
"The World Of Others",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testing empress formatter", () => {
|
||||
const empressGames = [
|
||||
"Resident.Evil.4-EMPRESS",
|
||||
"Marvels.Guardians.of.the.Galaxy.Crackfix-EMPRESS",
|
||||
"Life.is.Strange.2.Complete.Edition-EMPRESS",
|
||||
"Forza.Horizon.4.PROPER-EMPRESS",
|
||||
"Just.Cause.4.Complete.Edition.READNFO-EMPRESS",
|
||||
"Immortals.Fenyx.Rising.Crackfix.V2-EMPRESS",
|
||||
];
|
||||
|
||||
test("should format games correctly", () => {
|
||||
assert.equal(empressGames.map(empressFormatter), [
|
||||
"Resident Evil 4",
|
||||
"Marvels Guardians of the Galaxy",
|
||||
"Life is Strange 2 Complete Edition",
|
||||
"Forza Horizon 4 PROPER",
|
||||
"Just Cause 4 Complete Edition",
|
||||
"Immortals Fenyx Rising",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testing kodi formatter", () => {
|
||||
const dodiGames = [
|
||||
"Tomb Raider I-III Remastered Starring Lara Croft (MULTi20) (From 2.5 GB) [DODI Repack]",
|
||||
"Trail Out: Complete Edition (v2.9st + All DLCs + MULTi11) [DODI Repack]",
|
||||
"Call to Arms - Gates of Hell: Ostfront (v1.034.0 + All DLCs + MULTi9) (From 22.4 GB) [DODI Repack]",
|
||||
"Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition (Digital book + MULTi6) [DODI Repack]",
|
||||
"DREDGE: Digital Deluxe Edition (v1.2.0.1922 + All DLCs + Bonus Content + MULTi11) (From 413 MB) [DODI Repack]",
|
||||
"Outliver: Tribulation [DODI Repack]",
|
||||
];
|
||||
|
||||
test("should format games correctly", () => {
|
||||
assert.equal(dodiGames.map(dodiFormatter), [
|
||||
"Tomb Raider I-III Remastered Starring Lara Croft",
|
||||
"Trail Out: Complete Edition",
|
||||
"Call to Arms - Gates of Hell: Ostfront",
|
||||
"Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition",
|
||||
"DREDGE: Digital Deluxe Edition",
|
||||
"Outliver: Tribulation",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
54
src/main/helpers/formatters.ts
Normal file
54
src/main/helpers/formatters.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/* String formatting */
|
||||
|
||||
export const removeReleaseYearFromName = (name: string) =>
|
||||
name.replace(/\([0-9]{4}\)/g, "");
|
||||
|
||||
export const removeSymbolsFromName = (name: string) =>
|
||||
name.replace(/[^A-Za-z 0-9]/g, "");
|
||||
|
||||
export const removeSpecialEditionFromName = (name: string) =>
|
||||
name.replace(
|
||||
/(The |Digital )?(GOTY|Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year|Reloaded|[0-9]{4}) Edition/g,
|
||||
""
|
||||
);
|
||||
|
||||
export const removeDuplicateSpaces = (name: string) =>
|
||||
name.replace(/\s{2,}/g, " ");
|
||||
|
||||
export const removeTrash = (title: string) =>
|
||||
title.replace(/\(.*\)|\[.*]/g, "").replace(/:/g, "");
|
||||
|
||||
/* Formatters per repacker */
|
||||
|
||||
export const fitGirlFormatter = (title: string) =>
|
||||
title.replace(/\(.*\)/g, "").trim();
|
||||
|
||||
export const kaosKrewFormatter = (title: string) =>
|
||||
title
|
||||
.replace(/(v\.?[0-9])+([0-9]|\.)+/, "")
|
||||
.replace(
|
||||
/(\.Build\.[0-9]*)?(\.MULTi[0-9]{1,2})?(\.REPACK-KaOs|\.UPDATE-KaOs)?/g,
|
||||
""
|
||||
)
|
||||
.replace(/\./g, " ")
|
||||
.trim();
|
||||
|
||||
export const empressFormatter = (title: string) =>
|
||||
title
|
||||
.replace(/-EMPRESS/, "")
|
||||
.replace(/\./g, " ")
|
||||
.trim();
|
||||
|
||||
export const dodiFormatter = (title: string) =>
|
||||
title.replace(/\(.*?\)/g, "").trim();
|
||||
|
||||
export const xatabFormatter = (title: string) =>
|
||||
title
|
||||
.replace(/RePack от xatab|RePack от Decepticon|R.G. GOGFAN/, "")
|
||||
.replace(/[\u0400-\u04FF]/g, "")
|
||||
.replace(/(v\.?([0-9]| )+)+([0-9]|\.|-|_|\/|[a-zA-Z]| )+/, "");
|
||||
|
||||
export const tinyRepacksFormatter = (title: string) => title;
|
||||
|
||||
export const gogFormatter = (title: string) =>
|
||||
title.replace(/(v\.[0-9]+|v[0-9]+\.|v[0-9]{4})+.+/, "");
|
||||
83
src/main/helpers/index.ts
Normal file
83
src/main/helpers/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
removeReleaseYearFromName,
|
||||
removeSymbolsFromName,
|
||||
removeSpecialEditionFromName,
|
||||
empressFormatter,
|
||||
kaosKrewFormatter,
|
||||
fitGirlFormatter,
|
||||
removeDuplicateSpaces,
|
||||
dodiFormatter,
|
||||
removeTrash,
|
||||
xatabFormatter,
|
||||
tinyRepacksFormatter,
|
||||
gogFormatter,
|
||||
} from "./formatters";
|
||||
import { months, repackers } from "../constants";
|
||||
|
||||
export const pipe =
|
||||
<T>(...fns: ((arg: T) => any)[]) =>
|
||||
(arg: T) =>
|
||||
fns.reduce((prev, fn) => fn(prev), arg);
|
||||
|
||||
export const formatName = pipe<string>(
|
||||
removeTrash,
|
||||
removeReleaseYearFromName,
|
||||
removeSymbolsFromName,
|
||||
removeSpecialEditionFromName,
|
||||
removeDuplicateSpaces,
|
||||
(str) => str.trim()
|
||||
);
|
||||
|
||||
export const repackerFormatter: Record<
|
||||
(typeof repackers)[number],
|
||||
(title: string) => string
|
||||
> = {
|
||||
DODI: dodiFormatter,
|
||||
"0xEMPRESS": empressFormatter,
|
||||
KaOsKrew: kaosKrewFormatter,
|
||||
FitGirl: fitGirlFormatter,
|
||||
Xatab: xatabFormatter,
|
||||
CPG: (title: string) => title,
|
||||
TinyRepacks: tinyRepacksFormatter,
|
||||
GOG: gogFormatter,
|
||||
};
|
||||
|
||||
export const formatUploadDate = (str: string) => {
|
||||
const date = new Date();
|
||||
|
||||
const [month, day, year] = str.split(" ");
|
||||
|
||||
date.setMonth(months.indexOf(month.replace(".", "")));
|
||||
date.setDate(Number(day.substring(0, 2)));
|
||||
date.setFullYear(Number("20" + year.replace("'", "")));
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
return date;
|
||||
};
|
||||
|
||||
export const getSteamAppAsset = (
|
||||
category: "library" | "hero" | "logo" | "icon",
|
||||
objectID: string,
|
||||
clientIcon?: string
|
||||
) => {
|
||||
if (category === "library")
|
||||
return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`;
|
||||
|
||||
if (category === "hero")
|
||||
return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`;
|
||||
|
||||
if (category === "logo")
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`;
|
||||
|
||||
return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`;
|
||||
};
|
||||
|
||||
export const getImageBase64 = async (url: string) =>
|
||||
fetch(url, { method: "GET" }).then((response) =>
|
||||
response.arrayBuffer().then((buffer) => {
|
||||
return `data:image/jpeg;base64,${Buffer.from(buffer).toString("base64")}`;
|
||||
})
|
||||
);
|
||||
|
||||
export * from "./formatters";
|
||||
export * from "./ps";
|
||||
12
src/main/helpers/ps.ts
Normal file
12
src/main/helpers/ps.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import psList from "ps-list";
|
||||
import { tasklist } from "tasklist";
|
||||
|
||||
export const getProcesses = async () => {
|
||||
if (process.platform === "win32") {
|
||||
return tasklist().then((tasks) =>
|
||||
tasks.map((task) => ({ ...task, name: task.imageName }))
|
||||
);
|
||||
}
|
||||
|
||||
return psList();
|
||||
};
|
||||
118
src/main/index.ts
Normal file
118
src/main/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { stateManager } from "./state-manager";
|
||||
import { GameStatus, repackers } from "./constants";
|
||||
import {
|
||||
getNewGOGGames,
|
||||
getNewRepacksFromCPG,
|
||||
getNewRepacksFromUser,
|
||||
getNewRepacksFromXatab,
|
||||
readPipe,
|
||||
startProcessWatcher,
|
||||
writePipe,
|
||||
} from "./services";
|
||||
import {
|
||||
gameRepository,
|
||||
repackRepository,
|
||||
repackerFriendlyNameRepository,
|
||||
userPreferencesRepository,
|
||||
} from "./repository";
|
||||
import { TorrentClient } from "./services/torrent-client";
|
||||
import { Repack } from "./entity";
|
||||
import { Notification } from "electron";
|
||||
import { t } from "i18next";
|
||||
import { In } from "typeorm";
|
||||
|
||||
startProcessWatcher();
|
||||
|
||||
TorrentClient.startTorrentClient(writePipe.socketPath, readPipe.socketPath);
|
||||
|
||||
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(async () => {
|
||||
const game = await gameRepository.findOne({
|
||||
where: {
|
||||
status: In([
|
||||
GameStatus.Downloading,
|
||||
GameStatus.DownloadingMetadata,
|
||||
GameStatus.CheckingFiles,
|
||||
]),
|
||||
},
|
||||
relations: { repack: true },
|
||||
});
|
||||
|
||||
if (game) {
|
||||
writePipe.write({
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
magnet: game.repack.magnet,
|
||||
save_path: game.downloadPath,
|
||||
});
|
||||
}
|
||||
|
||||
readPipe.socket.on("data", (data) => {
|
||||
TorrentClient.onSocketData(data);
|
||||
});
|
||||
});
|
||||
|
||||
const track1337xUsers = async (existingRepacks: Repack[]) => {
|
||||
for (const repacker of repackers) {
|
||||
await getNewRepacksFromUser(
|
||||
repacker,
|
||||
existingRepacks.filter((repack) => repack.repacker === repacker)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const checkForNewRepacks = async () => {
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const existingRepacks = stateManager.getValue("repacks");
|
||||
|
||||
Promise.allSettled([
|
||||
getNewGOGGames(
|
||||
existingRepacks.filter((repack) => repack.repacker === "GOG")
|
||||
),
|
||||
getNewRepacksFromXatab(
|
||||
existingRepacks.filter((repack) => repack.repacker === "Xatab")
|
||||
),
|
||||
getNewRepacksFromCPG(
|
||||
existingRepacks.filter((repack) => repack.repacker === "CPG")
|
||||
),
|
||||
track1337xUsers(existingRepacks),
|
||||
]).then(() => {
|
||||
repackRepository.count().then((count) => {
|
||||
const total = count - stateManager.getValue("repacks").length;
|
||||
|
||||
if (total > 0 && userPreferences.repackUpdatesNotificationsEnabled) {
|
||||
new Notification({
|
||||
title: t("repack_list_updated", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences?.language || "en",
|
||||
}),
|
||||
body: t("repack_count", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences?.language || "en",
|
||||
count: total,
|
||||
}),
|
||||
}).show();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const loadState = async () => {
|
||||
const [friendlyNames, repacks] = await Promise.all([
|
||||
repackerFriendlyNameRepository.find(),
|
||||
repackRepository.find({
|
||||
order: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
stateManager.setValue("repackersFriendlyNames", friendlyNames);
|
||||
stateManager.setValue("repacks", repacks);
|
||||
|
||||
import("./events");
|
||||
};
|
||||
|
||||
loadState().then(() => checkForNewRepacks());
|
||||
27
src/main/repository.ts
Normal file
27
src/main/repository.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { dataSource } from "./data-source";
|
||||
import {
|
||||
Game,
|
||||
GameShopCache,
|
||||
ImageCache,
|
||||
Repack,
|
||||
RepackerFriendlyName,
|
||||
UserPreferences,
|
||||
MigrationScript,
|
||||
} from "@main/entity";
|
||||
|
||||
export const gameRepository = dataSource.getRepository(Game);
|
||||
|
||||
export const imageCacheRepository = dataSource.getRepository(ImageCache);
|
||||
|
||||
export const repackRepository = dataSource.getRepository(Repack);
|
||||
|
||||
export const repackerFriendlyNameRepository =
|
||||
dataSource.getRepository(RepackerFriendlyName);
|
||||
|
||||
export const userPreferencesRepository =
|
||||
dataSource.getRepository(UserPreferences);
|
||||
|
||||
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
|
||||
|
||||
export const migrationScriptRepository =
|
||||
dataSource.getRepository(MigrationScript);
|
||||
38
src/main/services/fifo.ts
Normal file
38
src/main/services/fifo.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
import crypto from "node:crypto";
|
||||
import os from "node:os";
|
||||
|
||||
export class FIFO {
|
||||
public socket: null | net.Socket = null;
|
||||
public socketPath = this.generateSocketFilename();
|
||||
|
||||
private generateSocketFilename() {
|
||||
const hash = crypto.randomBytes(16).toString("hex");
|
||||
|
||||
if (process.platform === "win32") {
|
||||
return "\\\\.\\pipe\\" + hash;
|
||||
}
|
||||
|
||||
return path.join(os.tmpdir(), hash);
|
||||
}
|
||||
|
||||
public write(data: any) {
|
||||
if (!this.socket) return;
|
||||
this.socket.write(Buffer.from(JSON.stringify(data)));
|
||||
}
|
||||
|
||||
public createPipe() {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer((socket) => {
|
||||
this.socket = socket;
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
server.listen(this.socketPath);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const writePipe = new FIFO();
|
||||
export const readPipe = new FIFO();
|
||||
60
src/main/services/how-long-to-beat.ts
Normal file
60
src/main/services/how-long-to-beat.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { formatName } from "@main/helpers";
|
||||
import axios from "axios";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { requestWebPage } from "./repack-tracker/helpers";
|
||||
import { HowLongToBeatCategory } from "@types";
|
||||
|
||||
export interface HowLongToBeatResult {
|
||||
game_id: number;
|
||||
profile_steam: number;
|
||||
}
|
||||
|
||||
export interface HowLongToBeatSearchResponse {
|
||||
data: HowLongToBeatResult[];
|
||||
}
|
||||
|
||||
export const searchHowLongToBeat = async (gameName: string) => {
|
||||
const response = await axios.post(
|
||||
"https://howlongtobeat.com/api/search",
|
||||
{
|
||||
searchType: "games",
|
||||
searchTerms: formatName(gameName).split(" "),
|
||||
searchPage: 1,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
Referer: "https://howlongtobeat.com/",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data as HowLongToBeatSearchResponse;
|
||||
};
|
||||
|
||||
export const getHowLongToBeatGame = async (
|
||||
id: string
|
||||
): Promise<HowLongToBeatCategory[]> => {
|
||||
const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`);
|
||||
|
||||
const { window } = new JSDOM(response);
|
||||
const { document } = window;
|
||||
|
||||
const $ul = document.querySelector(".shadow_shadow ul");
|
||||
const $lis = Array.from($ul.children);
|
||||
|
||||
return $lis.map(($li) => {
|
||||
const title = $li.querySelector("h4").textContent;
|
||||
const [, accuracyClassName] = Array.from(($li as HTMLElement).classList);
|
||||
|
||||
const accuracy = accuracyClassName.split("time_").at(1);
|
||||
|
||||
return {
|
||||
title,
|
||||
duration: $li.querySelector("h5").textContent,
|
||||
accuracy,
|
||||
};
|
||||
});
|
||||
};
|
||||
11
src/main/services/index.ts
Normal file
11
src/main/services/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from "./logger";
|
||||
export * from "./repack-tracker";
|
||||
export * from "./steam";
|
||||
export * from "./steam-250";
|
||||
export * from "./steam-grid";
|
||||
export * from "./update-resolver";
|
||||
export * from "./window-manager";
|
||||
export * from "./fifo";
|
||||
export * from "./torrent-client";
|
||||
export * from "./how-long-to-beat";
|
||||
export * from "./process-watcher";
|
||||
11
src/main/services/logger.ts
Normal file
11
src/main/services/logger.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import winston from "winston";
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: "info",
|
||||
format: winston.format.json(),
|
||||
transports: [
|
||||
new winston.transports.File({ filename: "error.log", level: "error" }),
|
||||
new winston.transports.File({ filename: "info.log", level: "info" }),
|
||||
new winston.transports.File({ filename: "combined.log" }),
|
||||
],
|
||||
});
|
||||
77
src/main/services/process-watcher.ts
Normal file
77
src/main/services/process-watcher.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { IsNull, Not } from "typeorm";
|
||||
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { GameStatus } from "@main/constants";
|
||||
import { getProcesses } from "@main/helpers";
|
||||
import { WindowManager } from "./window-manager";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const startProcessWatcher = async () => {
|
||||
const sleepTime = 100;
|
||||
const gamesPlaytime = new Map<number, number>();
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
executablePath: Not(IsNull()),
|
||||
status: GameStatus.Seeding,
|
||||
},
|
||||
});
|
||||
|
||||
const processes = await getProcesses();
|
||||
|
||||
for (const game of games) {
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
const basename = path.win32.basename(game.executablePath);
|
||||
const basenameWithoutExtension = path.win32.basename(
|
||||
game.executablePath,
|
||||
path.extname(game.executablePath)
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
return runningProcess.name === basename;
|
||||
}
|
||||
|
||||
return [basename, basenameWithoutExtension].includes(
|
||||
runningProcess.name
|
||||
);
|
||||
});
|
||||
|
||||
if (gameProcess) {
|
||||
if (gamesPlaytime.has(game.id)) {
|
||||
const zero = gamesPlaytime.get(game.id);
|
||||
const delta = performance.now() - zero;
|
||||
|
||||
WindowManager.mainWindow.webContents.send("on-playtime", game.id);
|
||||
|
||||
await gameRepository.update(game.id, {
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
|
||||
});
|
||||
|
||||
gamesPlaytime.set(game.id, performance.now());
|
||||
await sleep(sleepTime);
|
||||
continue;
|
||||
}
|
||||
|
||||
gamesPlaytime.set(game.id, performance.now());
|
||||
gameRepository.update(game.id, {
|
||||
lastTimePlayed: new Date().toUTCString(),
|
||||
});
|
||||
|
||||
await sleep(sleepTime);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (gamesPlaytime.has(game.id)) {
|
||||
gamesPlaytime.delete(game.id);
|
||||
WindowManager.mainWindow.webContents.send("on-game-close", game.id);
|
||||
}
|
||||
|
||||
await sleep(sleepTime);
|
||||
}
|
||||
}
|
||||
};
|
||||
135
src/main/services/repack-tracker/1337x.ts
Normal file
135
src/main/services/repack-tracker/1337x.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { formatUploadDate } from "@main/helpers";
|
||||
|
||||
import { Repack } from "@main/entity";
|
||||
import { requestWebPage, savePage } from "./helpers";
|
||||
import type { GameRepackInput } from "./helpers";
|
||||
|
||||
export const request1337x = async (path: string) =>
|
||||
requestWebPage(`https://1337xx.to${path}`);
|
||||
|
||||
/* TODO: $a will often be null */
|
||||
const getTorrentDetails = async (path: string) => {
|
||||
const response = await request1337x(path);
|
||||
|
||||
const { window } = new JSDOM(response);
|
||||
const { document } = window;
|
||||
|
||||
const $a = window.document.querySelector(
|
||||
".torrentdown1"
|
||||
) as HTMLAnchorElement;
|
||||
|
||||
const $ul = Array.from(
|
||||
document.querySelectorAll(".torrent-detail-page .list")
|
||||
);
|
||||
const [$firstColumn, $secondColumn] = $ul;
|
||||
|
||||
if (!$firstColumn || !$secondColumn) {
|
||||
return { magnet: $a?.href };
|
||||
}
|
||||
|
||||
const [_$category, _$type, _$language, $totalSize] = $firstColumn.children;
|
||||
const [_$downloads, _$lastChecked, $dateUploaded] = $secondColumn.children;
|
||||
|
||||
return {
|
||||
magnet: $a?.href,
|
||||
fileSize: $totalSize.querySelector("span").textContent ?? undefined,
|
||||
uploadDate: formatUploadDate(
|
||||
$dateUploaded.querySelector("span").textContent!
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const getTorrentListLastPage = async (user: string) => {
|
||||
const response = await request1337x(`/user/${user}/1`);
|
||||
|
||||
const { window } = new JSDOM(response);
|
||||
|
||||
const $ul = window.document.querySelector(".pagination > ul");
|
||||
|
||||
if ($ul) {
|
||||
const $li = Array.from($ul.querySelectorAll("li")).at(-1);
|
||||
const text = $li?.textContent;
|
||||
|
||||
if (text === ">>") {
|
||||
const $previousLi = Array.from($ul.querySelectorAll("li")).at(-2);
|
||||
return Number($previousLi?.textContent);
|
||||
}
|
||||
|
||||
return Number(text);
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const extractTorrentsFromDocument = async (
|
||||
page: number,
|
||||
user: string,
|
||||
document: Document,
|
||||
existingRepacks: Repack[] = []
|
||||
): Promise<GameRepackInput[]> => {
|
||||
const $trs = Array.from(document.querySelectorAll("tbody tr"));
|
||||
|
||||
return Promise.all(
|
||||
$trs.map(async ($tr) => {
|
||||
const $td = $tr.querySelector("td");
|
||||
|
||||
const [, $name] = Array.from($td!.querySelectorAll("a"));
|
||||
const url = $name.href;
|
||||
const title = $name.textContent ?? "";
|
||||
|
||||
if (existingRepacks.some((repack) => repack.title === title)) {
|
||||
return {
|
||||
title,
|
||||
magnet: "",
|
||||
fileSize: null,
|
||||
uploadDate: null,
|
||||
repacker: user,
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
const details = await getTorrentDetails(url);
|
||||
|
||||
return {
|
||||
title,
|
||||
magnet: details.magnet,
|
||||
fileSize: details.fileSize ?? null,
|
||||
uploadDate: details.uploadDate ?? null,
|
||||
repacker: user,
|
||||
page,
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const getNewRepacksFromUser = async (
|
||||
user: string,
|
||||
existingRepacks: Repack[],
|
||||
page = 1
|
||||
): Promise<Repack[]> => {
|
||||
const response = await request1337x(`/user/${user}/${page}`);
|
||||
const { window } = new JSDOM(response);
|
||||
|
||||
const repacks = await extractTorrentsFromDocument(
|
||||
page,
|
||||
user,
|
||||
window.document,
|
||||
existingRepacks
|
||||
);
|
||||
|
||||
const newRepacks = repacks.filter(
|
||||
(repack) =>
|
||||
repack.uploadDate &&
|
||||
!existingRepacks.some(
|
||||
(existingRepack) => existingRepack.title === repack.title
|
||||
)
|
||||
);
|
||||
|
||||
if (!newRepacks.length) return;
|
||||
|
||||
await savePage(newRepacks);
|
||||
|
||||
return getNewRepacksFromUser(user, existingRepacks, page + 1);
|
||||
};
|
||||
65
src/main/services/repack-tracker/cpg-repacks.ts
Normal file
65
src/main/services/repack-tracker/cpg-repacks.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { Repack } from "@main/entity";
|
||||
|
||||
import { requestWebPage, savePage } from "./helpers";
|
||||
import type { GameRepackInput } from "./helpers";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export const getNewRepacksFromCPG = async (
|
||||
existingRepacks: Repack[] = [],
|
||||
page = 1
|
||||
): Promise<void> => {
|
||||
const data = await requestWebPage(`https://cpgrepacks.site/page/${page}`);
|
||||
|
||||
const { window } = new JSDOM(data);
|
||||
|
||||
const repacks: GameRepackInput[] = [];
|
||||
|
||||
try {
|
||||
Array.from(window.document.querySelectorAll(".post")).forEach(($post) => {
|
||||
const $title = $post.querySelector(".entry-title");
|
||||
const uploadDate = $post.querySelector("time").getAttribute("datetime");
|
||||
|
||||
const $downloadInfo = Array.from(
|
||||
$post.querySelectorAll(".wp-block-heading")
|
||||
).find(($heading) => $heading.textContent.startsWith("Download"));
|
||||
|
||||
/* Side note: CPG often misspells "Magnet" as "Magent" */
|
||||
const $magnet = Array.from($post.querySelectorAll("a")).find(
|
||||
($a) =>
|
||||
$a.textContent.startsWith("Magnet") ||
|
||||
$a.textContent.startsWith("Magent")
|
||||
);
|
||||
|
||||
const fileSize = $downloadInfo.textContent
|
||||
.split("Download link => ")
|
||||
.at(1);
|
||||
|
||||
repacks.push({
|
||||
title: $title.textContent,
|
||||
fileSize: fileSize ?? "N/A",
|
||||
magnet: $magnet.href,
|
||||
repacker: "CPG",
|
||||
page,
|
||||
uploadDate: new Date(uploadDate),
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err.message, { method: "getNewRepacksFromCPG" });
|
||||
}
|
||||
|
||||
const newRepacks = repacks.filter(
|
||||
(repack) =>
|
||||
repack.uploadDate &&
|
||||
!existingRepacks.some(
|
||||
(existingRepack) => existingRepack.title === repack.title
|
||||
)
|
||||
);
|
||||
|
||||
if (!newRepacks.length) return;
|
||||
|
||||
await savePage(newRepacks);
|
||||
|
||||
return getNewRepacksFromCPG(existingRepacks, page + 1);
|
||||
};
|
||||
78
src/main/services/repack-tracker/gog.ts
Normal file
78
src/main/services/repack-tracker/gog.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { JSDOM, VirtualConsole } from "jsdom";
|
||||
import { GameRepackInput, requestWebPage, savePage } from "./helpers";
|
||||
import { Repack } from "@main/entity";
|
||||
import { logger } from "../logger";
|
||||
|
||||
const virtualConsole = new VirtualConsole();
|
||||
|
||||
const getGOGGame = async (url: string) => {
|
||||
const data = await requestWebPage(url);
|
||||
const { window } = new JSDOM(data, { virtualConsole });
|
||||
|
||||
const $modifiedTime = window.document.querySelector(
|
||||
'[property="article:modified_time"]'
|
||||
) as HTMLMetaElement;
|
||||
|
||||
const $em = window.document.querySelector(
|
||||
"p:not(.lightweight-accordion *) em"
|
||||
);
|
||||
const fileSize = $em.textContent.split("Size: ").at(1);
|
||||
const $downloadButton = window.document.querySelector(
|
||||
".download-btn:not(.lightweight-accordion *)"
|
||||
) as HTMLAnchorElement;
|
||||
|
||||
const { searchParams } = new URL($downloadButton.href);
|
||||
const magnet = Buffer.from(searchParams.get("url"), "base64").toString(
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
return {
|
||||
fileSize: fileSize ?? "N/A",
|
||||
uploadDate: new Date($modifiedTime.content),
|
||||
repacker: "GOG",
|
||||
magnet,
|
||||
page: 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
|
||||
try {
|
||||
const data = await requestWebPage(
|
||||
"https://freegogpcgames.com/a-z-games-list/"
|
||||
);
|
||||
|
||||
const { window } = new JSDOM(data, { virtualConsole });
|
||||
|
||||
const $uls = Array.from(window.document.querySelectorAll(".az-columns"));
|
||||
|
||||
for (const $ul of $uls) {
|
||||
const repacks: GameRepackInput[] = [];
|
||||
const $lis = Array.from($ul.querySelectorAll("li"));
|
||||
|
||||
for (const $li of $lis) {
|
||||
const $a = $li.querySelector("a");
|
||||
const href = $a.href;
|
||||
|
||||
const title = $a.textContent.trim();
|
||||
|
||||
const gameExists = existingRepacks.some(
|
||||
(existingRepack) => existingRepack.title === title
|
||||
);
|
||||
|
||||
if (!gameExists) {
|
||||
try {
|
||||
const game = await getGOGGame(href);
|
||||
|
||||
repacks.push({ ...game, title });
|
||||
} catch (err) {
|
||||
logger.error(err.message, { method: "getGOGGame", url: href });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (repacks.length) await savePage(repacks);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err.message, { method: "getNewGOGGames" });
|
||||
}
|
||||
};
|
||||
18
src/main/services/repack-tracker/helpers.ts
Normal file
18
src/main/services/repack-tracker/helpers.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { repackRepository } from "@main/repository";
|
||||
|
||||
import type { GameRepack } from "@types";
|
||||
|
||||
export type GameRepackInput = Omit<
|
||||
GameRepack,
|
||||
"id" | "repackerFriendlyName" | "createdAt" | "updatedAt"
|
||||
>;
|
||||
|
||||
export const savePage = async (repacks: GameRepackInput[]) =>
|
||||
Promise.all(
|
||||
repacks.map((repack) => repackRepository.insert(repack).catch(() => {}))
|
||||
);
|
||||
|
||||
export const requestWebPage = async (url: string) =>
|
||||
fetch(url, {
|
||||
method: "GET",
|
||||
}).then((response) => response.text());
|
||||
4
src/main/services/repack-tracker/index.ts
Normal file
4
src/main/services/repack-tracker/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./1337x";
|
||||
export * from "./xatab";
|
||||
export * from "./cpg-repacks";
|
||||
export * from "./gog";
|
||||
95
src/main/services/repack-tracker/xatab.ts
Normal file
95
src/main/services/repack-tracker/xatab.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import parseTorrent, { toMagnetURI } from "parse-torrent";
|
||||
|
||||
import { Repack } from "@main/entity";
|
||||
import { logger } from "../logger";
|
||||
import { requestWebPage, savePage } from "./helpers";
|
||||
import type { GameRepackInput } from "./helpers";
|
||||
|
||||
const getTorrentBuffer = (url: string) =>
|
||||
fetch(url, { method: "GET" }).then((response) =>
|
||||
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
|
||||
);
|
||||
|
||||
const formatXatabDate = (str: string) => {
|
||||
const date = new Date();
|
||||
|
||||
const [day, month, year] = str.split(".");
|
||||
|
||||
date.setDate(Number(day));
|
||||
date.setMonth(Number(month) - 1);
|
||||
date.setFullYear(Number(year));
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
return date;
|
||||
};
|
||||
|
||||
const formatXatabDownloadSize = (str: string) =>
|
||||
str.replace(",", ".").replace(/Гб/g, "GB").replace(/Мб/g, "MB");
|
||||
|
||||
const getXatabRepack = async (url: string) => {
|
||||
const data = await requestWebPage(url);
|
||||
const { window } = new JSDOM(data);
|
||||
|
||||
const $uploadDate = window.document.querySelector(".entry__date");
|
||||
const $size = window.document.querySelector(".entry__info-size");
|
||||
|
||||
const $downloadButton = window.document.querySelector(
|
||||
".download-torrent"
|
||||
) as HTMLAnchorElement;
|
||||
|
||||
if (!$downloadButton) throw new Error("Download button not found");
|
||||
|
||||
const torrentBuffer = await getTorrentBuffer($downloadButton.href);
|
||||
|
||||
return {
|
||||
fileSize: formatXatabDownloadSize($size.textContent).toUpperCase(),
|
||||
magnet: toMagnetURI({
|
||||
infoHash: parseTorrent(torrentBuffer).infoHash,
|
||||
}),
|
||||
uploadDate: formatXatabDate($uploadDate.textContent),
|
||||
};
|
||||
};
|
||||
|
||||
export const getNewRepacksFromXatab = async (
|
||||
existingRepacks: Repack[] = [],
|
||||
page = 1
|
||||
): Promise<void> => {
|
||||
const data = await requestWebPage(`https://byxatab.com/page/${page}`);
|
||||
|
||||
const { window } = new JSDOM(data);
|
||||
|
||||
const repacks: GameRepackInput[] = [];
|
||||
|
||||
for (const $a of Array.from(
|
||||
window.document.querySelectorAll(".entry__title a")
|
||||
)) {
|
||||
try {
|
||||
const repack = await getXatabRepack(($a as HTMLAnchorElement).href);
|
||||
|
||||
repacks.push({
|
||||
title: $a.textContent,
|
||||
repacker: "Xatab",
|
||||
...repack,
|
||||
page,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err.message, { method: "getNewRepacksFromXatab" });
|
||||
}
|
||||
}
|
||||
|
||||
const newRepacks = repacks.filter(
|
||||
(repack) =>
|
||||
repack.uploadDate &&
|
||||
!existingRepacks.some(
|
||||
(existingRepack) => existingRepack.title === repack.title
|
||||
)
|
||||
);
|
||||
|
||||
if (!newRepacks.length) return;
|
||||
|
||||
await savePage(newRepacks);
|
||||
|
||||
return getNewRepacksFromXatab(existingRepacks, page + 1);
|
||||
};
|
||||
46
src/main/services/steam-250.ts
Normal file
46
src/main/services/steam-250.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios from "axios";
|
||||
import { JSDOM } from "jsdom";
|
||||
import shuffle from "lodash/shuffle";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const requestSteam250 = async (path: string) => {
|
||||
return axios
|
||||
.get(`https://steam250.com${path}`)
|
||||
.then((response) => response.data);
|
||||
};
|
||||
|
||||
export const getTrendingGames = async () => {
|
||||
const response = await requestSteam250("/365day").catch((err) => {
|
||||
logger.error(err.response, { method: "getTrendingGames" });
|
||||
throw new Error(err);
|
||||
});
|
||||
|
||||
const { window } = new JSDOM(response);
|
||||
const { document } = window;
|
||||
|
||||
return Array.from(document.querySelectorAll(".appline .title a")).map(
|
||||
($title) => $title.textContent!
|
||||
);
|
||||
};
|
||||
|
||||
const steam250Paths = [
|
||||
"/hidden_gems",
|
||||
`/${new Date().getFullYear()}`,
|
||||
"/top250",
|
||||
"/most_played",
|
||||
];
|
||||
|
||||
export const getRandomSteam250List = async () => {
|
||||
const [path] = shuffle(steam250Paths);
|
||||
const response = await requestSteam250(path).catch((err) => {
|
||||
logger.error(err.response, { method: "getRandomSteam250List" });
|
||||
throw new Error(err);
|
||||
});
|
||||
|
||||
const { window } = new JSDOM(response);
|
||||
const { document } = window;
|
||||
|
||||
return Array.from(document.querySelectorAll(".appline .title a")).map(
|
||||
($title) => $title.textContent!
|
||||
);
|
||||
};
|
||||
71
src/main/services/steam-grid.ts
Normal file
71
src/main/services/steam-grid.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getSteamAppAsset } from "@main/helpers";
|
||||
|
||||
export interface SteamGridResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamGridGameResponse {
|
||||
data: {
|
||||
platforms: {
|
||||
steam: {
|
||||
metadata: {
|
||||
clienticon: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const getSteamGridData = async (
|
||||
objectID: string,
|
||||
path: string,
|
||||
shop: string,
|
||||
params: Record<string, string> = {}
|
||||
): Promise<SteamGridResponse> => {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.STEAMGRIDDB_API_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getSteamGridGameById = async (
|
||||
id: number
|
||||
): Promise<SteamGridGameResponse> => {
|
||||
const response = await fetch(
|
||||
`https://www.steamgriddb.com/api/public/game/${id}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Referer: "https://www.steamgriddb.com/",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getSteamGameIconUrl = async (objectID: string) => {
|
||||
const {
|
||||
data: { id: steamGridGameId },
|
||||
} = await getSteamGridData(objectID, "games", "steam");
|
||||
|
||||
const steamGridGame = await getSteamGridGameById(steamGridGameId);
|
||||
|
||||
return getSteamAppAsset(
|
||||
"icon",
|
||||
objectID,
|
||||
steamGridGame.data.platforms.steam.metadata.clienticon
|
||||
);
|
||||
};
|
||||
78
src/main/services/steam.ts
Normal file
78
src/main/services/steam.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import axios from "axios";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import type { SteamAppDetails } from "@types";
|
||||
|
||||
import { logger } from "./logger";
|
||||
|
||||
export interface SteamAppDetailsResponse {
|
||||
[key: string]: {
|
||||
success: boolean;
|
||||
data: SteamAppDetails;
|
||||
};
|
||||
}
|
||||
|
||||
export const getSteamAppDetails = async (
|
||||
objectID: string,
|
||||
language: string
|
||||
) => {
|
||||
const searchParams = new URLSearchParams({
|
||||
appids: objectID,
|
||||
l: language,
|
||||
});
|
||||
|
||||
return axios
|
||||
.get(
|
||||
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.data[objectID].success) return response.data[objectID].data;
|
||||
return null;
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err, { method: "getSteamAppDetails" });
|
||||
throw new Error(err);
|
||||
});
|
||||
};
|
||||
|
||||
export const searchSteamGame = async (term: string) => {
|
||||
const searchParams = new URLSearchParams({
|
||||
start: "0",
|
||||
count: "12",
|
||||
sort_by: "_ASC",
|
||||
/* Games only */
|
||||
category1: "998",
|
||||
term: term,
|
||||
});
|
||||
|
||||
const response = await axios.get(
|
||||
`https://store.steampowered.com/search/results/?${searchParams.toString()}`
|
||||
);
|
||||
|
||||
const { window } = new JSDOM(response.data);
|
||||
const { document } = window;
|
||||
|
||||
const $anchors = Array.from(
|
||||
document.querySelectorAll("#search_resultsRows a")
|
||||
);
|
||||
|
||||
return $anchors.reduce((prev, $a) => {
|
||||
const $title = $a.querySelector(".title");
|
||||
const objectIDs = $a.getAttribute("data-ds-appid");
|
||||
|
||||
if (!objectIDs) return prev;
|
||||
|
||||
const [objectID] = objectIDs.split(",");
|
||||
|
||||
if (!objectID || prev.some((game) => game.objectID === objectID))
|
||||
return prev;
|
||||
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
name: $title.textContent,
|
||||
objectID,
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
};
|
||||
161
src/main/services/torrent-client.ts
Normal file
161
src/main/services/torrent-client.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import * as Sentry from "@sentry/electron/main";
|
||||
import { Notification, app } from "electron";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||
import { t } from "i18next";
|
||||
import { WindowManager } from "./window-manager";
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "hydra-download-manager",
|
||||
linux: "hydra-download-manager",
|
||||
win32: "hydra-download-manager.exe",
|
||||
};
|
||||
|
||||
enum TorrentState {
|
||||
CheckingFiles = 1,
|
||||
DownloadingMetadata = 2,
|
||||
Downloading = 3,
|
||||
Finished = 4,
|
||||
Seeding = 5,
|
||||
}
|
||||
|
||||
export interface TorrentUpdate {
|
||||
gameId: number;
|
||||
progress: number;
|
||||
downloadSpeed: number;
|
||||
timeRemaining: number;
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
status: TorrentState;
|
||||
folderName: string;
|
||||
fileSize: number;
|
||||
bytesDownloaded: number;
|
||||
}
|
||||
|
||||
export const BITTORRENT_PORT = "5881";
|
||||
|
||||
export class TorrentClient {
|
||||
public static startTorrentClient(
|
||||
writePipePath: string,
|
||||
readPipePath: string
|
||||
) {
|
||||
const commonArgs = [BITTORRENT_PORT, writePipePath, readPipePath];
|
||||
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform];
|
||||
const binaryPath = path.join(
|
||||
process.resourcesPath,
|
||||
"dist",
|
||||
"hydra-download-manager",
|
||||
binaryName
|
||||
);
|
||||
|
||||
cp.spawn(binaryPath, commonArgs, {
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"torrent-client",
|
||||
"main.py"
|
||||
);
|
||||
|
||||
cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
private static getTorrentStateName(state: TorrentState) {
|
||||
if (state === TorrentState.CheckingFiles) return "checking_files";
|
||||
if (state === TorrentState.Downloading) return "downloading";
|
||||
if (state === TorrentState.DownloadingMetadata)
|
||||
return "downloading_metadata";
|
||||
if (state === TorrentState.Finished) return "finished";
|
||||
if (state === TorrentState.Seeding) return "seeding";
|
||||
return "";
|
||||
}
|
||||
|
||||
private static getGameProgress(game: Game) {
|
||||
if (game.status === "checking_files") return game.fileVerificationProgress;
|
||||
return game.progress;
|
||||
}
|
||||
|
||||
public static async onSocketData(data: Buffer) {
|
||||
const message = Buffer.from(data).toString("utf-8");
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(message) as TorrentUpdate;
|
||||
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded: payload.bytesDownloaded,
|
||||
status: this.getTorrentStateName(payload.status),
|
||||
};
|
||||
|
||||
if (payload.status === TorrentState.CheckingFiles) {
|
||||
updatePayload.fileVerificationProgress = payload.progress;
|
||||
} else {
|
||||
if (payload.folderName) {
|
||||
updatePayload.folderName = payload.folderName;
|
||||
updatePayload.fileSize = payload.fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
[TorrentState.Downloading, TorrentState.Seeding].includes(
|
||||
payload.status
|
||||
)
|
||||
) {
|
||||
updatePayload.progress = payload.progress;
|
||||
}
|
||||
|
||||
await gameRepository.update({ id: payload.gameId }, updatePayload);
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: payload.gameId },
|
||||
relations: { repack: true },
|
||||
});
|
||||
|
||||
if (game.progress === 1) {
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (userPreferences?.downloadNotificationsEnabled) {
|
||||
new Notification({
|
||||
title: t("download_complete", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences.language,
|
||||
}),
|
||||
body: t("game_ready_to_install", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences.language,
|
||||
title: game.title,
|
||||
}),
|
||||
}).show();
|
||||
}
|
||||
}
|
||||
|
||||
if (WindowManager.mainWindow) {
|
||||
const progress = this.getGameProgress(game);
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(JSON.stringify({ ...payload, game }))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
Sentry.captureMessage(message, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
144
src/main/services/update-resolver.ts
Normal file
144
src/main/services/update-resolver.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
|
||||
import chunk from "lodash/chunk";
|
||||
|
||||
import { createDataSource, dataSource } from "@main/data-source";
|
||||
import { Repack, RepackerFriendlyName } from "@main/entity";
|
||||
import {
|
||||
migrationScriptRepository,
|
||||
repackRepository,
|
||||
repackerFriendlyNameRepository,
|
||||
} from "@main/repository";
|
||||
import { MigrationScript } from "@main/entity/migration-script.entity";
|
||||
import { Like } from "typeorm";
|
||||
|
||||
const migrationScripts = {
|
||||
/*
|
||||
0.0.6 -> 0.0.7
|
||||
Xatab repacks were previously created with an incorrect upload date.
|
||||
This migration script will update the upload date of all Xatab repacks.
|
||||
*/
|
||||
"0.0.7": async (updateRepacks: Repack[]) => {
|
||||
const VERSION = "0.0.7";
|
||||
|
||||
const migrationScript = await migrationScriptRepository.findOne({
|
||||
where: {
|
||||
version: VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
if (!migrationScript) {
|
||||
const xatabRepacks = updateRepacks.filter(
|
||||
(repack) => repack.repacker === "Xatab"
|
||||
);
|
||||
|
||||
await dataSource.transaction(async (transactionalEntityManager) => {
|
||||
await Promise.all(
|
||||
xatabRepacks.map((repack) =>
|
||||
transactionalEntityManager.getRepository(Repack).update(
|
||||
{
|
||||
title: repack.title,
|
||||
repacker: repack.repacker,
|
||||
},
|
||||
{
|
||||
uploadDate: repack.uploadDate,
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await transactionalEntityManager.getRepository(MigrationScript).insert({
|
||||
version: VERSION,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
/*
|
||||
1.0.1 -> 1.1.0
|
||||
A few torrents scraped from 1337x were previously created with an incorrect upload date.
|
||||
*/
|
||||
"1.1.0": async () => {
|
||||
const VERSION = "1.1.0";
|
||||
|
||||
const migrationScript = await migrationScriptRepository.findOne({
|
||||
where: {
|
||||
version: VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
if (!migrationScript) {
|
||||
await dataSource.transaction(async (transactionalEntityManager) => {
|
||||
const repacks = await transactionalEntityManager
|
||||
.getRepository(Repack)
|
||||
.find({
|
||||
where: {
|
||||
uploadDate: Like("1%"),
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
repacks.map(async (repack) => {
|
||||
return transactionalEntityManager
|
||||
.getRepository(Repack)
|
||||
.update(
|
||||
{ id: repack.id },
|
||||
{ uploadDate: new Date(repack.uploadDate) }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
await transactionalEntityManager.getRepository(MigrationScript).insert({
|
||||
version: VERSION,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const runMigrationScripts = async (updateRepacks: Repack[]) => {
|
||||
return Promise.all(
|
||||
Object.values(migrationScripts).map((migrationScript) => {
|
||||
return migrationScript(updateRepacks);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveDatabaseUpdates = async () => {
|
||||
const updateDataSource = createDataSource({
|
||||
database: app.isPackaged
|
||||
? path.join(process.resourcesPath, "hydra.db")
|
||||
: path.join(__dirname, "..", "..", "resources", "hydra.db"),
|
||||
});
|
||||
|
||||
return updateDataSource.initialize().then(async () => {
|
||||
const updateRepackRepository = updateDataSource.getRepository(Repack);
|
||||
const updateRepackerFriendlyNameRepository =
|
||||
updateDataSource.getRepository(RepackerFriendlyName);
|
||||
|
||||
const [updateRepacks, updateRepackerFriendlyNames] = await Promise.all([
|
||||
updateRepackRepository.find(),
|
||||
updateRepackerFriendlyNameRepository.find(),
|
||||
]);
|
||||
|
||||
await runMigrationScripts(updateRepacks);
|
||||
|
||||
await repackerFriendlyNameRepository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values(updateRepackerFriendlyNames)
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
const updateRepacksChunks = chunk(updateRepacks, 800);
|
||||
|
||||
for (const chunk of updateRepacksChunks) {
|
||||
await repackRepository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values(chunk)
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
};
|
||||
107
src/main/services/window-manager.ts
Normal file
107
src/main/services/window-manager.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { BrowserWindow, Menu, Tray, app } from "electron";
|
||||
import { t } from "i18next";
|
||||
import path from "node:path";
|
||||
|
||||
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
|
||||
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
|
||||
// whether you're running in development or production).
|
||||
declare const MAIN_WINDOW_WEBPACK_ENTRY: string;
|
||||
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;
|
||||
|
||||
export class WindowManager {
|
||||
public static mainWindow: Electron.BrowserWindow | null = null;
|
||||
|
||||
public static createMainWindow() {
|
||||
// Create the browser window.
|
||||
this.mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 720,
|
||||
minWidth: 1024,
|
||||
minHeight: 540,
|
||||
titleBarStyle: "hidden",
|
||||
icon: path.join(__dirname, "..", "..", "images", "icon.png"),
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
titleBarOverlay: {
|
||||
symbolColor: "#DADBE1",
|
||||
color: "#151515",
|
||||
height: 34,
|
||||
},
|
||||
webPreferences: {
|
||||
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
|
||||
},
|
||||
});
|
||||
|
||||
this.mainWindow.removeMenu();
|
||||
|
||||
this.mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);
|
||||
|
||||
this.mainWindow.webContents.on("did-finish-load", () => {
|
||||
if (!app.isPackaged) {
|
||||
// Open the DevTools.
|
||||
this.mainWindow.webContents.openDevTools();
|
||||
}
|
||||
});
|
||||
|
||||
this.mainWindow.on("close", () => {
|
||||
WindowManager.mainWindow.setProgressBar(-1);
|
||||
});
|
||||
}
|
||||
|
||||
public static redirect(path: string) {
|
||||
if (!this.mainWindow) this.createMainWindow();
|
||||
this.mainWindow.loadURL(`${MAIN_WINDOW_WEBPACK_ENTRY}#${path}`);
|
||||
|
||||
if (this.mainWindow.isMinimized()) this.mainWindow.restore();
|
||||
this.mainWindow.focus();
|
||||
}
|
||||
|
||||
public static createSystemTray(language: string) {
|
||||
const tray = new Tray(
|
||||
app.isPackaged
|
||||
? path.join(process.resourcesPath, "icon_tray.png")
|
||||
: path.join(__dirname, "..", "..", "resources", "icon_tray.png")
|
||||
);
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: t("open", {
|
||||
ns: "system_tray",
|
||||
lng: language,
|
||||
}),
|
||||
type: "normal",
|
||||
click: () => {
|
||||
if (this.mainWindow) {
|
||||
this.mainWindow.show();
|
||||
} else {
|
||||
this.createMainWindow();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("quit", {
|
||||
ns: "system_tray",
|
||||
lng: language,
|
||||
}),
|
||||
type: "normal",
|
||||
click: () => app.quit(),
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setToolTip("Hydra");
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
tray.addListener("click", () => {
|
||||
if (this.mainWindow) {
|
||||
if (WindowManager.mainWindow.isMinimized())
|
||||
WindowManager.mainWindow.restore();
|
||||
|
||||
WindowManager.mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
this.createMainWindow();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/main/state-manager.ts
Normal file
31
src/main/state-manager.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Repack, RepackerFriendlyName } from "@main/entity";
|
||||
|
||||
interface State {
|
||||
repacks: Repack[];
|
||||
repackersFriendlyNames: RepackerFriendlyName[];
|
||||
eventResults: Map<[string, any[]], any>;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
repacks: [],
|
||||
repackersFriendlyNames: [],
|
||||
eventResults: new Map(),
|
||||
};
|
||||
|
||||
export class StateManager {
|
||||
private state = initialState;
|
||||
|
||||
public setValue<T extends keyof State>(key: T, value: State[T]) {
|
||||
this.state = { ...this.state, [key]: value };
|
||||
}
|
||||
|
||||
public getValue<T extends keyof State>(key: T) {
|
||||
return this.state[key];
|
||||
}
|
||||
|
||||
public clearValue<T extends keyof State>(key: T) {
|
||||
this.state = { ...this.state, [key]: initialState[key] };
|
||||
}
|
||||
}
|
||||
|
||||
export const stateManager = new StateManager();
|
||||
Reference in New Issue
Block a user