mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-28 21:31:03 +00:00
Merge branch 'main' into linux-install
This commit is contained in:
@@ -33,15 +33,6 @@ export const months = [
|
||||
"Dec",
|
||||
];
|
||||
|
||||
export enum GameStatus {
|
||||
Seeding = "seeding",
|
||||
Downloading = "downloading",
|
||||
Paused = "paused",
|
||||
CheckingFiles = "checking_files",
|
||||
DownloadingMetadata = "downloading_metadata",
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
export const defaultDownloadsPath = app.getPath("downloads");
|
||||
|
||||
export const databasePath = path.join(
|
||||
@@ -50,7 +41,5 @@ export const databasePath = path.join(
|
||||
"hydra.db"
|
||||
);
|
||||
|
||||
export const imageCachePath = path.join(app.getPath("userData"), ".imagecache");
|
||||
|
||||
export const INSTALLATION_ID_LENGTH = 6;
|
||||
export const ACTIVATION_KEY_MULTIPLIER = 7;
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
OneToOne,
|
||||
JoinColumn,
|
||||
} from "typeorm";
|
||||
import type { GameShop } from "@types";
|
||||
import { Repack } from "./repack.entity";
|
||||
|
||||
import type { GameShop } from "@types";
|
||||
import { Downloader, GameStatus } from "@shared";
|
||||
|
||||
@Entity("game")
|
||||
export class Game {
|
||||
@PrimaryGeneratedColumn()
|
||||
@@ -40,8 +42,14 @@ export class Game {
|
||||
shop: GameShop;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
status: string | null;
|
||||
status: GameStatus | null;
|
||||
|
||||
@Column("int", { default: Downloader.Torrent })
|
||||
downloader: Downloader;
|
||||
|
||||
/**
|
||||
* Progress is a float between 0 and 1
|
||||
*/
|
||||
@Column("float", { default: 0 })
|
||||
progress: number;
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ export class UserPreferences {
|
||||
@Column("text", { default: "en" })
|
||||
language: string;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
realDebridApiToken: string | null;
|
||||
|
||||
@Column("boolean", { default: false })
|
||||
downloadNotificationsEnabled: boolean;
|
||||
|
||||
|
||||
@@ -8,42 +8,35 @@ import { requestSteam250 } from "@main/services";
|
||||
|
||||
const repacks = stateManager.getValue("repacks");
|
||||
|
||||
interface GetStringForLookup {
|
||||
(index: number): string;
|
||||
}
|
||||
const getStringForLookup = (index: number): string => {
|
||||
const repack = repacks[index];
|
||||
const formatter =
|
||||
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
|
||||
|
||||
return formatName(formatter(repack.title));
|
||||
};
|
||||
|
||||
const resultSize = 12;
|
||||
|
||||
const getCatalogue = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
category: CatalogueCategory
|
||||
) => {
|
||||
const getStringForLookup = (index: number): string => {
|
||||
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;
|
||||
|
||||
if (category === "trending") {
|
||||
return getTrendingCatalogue(resultSize);
|
||||
} else {
|
||||
return getRecentlyAddedCatalogue(
|
||||
resultSize,
|
||||
resultSize,
|
||||
getStringForLookup
|
||||
);
|
||||
}
|
||||
|
||||
return getRecentlyAddedCatalogue(resultSize);
|
||||
};
|
||||
|
||||
const getTrendingCatalogue = async (
|
||||
resultSize: number
|
||||
): Promise<CatalogueEntry[]> => {
|
||||
const results: CatalogueEntry[] = [];
|
||||
const trendingGames = await requestSteam250("/30day");
|
||||
const trendingGames = await requestSteam250("/90day");
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < trendingGames.length && results.length < resultSize;
|
||||
@@ -51,7 +44,7 @@ const getTrendingCatalogue = async (
|
||||
) {
|
||||
if (!trendingGames[i]) continue;
|
||||
|
||||
const { title, objectID } = trendingGames[i];
|
||||
const { title, objectID } = trendingGames[i]!;
|
||||
const repacks = searchRepacks(title);
|
||||
|
||||
if (title && repacks.length) {
|
||||
@@ -69,11 +62,8 @@ const getTrendingCatalogue = async (
|
||||
};
|
||||
|
||||
const getRecentlyAddedCatalogue = async (
|
||||
resultSize: number,
|
||||
requestSize: number,
|
||||
getStringForLookup: GetStringForLookup
|
||||
resultSize: number
|
||||
): Promise<CatalogueEntry[]> => {
|
||||
let lookupRequest = [];
|
||||
const results: CatalogueEntry[] = [];
|
||||
|
||||
for (let i = 0; results.length < resultSize; i++) {
|
||||
@@ -84,15 +74,7 @@ const getRecentlyAddedCatalogue = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
lookupRequest.push(searchGames({ query: stringForLookup }));
|
||||
|
||||
if (lookupRequest.length < requestSize) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const games = (await Promise.all(lookupRequest)).map((value) =>
|
||||
value.at(0)
|
||||
);
|
||||
const games = searchGames({ query: stringForLookup });
|
||||
|
||||
for (const game of games) {
|
||||
const isAlreadyIncluded = results.some(
|
||||
@@ -105,7 +87,6 @@ const getRecentlyAddedCatalogue = async (
|
||||
|
||||
results.push(game);
|
||||
}
|
||||
lookupRequest = [];
|
||||
}
|
||||
|
||||
return results.slice(0, resultSize);
|
||||
|
||||
@@ -28,8 +28,8 @@ export const generateYML = (game: Game) => {
|
||||
{
|
||||
task: {
|
||||
executable: path.join(
|
||||
game.downloadPath,
|
||||
game.folderName,
|
||||
game.downloadPath!,
|
||||
game.folderName!,
|
||||
"setup.exe"
|
||||
),
|
||||
name: "wineexec",
|
||||
|
||||
@@ -10,7 +10,9 @@ const closeGame = async (
|
||||
gameId: number
|
||||
) => {
|
||||
const processes = await getProcesses();
|
||||
const game = await gameRepository.findOne({ where: { id: gameId } });
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
});
|
||||
|
||||
if (!game) return false;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { GameStatus } from "@main/constants";
|
||||
import { GameStatus } from "@shared";
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
||||
@@ -11,11 +11,12 @@ import { registerEvent } from "../register-event";
|
||||
const deleteGameFolder = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number
|
||||
) => {
|
||||
): Promise<void> => {
|
||||
const game = await gameRepository.findOne({
|
||||
where: {
|
||||
id: gameId,
|
||||
status: GameStatus.Cancelled,
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,7 +38,8 @@ const deleteGameFolder = async (
|
||||
logger.error(error);
|
||||
reject();
|
||||
}
|
||||
resolve(null);
|
||||
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { GameStatus } from "@main/constants";
|
||||
|
||||
import { searchRepacks } from "../helpers/search-games";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { GameStatus } from "@shared";
|
||||
import { sortBy } from "lodash-es";
|
||||
|
||||
const getLibrary = async () =>
|
||||
|
||||
@@ -13,13 +13,15 @@ const openGameInstaller = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number
|
||||
) => {
|
||||
const game = await gameRepository.findOne({ where: { id: gameId } });
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
});
|
||||
|
||||
if (!game || !game.folderName) return true;
|
||||
|
||||
let gamePath = path.join(
|
||||
game.downloadPath ?? (await getDownloadsPath()),
|
||||
game.folderName
|
||||
game.folderName!
|
||||
);
|
||||
|
||||
if (!fs.existsSync(gamePath)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gameRepository } from "../../repository";
|
||||
import { GameStatus } from "@main/constants";
|
||||
import { GameStatus } from "@shared";
|
||||
|
||||
const removeGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
|
||||
@@ -7,8 +7,10 @@ const showOpenDialog = async (
|
||||
options: Electron.OpenDialogOptions
|
||||
) => {
|
||||
if (WindowManager.mainWindow) {
|
||||
dialog.showOpenDialog(WindowManager.mainWindow, options);
|
||||
return dialog.showOpenDialog(WindowManager.mainWindow, options);
|
||||
}
|
||||
|
||||
throw new Error("Main window is not available");
|
||||
};
|
||||
|
||||
registerEvent(showOpenDialog, {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { GameStatus } from "@main/constants";
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { WindowManager, writePipe } from "@main/services";
|
||||
import { WindowManager } from "@main/services";
|
||||
|
||||
import { In } from "typeorm";
|
||||
import { DownloadManager } from "@main/services";
|
||||
import { GameStatus } from "@shared";
|
||||
|
||||
const cancelGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -13,17 +14,20 @@ const cancelGameDownload = async (
|
||||
const game = await gameRepository.findOne({
|
||||
where: {
|
||||
id: gameId,
|
||||
isDeleted: false,
|
||||
status: In([
|
||||
GameStatus.Downloading,
|
||||
GameStatus.DownloadingMetadata,
|
||||
GameStatus.CheckingFiles,
|
||||
GameStatus.Paused,
|
||||
GameStatus.Seeding,
|
||||
GameStatus.Finished,
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
if (!game) return;
|
||||
DownloadManager.cancelDownload();
|
||||
|
||||
await gameRepository
|
||||
.update(
|
||||
@@ -41,7 +45,6 @@ const cancelGameDownload = async (
|
||||
game.status !== GameStatus.Paused &&
|
||||
game.status !== GameStatus.Seeding
|
||||
) {
|
||||
writePipe.write({ action: "cancel" });
|
||||
if (result.affected) WindowManager.mainWindow?.setProgressBar(-1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { WindowManager, writePipe } from "@main/services";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { GameStatus } from "../../constants";
|
||||
import { gameRepository } from "../../repository";
|
||||
import { In } from "typeorm";
|
||||
import { DownloadManager, WindowManager } from "@main/services";
|
||||
import { GameStatus } from "@shared";
|
||||
|
||||
const pauseGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number
|
||||
) => {
|
||||
DownloadManager.pauseDownload();
|
||||
|
||||
await gameRepository
|
||||
.update(
|
||||
{
|
||||
@@ -22,10 +23,7 @@ const pauseGameDownload = async (
|
||||
{ status: GameStatus.Paused }
|
||||
)
|
||||
.then((result) => {
|
||||
if (result.affected) {
|
||||
writePipe.write({ action: "pause" });
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
}
|
||||
if (result.affected) WindowManager.mainWindow?.setProgressBar(-1);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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";
|
||||
import { DownloadManager } from "@main/services";
|
||||
import { GameStatus } from "@shared";
|
||||
|
||||
const resumeGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -12,23 +12,18 @@ const resumeGameDownload = async (
|
||||
const game = await gameRepository.findOne({
|
||||
where: {
|
||||
id: gameId,
|
||||
isDeleted: false,
|
||||
},
|
||||
relations: { repack: true },
|
||||
});
|
||||
|
||||
if (!game) return;
|
||||
|
||||
writePipe.write({ action: "pause" });
|
||||
DownloadManager.pauseDownload();
|
||||
|
||||
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,
|
||||
});
|
||||
DownloadManager.resumeDownload(gameId);
|
||||
|
||||
await gameRepository.update(
|
||||
{
|
||||
@@ -44,7 +39,7 @@ const resumeGameDownload = async (
|
||||
await gameRepository.update(
|
||||
{ id: game.id },
|
||||
{
|
||||
status: GameStatus.DownloadingMetadata,
|
||||
status: GameStatus.Downloading,
|
||||
downloadPath: downloadsPath,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getSteamGameIconUrl, writePipe } from "@main/services";
|
||||
import { gameRepository, repackRepository } from "@main/repository";
|
||||
import { GameStatus } from "@main/constants";
|
||||
import { getSteamGameIconUrl } from "@main/services";
|
||||
import {
|
||||
gameRepository,
|
||||
repackRepository,
|
||||
userPreferencesRepository,
|
||||
} from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
import type { GameShop } from "@types";
|
||||
import { getFileBase64 } from "@main/helpers";
|
||||
import { In } from "typeorm";
|
||||
import { DownloadManager } from "@main/services";
|
||||
import { Downloader, GameStatus } from "@shared";
|
||||
|
||||
const startGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -16,6 +21,14 @@ const startGameDownload = async (
|
||||
gameShop: GameShop,
|
||||
downloadPath: string
|
||||
) => {
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const downloader = userPreferences?.realDebridApiToken
|
||||
? Downloader.RealDebrid
|
||||
: Downloader.Torrent;
|
||||
|
||||
const [game, repack] = await Promise.all([
|
||||
gameRepository.findOne({
|
||||
where: {
|
||||
@@ -29,13 +42,8 @@ const startGameDownload = async (
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!repack) return;
|
||||
|
||||
if (game?.status === GameStatus.Downloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
writePipe.write({ action: "pause" });
|
||||
if (!repack || game?.status === GameStatus.Downloading) return;
|
||||
DownloadManager.pauseDownload();
|
||||
|
||||
await gameRepository.update(
|
||||
{
|
||||
@@ -56,17 +64,13 @@ const startGameDownload = async (
|
||||
{
|
||||
status: GameStatus.DownloadingMetadata,
|
||||
downloadPath: downloadPath,
|
||||
downloader,
|
||||
repack: { id: repackId },
|
||||
isDeleted: false,
|
||||
}
|
||||
);
|
||||
|
||||
writePipe.write({
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
magnet: repack.magnet,
|
||||
save_path: downloadPath,
|
||||
});
|
||||
DownloadManager.downloadGame(game.id);
|
||||
|
||||
game.status = GameStatus.DownloadingMetadata;
|
||||
|
||||
@@ -78,18 +82,14 @@ const startGameDownload = async (
|
||||
title,
|
||||
iconUrl,
|
||||
objectID,
|
||||
downloader,
|
||||
shop: gameShop,
|
||||
status: GameStatus.DownloadingMetadata,
|
||||
downloadPath: downloadPath,
|
||||
status: GameStatus.Downloading,
|
||||
downloadPath,
|
||||
repack: { id: repackId },
|
||||
});
|
||||
|
||||
writePipe.write({
|
||||
action: "start",
|
||||
game_id: createdGame.id,
|
||||
magnet: repack.magnet,
|
||||
save_path: downloadPath,
|
||||
});
|
||||
DownloadManager.downloadGame(createdGame.id);
|
||||
|
||||
const { repack: _, ...rest } = createdGame;
|
||||
|
||||
|
||||
@@ -2,11 +2,16 @@ import { userPreferencesRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
import type { UserPreferences } from "@types";
|
||||
import { RealDebridClient } from "@main/services/real-debrid";
|
||||
|
||||
const updateUserPreferences = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
preferences: Partial<UserPreferences>
|
||||
) => {
|
||||
if (preferences.realDebridApiToken) {
|
||||
RealDebridClient.authorize(preferences.realDebridApiToken);
|
||||
}
|
||||
|
||||
await userPreferencesRepository.upsert(
|
||||
{
|
||||
id: 1,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { stateManager } from "./state-manager";
|
||||
import { GameStatus, repackers } from "./constants";
|
||||
import { repackers } from "./constants";
|
||||
import {
|
||||
getNewGOGGames,
|
||||
getNewRepacksFromCPG,
|
||||
getNewRepacksFromUser,
|
||||
getNewRepacksFromXatab,
|
||||
getNewRepacksFromOnlineFix,
|
||||
readPipe,
|
||||
startProcessWatcher,
|
||||
writePipe,
|
||||
DownloadManager,
|
||||
} from "./services";
|
||||
import {
|
||||
gameRepository,
|
||||
@@ -17,42 +16,16 @@ import {
|
||||
steamGameRepository,
|
||||
userPreferencesRepository,
|
||||
} from "./repository";
|
||||
import { TorrentClient } from "./services/torrent-client";
|
||||
import { Repack } from "./entity";
|
||||
import { TorrentDownloader } from "./services";
|
||||
import { Repack, UserPreferences } from "./entity";
|
||||
import { Notification } from "electron";
|
||||
import { t } from "i18next";
|
||||
import { GameStatus } from "@shared";
|
||||
import { In } from "typeorm";
|
||||
import { RealDebridClient } from "./services/real-debrid";
|
||||
|
||||
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(
|
||||
@@ -62,11 +35,7 @@ const track1337xUsers = async (existingRepacks: Repack[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkForNewRepacks = async () => {
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const checkForNewRepacks = async (userPreferences: UserPreferences | null) => {
|
||||
const existingRepacks = stateManager.getValue("repacks");
|
||||
|
||||
Promise.allSettled([
|
||||
@@ -104,7 +73,7 @@ const checkForNewRepacks = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const loadState = async () => {
|
||||
const loadState = async (userPreferences: UserPreferences | null) => {
|
||||
const [friendlyNames, repacks, steamGames] = await Promise.all([
|
||||
repackerFriendlyNameRepository.find(),
|
||||
repackRepository.find({
|
||||
@@ -124,6 +93,33 @@ const loadState = async () => {
|
||||
stateManager.setValue("steamGames", steamGames);
|
||||
|
||||
import("./events");
|
||||
|
||||
if (userPreferences?.realDebridApiToken)
|
||||
await RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: {
|
||||
status: In([
|
||||
GameStatus.Downloading,
|
||||
GameStatus.DownloadingMetadata,
|
||||
GameStatus.CheckingFiles,
|
||||
]),
|
||||
isDeleted: false,
|
||||
},
|
||||
relations: { repack: true },
|
||||
});
|
||||
|
||||
await TorrentDownloader.startClient();
|
||||
|
||||
if (game) {
|
||||
DownloadManager.resumeDownload(game.id);
|
||||
}
|
||||
};
|
||||
|
||||
loadState().then(() => checkForNewRepacks());
|
||||
userPreferencesRepository
|
||||
.findOne({
|
||||
where: { id: 1 },
|
||||
})
|
||||
.then((userPreferences) => {
|
||||
loadState(userPreferences).then(() => checkForNewRepacks(userPreferences));
|
||||
});
|
||||
|
||||
76
src/main/services/download-manager.ts
Normal file
76
src/main/services/download-manager.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
import type { Game } from "@main/entity";
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
import { writePipe } from "./fifo";
|
||||
import { RealDebridDownloader } from "./downloaders";
|
||||
|
||||
export class DownloadManager {
|
||||
private static gameDownloading: Game;
|
||||
|
||||
static async getGame(gameId: number) {
|
||||
return gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
relations: {
|
||||
repack: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async cancelDownload() {
|
||||
if (
|
||||
this.gameDownloading &&
|
||||
this.gameDownloading.downloader === Downloader.Torrent
|
||||
) {
|
||||
writePipe.write({ action: "cancel" });
|
||||
} else {
|
||||
RealDebridDownloader.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
if (
|
||||
this.gameDownloading &&
|
||||
this.gameDownloading.downloader === Downloader.Torrent
|
||||
) {
|
||||
writePipe.write({ action: "pause" });
|
||||
} else {
|
||||
RealDebridDownloader.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
static async resumeDownload(gameId: number) {
|
||||
const game = await this.getGame(gameId);
|
||||
|
||||
if (game!.downloader === Downloader.Torrent) {
|
||||
writePipe.write({
|
||||
action: "start",
|
||||
game_id: game!.id,
|
||||
magnet: game!.repack.magnet,
|
||||
save_path: game!.downloadPath,
|
||||
});
|
||||
} else {
|
||||
RealDebridDownloader.startDownload(game!);
|
||||
}
|
||||
|
||||
this.gameDownloading = game!;
|
||||
}
|
||||
|
||||
static async downloadGame(gameId: number) {
|
||||
const game = await this.getGame(gameId);
|
||||
|
||||
if (game!.downloader === Downloader.Torrent) {
|
||||
writePipe.write({
|
||||
action: "start",
|
||||
game_id: game!.id,
|
||||
magnet: game!.repack.magnet,
|
||||
save_path: game!.downloadPath,
|
||||
});
|
||||
} else {
|
||||
RealDebridDownloader.startDownload(game!);
|
||||
}
|
||||
|
||||
this.gameDownloading = game!;
|
||||
}
|
||||
}
|
||||
85
src/main/services/downloaders/downloader.ts
Normal file
85
src/main/services/downloaders/downloader.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { t } from "i18next";
|
||||
import { Notification } from "electron";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
|
||||
import { WindowManager } from "../window-manager";
|
||||
import type { TorrentUpdate } from "./torrent.downloader";
|
||||
|
||||
import { GameStatus } from "@shared";
|
||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||
|
||||
interface DownloadStatus {
|
||||
numPeers?: number;
|
||||
numSeeds?: number;
|
||||
downloadSpeed?: number;
|
||||
timeRemaining?: number;
|
||||
}
|
||||
|
||||
export class Downloader {
|
||||
static getGameProgress(game: Game) {
|
||||
if (game.status === GameStatus.CheckingFiles)
|
||||
return game.fileVerificationProgress;
|
||||
|
||||
return game.progress;
|
||||
}
|
||||
|
||||
static async updateGameProgress(
|
||||
gameId: number,
|
||||
gameUpdate: QueryDeepPartialEntity<Game>,
|
||||
downloadStatus: DownloadStatus
|
||||
) {
|
||||
await gameRepository.update({ id: gameId }, gameUpdate);
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
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 && game) {
|
||||
const progress = this.getGameProgress(game);
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(
|
||||
JSON.stringify({
|
||||
...({
|
||||
progress: gameUpdate.progress,
|
||||
bytesDownloaded: gameUpdate.bytesDownloaded,
|
||||
fileSize: gameUpdate.fileSize,
|
||||
gameId,
|
||||
numPeers: downloadStatus.numPeers,
|
||||
numSeeds: downloadStatus.numSeeds,
|
||||
downloadSpeed: downloadStatus.downloadSpeed,
|
||||
timeRemaining: downloadStatus.timeRemaining,
|
||||
} as TorrentUpdate),
|
||||
game,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/main/services/downloaders/index.ts
Normal file
2
src/main/services/downloaders/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./real-debrid.downloader";
|
||||
export * from "./torrent.downloader";
|
||||
115
src/main/services/downloaders/real-debrid.downloader.ts
Normal file
115
src/main/services/downloaders/real-debrid.downloader.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Game } from "@main/entity";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import EasyDL from "easydl";
|
||||
import { GameStatus } from "@shared";
|
||||
// import { fullArchive } from "node-7z-archive";
|
||||
|
||||
import { Downloader } from "./downloader";
|
||||
import { RealDebridClient } from "../real-debrid";
|
||||
|
||||
export class RealDebridDownloader extends Downloader {
|
||||
private static download: EasyDL;
|
||||
private static downloadSize = 0;
|
||||
|
||||
private static getEta(bytesDownloaded: number, speed: number) {
|
||||
const remainingBytes = this.downloadSize - bytesDownloaded;
|
||||
|
||||
if (remainingBytes >= 0 && speed > 0) {
|
||||
return (remainingBytes / speed) * 1000;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static createFolderIfNotExists(path: string) {
|
||||
if (!fs.existsSync(path)) {
|
||||
fs.mkdirSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
// private static async startDecompression(
|
||||
// rarFile: string,
|
||||
// dest: string,
|
||||
// game: Game
|
||||
// ) {
|
||||
// await fullArchive(rarFile, dest);
|
||||
|
||||
// const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
// status: GameStatus.Finished,
|
||||
// };
|
||||
|
||||
// await this.updateGameProgress(game.id, updatePayload, {});
|
||||
// }
|
||||
|
||||
static destroy() {
|
||||
if (this.download) {
|
||||
this.download.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (this.download) this.download.destroy();
|
||||
const downloadUrl = decodeURIComponent(
|
||||
await RealDebridClient.getDownloadUrl(game)
|
||||
);
|
||||
|
||||
const filename = path.basename(downloadUrl);
|
||||
const folderName = path.basename(filename, path.extname(filename));
|
||||
|
||||
const downloadPath = path.join(game.downloadPath!, folderName);
|
||||
this.createFolderIfNotExists(downloadPath);
|
||||
|
||||
this.download = new EasyDL(downloadUrl, path.join(downloadPath, filename));
|
||||
|
||||
const metadata = await this.download.metadata();
|
||||
|
||||
this.downloadSize = metadata.size;
|
||||
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
status: GameStatus.Downloading,
|
||||
fileSize: metadata.size,
|
||||
folderName,
|
||||
};
|
||||
|
||||
const downloadStatus = {
|
||||
timeRemaining: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
|
||||
await this.updateGameProgress(game.id, updatePayload, downloadStatus);
|
||||
|
||||
this.download.on("progress", async ({ total }) => {
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
status: GameStatus.Downloading,
|
||||
progress: Math.min(0.99, total.percentage / 100),
|
||||
bytesDownloaded: total.bytes,
|
||||
};
|
||||
|
||||
const downloadStatus = {
|
||||
downloadSpeed: total.speed,
|
||||
timeRemaining: this.getEta(total.bytes ?? 0, total.speed ?? 0),
|
||||
};
|
||||
|
||||
await this.updateGameProgress(game.id, updatePayload, downloadStatus);
|
||||
});
|
||||
|
||||
this.download.on("end", async () => {
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
status: GameStatus.Finished,
|
||||
progress: 1,
|
||||
};
|
||||
|
||||
await this.updateGameProgress(game.id, updatePayload, {
|
||||
timeRemaining: 0,
|
||||
});
|
||||
|
||||
/* This has to be improved */
|
||||
// this.startDecompression(
|
||||
// path.join(downloadPath, filename),
|
||||
// downloadPath,
|
||||
// game
|
||||
// );
|
||||
});
|
||||
}
|
||||
}
|
||||
160
src/main/services/downloaders/torrent.downloader.ts
Normal file
160
src/main/services/downloaders/torrent.downloader.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import * as Sentry from "@sentry/electron/main";
|
||||
import { app, dialog } from "electron";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
import { GameStatus } from "@shared";
|
||||
import { Downloader } from "./downloader";
|
||||
import { readPipe, writePipe } from "../fifo";
|
||||
|
||||
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 TorrentDownloader extends Downloader {
|
||||
private static messageLength = 1024 * 2;
|
||||
|
||||
public static async attachListener() {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const buffer = readPipe.socket?.read(this.messageLength);
|
||||
|
||||
if (buffer === null) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = Buffer.from(
|
||||
buffer.slice(0, buffer.indexOf(0x00))
|
||||
).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;
|
||||
}
|
||||
|
||||
this.updateGameProgress(payload.gameId, updatePayload, {
|
||||
numPeers: payload.numPeers,
|
||||
numSeeds: payload.numSeeds,
|
||||
downloadSpeed: payload.downloadSpeed,
|
||||
timeRemaining: payload.timeRemaining,
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
} finally {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static startClient() {
|
||||
return new Promise((resolve) => {
|
||||
const commonArgs = [
|
||||
BITTORRENT_PORT,
|
||||
writePipe.socketPath,
|
||||
readPipe.socketPath,
|
||||
];
|
||||
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
||||
const binaryPath = path.join(
|
||||
process.resourcesPath,
|
||||
"hydra-download-manager",
|
||||
binaryName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
dialog.showErrorBox(
|
||||
"Fatal",
|
||||
"Hydra download manager binary not found. Please check if it has been removed by Windows Defender."
|
||||
);
|
||||
|
||||
app.quit();
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(
|
||||
async () => {
|
||||
this.attachListener();
|
||||
resolve(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private static getTorrentStateName(state: TorrentState) {
|
||||
if (state === TorrentState.CheckingFiles) return GameStatus.CheckingFiles;
|
||||
if (state === TorrentState.Downloading) return GameStatus.Downloading;
|
||||
if (state === TorrentState.DownloadingMetadata)
|
||||
return GameStatus.DownloadingMetadata;
|
||||
if (state === TorrentState.Finished) return GameStatus.Finished;
|
||||
if (state === TorrentState.Seeding) return GameStatus.Seeding;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export * from "./steam-grid";
|
||||
export * from "./update-resolver";
|
||||
export * from "./window-manager";
|
||||
export * from "./fifo";
|
||||
export * from "./torrent-client";
|
||||
export * from "./downloaders";
|
||||
export * from "./download-manager";
|
||||
export * from "./how-long-to-beat";
|
||||
export * from "./process-watcher";
|
||||
|
||||
@@ -16,6 +16,7 @@ export const startProcessWatcher = async () => {
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
executablePath: Not(IsNull()),
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
102
src/main/services/real-debrid.ts
Normal file
102
src/main/services/real-debrid.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Game } from "@main/entity";
|
||||
import type {
|
||||
RealDebridAddMagnet,
|
||||
RealDebridTorrentInfo,
|
||||
RealDebridUnrestrictLink,
|
||||
} from "./real-debrid.types";
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
|
||||
const base = "https://api.real-debrid.com/rest/1.0";
|
||||
|
||||
export class RealDebridClient {
|
||||
private static instance: AxiosInstance;
|
||||
|
||||
static async addMagnet(magnet: string) {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("magnet", magnet);
|
||||
|
||||
const response = await this.instance.post<RealDebridAddMagnet>(
|
||||
"/torrents/addMagnet",
|
||||
searchParams.toString()
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async getInfo(id: string) {
|
||||
const response = await this.instance.get<RealDebridTorrentInfo>(
|
||||
`/torrents/info/${id}`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async selectAllFiles(id: string) {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("files", "all");
|
||||
|
||||
await this.instance.post(
|
||||
`/torrents/selectFiles/${id}`,
|
||||
searchParams.toString()
|
||||
);
|
||||
}
|
||||
|
||||
static async unrestrictLink(link: string) {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("link", link);
|
||||
|
||||
const response = await this.instance.post<RealDebridUnrestrictLink>(
|
||||
"/unrestrict/link",
|
||||
searchParams.toString()
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async getAllTorrentsFromUser() {
|
||||
const response =
|
||||
await this.instance.get<RealDebridTorrentInfo[]>("/torrents");
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static extractSHA1FromMagnet(magnet: string) {
|
||||
return magnet.match(/btih:([0-9a-fA-F]*)/)?.[1].toLowerCase();
|
||||
}
|
||||
|
||||
static async getDownloadUrl(game: Game) {
|
||||
const torrents = await RealDebridClient.getAllTorrentsFromUser();
|
||||
const hash = RealDebridClient.extractSHA1FromMagnet(game!.repack.magnet);
|
||||
let torrent = torrents.find((t) => t.hash === hash);
|
||||
|
||||
if (!torrent) {
|
||||
const magnet = await RealDebridClient.addMagnet(game!.repack.magnet);
|
||||
|
||||
if (magnet && magnet.id) {
|
||||
await RealDebridClient.selectAllFiles(magnet.id);
|
||||
torrent = await RealDebridClient.getInfo(magnet.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (torrent) {
|
||||
const { links } = torrent;
|
||||
const { download } = await RealDebridClient.unrestrictLink(links[0]);
|
||||
|
||||
if (!download) {
|
||||
throw new Error("Torrent not cached on Real Debrid");
|
||||
}
|
||||
|
||||
return download;
|
||||
}
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
static async authorize(apiToken: string) {
|
||||
this.instance = axios.create({
|
||||
baseURL: base,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
51
src/main/services/real-debrid.types.ts
Normal file
51
src/main/services/real-debrid.types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface RealDebridUnrestrictLink {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
filesize: number;
|
||||
link: string;
|
||||
host: string;
|
||||
host_icon: string;
|
||||
chunks: number;
|
||||
crc: number;
|
||||
download: string;
|
||||
streamable: number;
|
||||
}
|
||||
|
||||
export interface RealDebridAddMagnet {
|
||||
id: string;
|
||||
// URL of the created ressource
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export interface RealDebridTorrentInfo {
|
||||
id: string;
|
||||
filename: string;
|
||||
original_filename: string; // Original name of the torrent
|
||||
hash: string; // SHA1 Hash of the torrent
|
||||
bytes: number; // Size of selected files only
|
||||
original_bytes: number; // Total size of the torrent
|
||||
host: string; // Host main domain
|
||||
split: number; // Split size of links
|
||||
progress: number; // Possible values: 0 to 100
|
||||
status: string; // Current status of the torrent: magnet_error, magnet_conversion, waiting_files_selection, queued, downloading, downloaded, error, virus, compressing, uploading, dead
|
||||
added: string; // jsonDate
|
||||
files: [
|
||||
{
|
||||
id: number;
|
||||
path: string; // Path to the file inside the torrent, starting with "/"
|
||||
bytes: number;
|
||||
selected: number; // 0 or 1
|
||||
},
|
||||
{
|
||||
id: number;
|
||||
path: string; // Path to the file inside the torrent, starting with "/"
|
||||
bytes: number;
|
||||
selected: number; // 0 or 1
|
||||
},
|
||||
];
|
||||
links: string[];
|
||||
ended: string; // !! Only present when finished, jsonDate
|
||||
speed: number; // !! Only present in "downloading", "compressing", "uploading" status
|
||||
seeders: number; // !! Only present in "downloading", "magnet_conversion" status
|
||||
}
|
||||
@@ -33,9 +33,9 @@ const getTorrentDetails = async (path: string) => {
|
||||
|
||||
return {
|
||||
magnet: $a?.href,
|
||||
fileSize: $totalSize.querySelector("span").textContent ?? undefined,
|
||||
fileSize: $totalSize.querySelector("span")!.textContent,
|
||||
uploadDate: formatUploadDate(
|
||||
$dateUploaded.querySelector("span").textContent!
|
||||
$dateUploaded.querySelector("span")!.textContent!
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -65,8 +65,7 @@ export const getTorrentListLastPage = async (user: string) => {
|
||||
export const extractTorrentsFromDocument = async (
|
||||
page: number,
|
||||
user: string,
|
||||
document: Document,
|
||||
existingRepacks: Repack[] = []
|
||||
document: Document
|
||||
) => {
|
||||
const $trs = Array.from(document.querySelectorAll("tbody tr"));
|
||||
|
||||
@@ -78,24 +77,13 @@ export const extractTorrentsFromDocument = async (
|
||||
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,
|
||||
fileSize: details.fileSize ?? "N/A",
|
||||
uploadDate: details.uploadDate ?? new Date(),
|
||||
repacker: user,
|
||||
page,
|
||||
};
|
||||
@@ -114,13 +102,11 @@ export const getNewRepacksFromUser = async (
|
||||
const repacks = await extractTorrentsFromDocument(
|
||||
page,
|
||||
user,
|
||||
window.document,
|
||||
existingRepacks
|
||||
window.document
|
||||
);
|
||||
|
||||
const newRepacks = repacks.filter(
|
||||
(repack) =>
|
||||
repack.uploadDate &&
|
||||
!existingRepacks.some(
|
||||
(existingRepack) => existingRepack.title === repack.title
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Repack } from "@main/entity";
|
||||
|
||||
import { requestWebPage, savePage } from "./helpers";
|
||||
import { logger } from "../logger";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
|
||||
export const getNewRepacksFromCPG = async (
|
||||
existingRepacks: Repack[] = [],
|
||||
@@ -13,11 +14,11 @@ export const getNewRepacksFromCPG = async (
|
||||
|
||||
const { window } = new JSDOM(data);
|
||||
|
||||
const repacks = [];
|
||||
const repacks: QueryDeepPartialEntity<Repack>[] = [];
|
||||
|
||||
try {
|
||||
Array.from(window.document.querySelectorAll(".post")).forEach(($post) => {
|
||||
const $title = $post.querySelector(".entry-title");
|
||||
const $title = $post.querySelector(".entry-title")!;
|
||||
const uploadDate = $post.querySelector("time")?.getAttribute("datetime");
|
||||
|
||||
const $downloadInfo = Array.from(
|
||||
@@ -31,26 +32,25 @@ export const getNewRepacksFromCPG = async (
|
||||
$a.textContent?.startsWith("Magent")
|
||||
);
|
||||
|
||||
const fileSize = $downloadInfo.textContent
|
||||
const fileSize = ($downloadInfo?.textContent ?? "")
|
||||
.split("Download link => ")
|
||||
.at(1);
|
||||
|
||||
repacks.push({
|
||||
title: $title.textContent,
|
||||
title: $title.textContent!,
|
||||
fileSize: fileSize ?? "N/A",
|
||||
magnet: $magnet.href,
|
||||
magnet: $magnet!.href,
|
||||
repacker: "CPG",
|
||||
page,
|
||||
uploadDate: new Date(uploadDate),
|
||||
uploadDate: uploadDate ? new Date(uploadDate) : new Date(),
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err.message, { method: "getNewRepacksFromCPG" });
|
||||
} catch (err: unknown) {
|
||||
logger.error((err as Error).message, { method: "getNewRepacksFromCPG" });
|
||||
}
|
||||
|
||||
const newRepacks = repacks.filter(
|
||||
(repack) =>
|
||||
repack.uploadDate &&
|
||||
!existingRepacks.some(
|
||||
(existingRepack) => existingRepack.title === repack.title
|
||||
)
|
||||
|
||||
@@ -16,14 +16,14 @@ const getGOGGame = async (url: string) => {
|
||||
|
||||
const $em = window.document.querySelector(
|
||||
"p:not(.lightweight-accordion *) em"
|
||||
);
|
||||
const fileSize = $em.textContent.split("Size: ").at(1);
|
||||
)!;
|
||||
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(
|
||||
const magnet = Buffer.from(searchParams.get("url")!, "base64").toString(
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
@@ -50,10 +50,10 @@ export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
|
||||
const $lis = Array.from($ul.querySelectorAll("li"));
|
||||
|
||||
for (const $li of $lis) {
|
||||
const $a = $li.querySelector("a");
|
||||
const $a = $li.querySelector("a")!;
|
||||
const href = $a.href;
|
||||
|
||||
const title = $a.textContent.trim();
|
||||
const title = $a.textContent!.trim();
|
||||
|
||||
const gameExists = existingRepacks.some(
|
||||
(existingRepack) => existingRepack.title === title
|
||||
|
||||
@@ -13,6 +13,9 @@ import { ru } from "date-fns/locale";
|
||||
import { onlinefixFormatter } from "@main/helpers";
|
||||
import makeFetchCookie from "fetch-cookie";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { formatBytes } from "@shared";
|
||||
|
||||
const ONLINE_FIX_URL = "https://online-fix.me/";
|
||||
|
||||
export const getNewRepacksFromOnlineFix = async (
|
||||
existingRepacks: Repack[] = [],
|
||||
@@ -27,14 +30,14 @@ export const getNewRepacksFromOnlineFix = async (
|
||||
const http = makeFetchCookie(fetch, cookieJar);
|
||||
|
||||
if (page === 1) {
|
||||
await http("https://online-fix.me/");
|
||||
await http(ONLINE_FIX_URL);
|
||||
|
||||
const preLogin =
|
||||
((await http("https://online-fix.me/engine/ajax/authtoken.php", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
Referer: "https://online-fix.me/",
|
||||
Referer: ONLINE_FIX_URL,
|
||||
},
|
||||
}).then((res) => res.json())) as {
|
||||
field: string;
|
||||
@@ -50,11 +53,11 @@ export const getNewRepacksFromOnlineFix = async (
|
||||
[preLogin.field]: preLogin.value,
|
||||
});
|
||||
|
||||
await http("https://online-fix.me/", {
|
||||
await http(ONLINE_FIX_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Referer: "https://online-fix.me",
|
||||
Origin: "https://online-fix.me",
|
||||
Referer: ONLINE_FIX_URL,
|
||||
Origin: ONLINE_FIX_URL,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params.toString(),
|
||||
@@ -149,13 +152,8 @@ export const getNewRepacksFromOnlineFix = async (
|
||||
const torrentSizeInBytes = torrent.length;
|
||||
if (!torrentSizeInBytes) return;
|
||||
|
||||
const fileSizeFormatted =
|
||||
torrentSizeInBytes >= 1024 ** 3
|
||||
? `${(torrentSizeInBytes / 1024 ** 3).toFixed(1)}GBs`
|
||||
: `${(torrentSizeInBytes / 1024 ** 2).toFixed(1)}MBs`;
|
||||
|
||||
repacks.push({
|
||||
fileSize: fileSizeFormatted,
|
||||
fileSize: formatBytes(torrentSizeInBytes),
|
||||
magnet: magnetLink,
|
||||
page: 1,
|
||||
repacker: "onlinefix",
|
||||
|
||||
@@ -7,6 +7,8 @@ import { requestWebPage, savePage } from "./helpers";
|
||||
import createWorker from "@main/workers/torrent-parser.worker?nodeWorker";
|
||||
import { toMagnetURI } from "parse-torrent";
|
||||
import type { Instance } from "parse-torrent";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { formatBytes } from "@shared";
|
||||
|
||||
const worker = createWorker({});
|
||||
|
||||
@@ -23,10 +25,9 @@ const formatXatabDate = (str: string) => {
|
||||
return date;
|
||||
};
|
||||
|
||||
const formatXatabDownloadSize = (str: string) =>
|
||||
str.replace(",", ".").replace(/Гб/g, "GB").replace(/Мб/g, "MB");
|
||||
|
||||
const getXatabRepack = (url: string) => {
|
||||
const getXatabRepack = (
|
||||
url: string
|
||||
): Promise<{ fileSize: string; magnet: string; uploadDate: Date }> => {
|
||||
return new Promise((resolve) => {
|
||||
(async () => {
|
||||
const data = await requestWebPage(url);
|
||||
@@ -34,7 +35,6 @@ const getXatabRepack = (url: string) => {
|
||||
const { document } = window;
|
||||
|
||||
const $uploadDate = document.querySelector(".entry__date");
|
||||
const $size = document.querySelector(".entry__info-size");
|
||||
|
||||
const $downloadButton = document.querySelector(
|
||||
".download-torrent"
|
||||
@@ -42,17 +42,13 @@ const getXatabRepack = (url: string) => {
|
||||
|
||||
if (!$downloadButton) throw new Error("Download button not found");
|
||||
|
||||
const onMessage = (torrent: Instance) => {
|
||||
worker.once("message", (torrent: Instance) => {
|
||||
resolve({
|
||||
fileSize: formatXatabDownloadSize($size.textContent).toUpperCase(),
|
||||
fileSize: formatBytes(torrent.length ?? 0),
|
||||
magnet: toMagnetURI(torrent),
|
||||
uploadDate: formatXatabDate($uploadDate.textContent),
|
||||
uploadDate: formatXatabDate($uploadDate!.textContent!),
|
||||
});
|
||||
|
||||
worker.removeListener("message", onMessage);
|
||||
};
|
||||
|
||||
worker.once("message", onMessage);
|
||||
});
|
||||
})();
|
||||
});
|
||||
};
|
||||
@@ -65,7 +61,7 @@ export const getNewRepacksFromXatab = async (
|
||||
|
||||
const { window } = new JSDOM(data);
|
||||
|
||||
const repacks = [];
|
||||
const repacks: QueryDeepPartialEntity<Repack>[] = [];
|
||||
|
||||
for (const $a of Array.from(
|
||||
window.document.querySelectorAll(".entry__title a")
|
||||
@@ -74,7 +70,7 @@ export const getNewRepacksFromXatab = async (
|
||||
const repack = await getXatabRepack(($a as HTMLAnchorElement).href);
|
||||
|
||||
repacks.push({
|
||||
title: $a.textContent,
|
||||
title: $a.textContent!,
|
||||
repacker: "Xatab",
|
||||
...repack,
|
||||
page,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios from "axios";
|
||||
import { getSteamAppAsset } from "@main/helpers";
|
||||
|
||||
export interface SteamGridResponse {
|
||||
@@ -27,33 +28,35 @@ export const getSteamGridData = async (
|
||||
): Promise<SteamGridResponse> => {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
|
||||
const response = await fetch(
|
||||
if (!import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY) {
|
||||
throw new Error("STEAMGRIDDB_API_KEY is not set");
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getSteamGridGameById = async (
|
||||
id: number
|
||||
): Promise<SteamGridGameResponse> => {
|
||||
const response = await fetch(
|
||||
const response = await axios.get(
|
||||
`https://www.steamgriddb.com/api/public/game/${id}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Referer: "https://www.steamgriddb.com/",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getSteamGameIconUrl = async (objectID: string) => {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import * as Sentry from "@sentry/electron/main";
|
||||
import { Notification, app, dialog } 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,
|
||||
"hydra-download-manager",
|
||||
binaryName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
dialog.showErrorBox(
|
||||
"Fatal",
|
||||
"Hydra download manager binary not found. Please check if it has been removed by Windows Defender."
|
||||
);
|
||||
|
||||
app.quit();
|
||||
}
|
||||
|
||||
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 && game) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export class WindowManager {
|
||||
tray.setToolTip("Hydra");
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
if (process.platform === "win32" || process.platform === "linux") {
|
||||
tray.addListener("click", () => {
|
||||
if (this.mainWindow) {
|
||||
if (WindowManager.mainWindow?.isMinimized())
|
||||
|
||||
Reference in New Issue
Block a user