mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-21 01:53:57 +00:00
first commit
This commit is contained in:
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user