fix: fixing errors with electron dl manager

This commit is contained in:
Hydra 2024-05-05 19:18:48 +01:00
parent 11f1785432
commit 74a99f5bc8
51 changed files with 718 additions and 766 deletions

1
.gitignore vendored
View File

@ -2,7 +2,6 @@
node_modules
hydra-download-manager
fastlist.exe
unrar.wasm
__pycache__
dist
out

View File

@ -6,7 +6,6 @@ extraResources:
- hydra-download-manager
- hydra.db
- fastlist.exe
- unrar.wasm
files:
- "!**/.vscode/*"
- "!src/*"

View File

@ -31,7 +31,7 @@ export default defineConfig(({ mode }) => {
"@main": resolve("src/main"),
"@locales": resolve("src/locales"),
"@resources": resolve("resources"),
"@globals": resolve("src/globals"),
"@shared": resolve("src/shared"),
},
},
plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin],
@ -47,7 +47,7 @@ export default defineConfig(({ mode }) => {
alias: {
"@renderer": resolve("src/renderer/src"),
"@locales": resolve("src/locales"),
"@globals": resolve("src/globals"),
"@shared": resolve("src/shared"),
},
},
plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin],

View File

@ -43,7 +43,7 @@
"classnames": "^2.5.1",
"color.js": "^1.2.0",
"date-fns": "^3.6.0",
"electron-dl-manager": "^3.0.0",
"easydl": "^1.1.1",
"fetch-cookie": "^3.0.1",
"flexsearch": "^0.7.43",
"i18next": "^23.11.2",

View File

@ -6,5 +6,3 @@ if (process.platform === "win32") {
"fastlist.exe"
);
}
fs.copyFileSync("node_modules/node-unrar-js/esm/js/unrar.wasm", "unrar.wasm");

View File

@ -1,25 +0,0 @@
export enum GameStatus {
Seeding = "seeding",
Downloading = "downloading",
Paused = "paused",
CheckingFiles = "checking_files",
DownloadingMetadata = "downloading_metadata",
Cancelled = "cancelled",
Finished = "finished",
Decompressing = "decompressing",
}
export namespace GameStatus {
export const isDownloading = (status: GameStatus | null) =>
status === GameStatus.Downloading ||
status === GameStatus.DownloadingMetadata ||
status === GameStatus.CheckingFiles;
export const isVerifying = (status: GameStatus | null) =>
GameStatus.DownloadingMetadata == status ||
GameStatus.CheckingFiles == status ||
GameStatus.Decompressing == status;
export const isReady = (status: GameStatus | null) =>
status === GameStatus.Finished || status === GameStatus.Seeding;
}

View File

@ -7,9 +7,10 @@ import {
OneToOne,
JoinColumn,
} from "typeorm";
import type { GameShop } from "@types";
import { Repack } from "./repack.entity";
import { GameStatus } from "@globals";
import type { GameShop } from "@types";
import { Downloader, GameStatus } from "@shared";
@Entity("game")
export class Game {
@ -34,9 +35,6 @@ export class Game {
@Column("text", { nullable: true })
executablePath: string | null;
@Column("text", { nullable: true })
rarPath: string | null;
@Column("int", { default: 0 })
playTimeInMilliseconds: number;
@ -46,6 +44,9 @@ export class Game {
@Column("text", { nullable: true })
status: GameStatus | null;
@Column("int", { default: Downloader.Torrent })
downloader: Downloader;
/**
* Progress is a float between 0 and 1
*/
@ -55,9 +56,6 @@ export class Game {
@Column("float", { default: 0 })
fileVerificationProgress: number;
@Column("float", { default: 0 })
decompressionProgress: number;
@Column("int", { default: 0 })
bytesDownloaded: number;

View File

@ -16,6 +16,7 @@ const addGameToLibrary = async (
const game = await gameRepository.findOne({
where: {
objectID,
isDeleted: false,
},
});

View File

@ -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;

View File

@ -1,7 +1,7 @@
import path from "node:path";
import fs from "node:fs";
import { GameStatus } from "@globals";
import { GameStatus } from "@shared";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
@ -16,6 +16,7 @@ const deleteGameFolder = async (
where: {
id: gameId,
status: GameStatus.Cancelled,
isDeleted: false,
},
});

View File

@ -2,7 +2,7 @@ import { gameRepository } from "@main/repository";
import { searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
import { GameStatus } from "@globals";
import { GameStatus } from "@shared";
import { sortBy } from "lodash-es";
const getLibrary = async () =>

View File

@ -13,7 +13,9 @@ 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) return true;

View File

@ -1,6 +1,6 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { GameStatus } from "@globals";
import { GameStatus } from "@shared";
const removeGame = async (
_event: Electron.IpcMainInvokeEvent,

View File

@ -4,8 +4,8 @@ import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
import { In } from "typeorm";
import { Downloader } from "@main/services/downloaders/downloader";
import { GameStatus } from "@globals";
import { DownloadManager } from "@main/services";
import { GameStatus } from "@shared";
const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -14,6 +14,7 @@ const cancelGameDownload = async (
const game = await gameRepository.findOne({
where: {
id: gameId,
isDeleted: false,
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
@ -21,12 +22,12 @@ const cancelGameDownload = async (
GameStatus.Paused,
GameStatus.Seeding,
GameStatus.Finished,
GameStatus.Decompressing,
]),
},
});
if (!game) return;
DownloadManager.cancelDownload();
await gameRepository
.update(
@ -44,7 +45,6 @@ const cancelGameDownload = async (
game.status !== GameStatus.Paused &&
game.status !== GameStatus.Seeding
) {
Downloader.cancelDownload();
if (result.affected) WindowManager.mainWindow?.setProgressBar(-1);
}
});

View File

@ -1,15 +1,15 @@
import { WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { In } from "typeorm";
import { Downloader } from "@main/services/downloaders/downloader";
import { GameStatus } from "@globals";
import { DownloadManager, WindowManager } from "@main/services";
import { GameStatus } from "@shared";
const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
DownloadManager.pauseDownload();
await gameRepository
.update(
{
@ -23,10 +23,7 @@ const pauseGameDownload = async (
{ status: GameStatus.Paused }
)
.then((result) => {
if (result.affected) {
Downloader.pauseDownload();
WindowManager.mainWindow?.setProgressBar(-1);
}
if (result.affected) WindowManager.mainWindow?.setProgressBar(-1);
});
};

View File

@ -2,8 +2,8 @@ import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { In } from "typeorm";
import { Downloader } from "@main/services/downloaders/downloader";
import { GameStatus } from "@globals";
import { DownloadManager } from "@main/services";
import { GameStatus } from "@shared";
const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -12,18 +12,18 @@ const resumeGameDownload = async (
const game = await gameRepository.findOne({
where: {
id: gameId,
isDeleted: false,
},
relations: { repack: true },
});
if (!game) return;
Downloader.resumeDownload();
DownloadManager.pauseDownload();
if (game.status === GameStatus.Paused) {
const downloadsPath = game.downloadPath ?? (await getDownloadsPath());
Downloader.downloadGame(game, game.repack);
DownloadManager.resumeDownload(gameId);
await gameRepository.update(
{
@ -39,7 +39,7 @@ const resumeGameDownload = async (
await gameRepository.update(
{ id: game.id },
{
status: GameStatus.DownloadingMetadata,
status: GameStatus.Downloading,
downloadPath: downloadsPath,
}
);

View File

@ -1,13 +1,17 @@
import { getSteamGameIconUrl } from "@main/services";
import { gameRepository, repackRepository } from "@main/repository";
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 { Downloader } from "@main/services/downloaders/downloader";
import { GameStatus } from "@globals";
import { DownloadManager } from "@main/services";
import { Downloader, GameStatus } from "@shared";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -17,6 +21,14 @@ const startGameDownload = async (
gameShop: GameShop,
downloadPath: string
) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const downloader = userPreferences?.realDebridApiToken
? Downloader.Http
: Downloader.Torrent;
const [game, repack] = await Promise.all([
gameRepository.findOne({
where: {
@ -30,13 +42,8 @@ const startGameDownload = async (
}),
]);
if (!repack) return;
if (game?.status === GameStatus.Downloading) {
return;
}
Downloader.pauseDownload();
if (!repack || game?.status === GameStatus.Downloading) return;
DownloadManager.pauseDownload();
await gameRepository.update(
{
@ -57,12 +64,13 @@ const startGameDownload = async (
{
status: GameStatus.DownloadingMetadata,
downloadPath: downloadPath,
downloader,
repack: { id: repackId },
isDeleted: false,
}
);
Downloader.downloadGame(game, repack);
DownloadManager.downloadGame(game.id);
game.status = GameStatus.DownloadingMetadata;
@ -74,13 +82,14 @@ const startGameDownload = async (
title,
iconUrl,
objectID,
downloader,
shop: gameShop,
status: GameStatus.DownloadingMetadata,
downloadPath: downloadPath,
status: GameStatus.Downloading,
downloadPath,
repack: { id: repackId },
});
Downloader.downloadGame(createdGame, repack);
DownloadManager.downloadGame(createdGame.id);
const { repack: _, ...rest } = createdGame;

View File

@ -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,

View File

@ -6,9 +6,8 @@ import {
getNewRepacksFromUser,
getNewRepacksFromXatab,
getNewRepacksFromOnlineFix,
readPipe,
startProcessWatcher,
writePipe,
DownloadManager,
} from "./services";
import {
gameRepository,
@ -17,39 +16,16 @@ import {
steamGameRepository,
userPreferencesRepository,
} from "./repository";
import { TorrentClient } from "./services/downloaders/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 { Downloader } from "./services/downloaders/downloader";
import { GameStatus } from "@globals";
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) {
Downloader.downloadGame(game, game.repack);
}
readPipe.socket?.on("data", (data) => {
TorrentClient.onSocketData(data);
});
});
const track1337xUsers = async (existingRepacks: Repack[]) => {
for (const repacker of repackers) {
await getNewRepacksFromUser(
@ -59,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([
@ -101,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({
@ -121,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));
});

View 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 { HTTPDownloader } 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 {
HTTPDownloader.destroy();
}
}
static async pauseDownload() {
if (
this.gameDownloading &&
this.gameDownloading.downloader === Downloader.Torrent
) {
writePipe.write({ action: "pause" });
} else {
HTTPDownloader.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 {
HTTPDownloader.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 {
HTTPDownloader.startDownload(game!);
}
this.gameDownloading = game!;
}
}

View File

@ -1,105 +1,29 @@
import { Game, Repack } from "@main/entity";
import { writePipe } from "../fifo";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import { RealDebridClient } from "./real-debrid";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
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 { TorrentUpdate } from "./torrent-client";
import { HTTPDownloader } from "./http-downloader";
import { Unrar } from "../unrar";
import { GameStatus } from "@globals";
import path from "node:path";
import crypto from "node:crypto";
import fs from "node:fs";
import { app } from "electron";
import type { TorrentUpdate } from "./torrent.downloader";
import { GameStatus, GameStatusHelper } from "@shared";
import { gameRepository, userPreferencesRepository } from "@main/repository";
interface DownloadStatus {
numPeers: number;
numSeeds: number;
downloadSpeed: number;
timeRemaining: number;
numPeers?: number;
numSeeds?: number;
downloadSpeed?: number;
timeRemaining?: number;
}
export class Downloader {
private static lastHttpDownloader: HTTPDownloader | null = null;
static getGameProgress(game: Game) {
if (game.status === GameStatus.CheckingFiles)
return game.fileVerificationProgress;
static async usesRealDebrid() {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
return userPreferences!.realDebridApiToken !== null;
}
static async cancelDownload() {
if (!(await this.usesRealDebrid())) {
writePipe.write({ action: "cancel" });
} else {
if (this.lastHttpDownloader) {
this.lastHttpDownloader.cancel();
}
}
}
static async pauseDownload() {
if (!(await this.usesRealDebrid())) {
writePipe.write({ action: "pause" });
} else {
if (this.lastHttpDownloader) {
this.lastHttpDownloader.pause();
}
}
}
static async resumeDownload() {
if (!(await this.usesRealDebrid())) {
writePipe.write({ action: "pause" });
} else {
if (this.lastHttpDownloader) {
this.lastHttpDownloader.resume();
}
}
}
static async downloadGame(game: Game, repack: Repack) {
if (!(await this.usesRealDebrid())) {
writePipe.write({
action: "start",
game_id: game.id,
magnet: repack.magnet,
save_path: game.downloadPath,
});
} else {
try {
// Lets try first to find the torrent on RealDebrid
const torrents = await RealDebridClient.getAllTorrents();
const hash = RealDebridClient.extractSHA1FromMagnet(repack.magnet);
let torrent = torrents.find((t) => t.hash === hash);
if (!torrent) {
// Torrent is missing, lets add it
const magnet = await RealDebridClient.addMagnet(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]);
this.lastHttpDownloader = new HTTPDownloader();
this.lastHttpDownloader.download(
download,
game.downloadPath!,
game.id
);
}
} catch (e) {
console.error(e);
}
}
return game.progress;
}
static async updateGameProgress(
@ -110,23 +34,17 @@ export class Downloader {
await gameRepository.update({ id: gameId }, gameUpdate);
const game = await gameRepository.findOne({
where: { id: gameId },
where: { id: gameId, isDeleted: false },
relations: { repack: true },
});
if (
game?.progress === 1 &&
gameUpdate.status !== GameStatus.Decompressing
) {
if (game?.progress === 1) {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.downloadNotificationsEnabled) {
const iconPath = await this.createTempIcon(game.iconUrl);
new Notification({
icon: iconPath,
title: t("download_complete", {
ns: "notifications",
lng: userPreferences.language,
@ -140,26 +58,6 @@ export class Downloader {
}
}
if (
game &&
gameUpdate.decompressionProgress === 0 &&
gameUpdate.status === GameStatus.Decompressing
) {
const unrar = await Unrar.fromFilePath(
game.rarPath!,
path.join(game.downloadPath!, game.folderName!)
);
unrar.extract();
this.updateGameProgress(
gameId,
{
decompressionProgress: 1,
status: GameStatus.Finished,
},
downloadStatus
);
}
if (WindowManager.mainWindow && game) {
const progress = this.getGameProgress(game);
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
@ -184,31 +82,4 @@ export class Downloader {
);
}
}
static getGameProgress(game: Game) {
if (game.status === GameStatus.CheckingFiles)
return game.fileVerificationProgress;
if (game.status === GameStatus.Decompressing)
return game.decompressionProgress;
return game.progress;
}
private static createTempIcon(encodedIcon: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.randomBytes(16).toString("hex");
const iconPath = path.join(app.getPath("temp"), `${hash}.png`);
fs.writeFile(
iconPath,
Buffer.from(
encodedIcon.replace("data:image/jpeg;base64,", ""),
"base64"
),
(err) => {
if (err) reject(err);
resolve(iconPath);
}
);
});
}
}

View File

@ -1,106 +0,0 @@
import { Game } from "@main/entity";
import { ElectronDownloadManager } from "electron-dl-manager";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { WindowManager } from "../window-manager";
import { Downloader } from "./downloader";
import { GameStatus } from "@globals";
function dropExtension(fileName: string) {
return fileName.split(".").slice(0, -1).join(".");
}
export class HTTPDownloader {
private downloadManager: ElectronDownloadManager;
private downloadId: string | null = null;
constructor() {
this.downloadManager = new ElectronDownloadManager();
}
async download(url: string, destination: string, gameId: number) {
const window = WindowManager.mainWindow;
this.downloadId = await this.downloadManager.download({
url,
window: window!,
callbacks: {
onDownloadStarted: async (ev) => {
const updatePayload: QueryDeepPartialEntity<Game> = {
status: GameStatus.Downloading,
progress: 0,
bytesDownloaded: 0,
fileSize: ev.item.getTotalBytes(),
rarPath: `${destination}/.rd/${ev.resolvedFilename}`,
folderName: dropExtension(ev.resolvedFilename),
};
const downloadStatus = {
numPeers: 0,
numSeeds: 0,
downloadSpeed: 0,
timeRemaining: Number.POSITIVE_INFINITY,
};
await Downloader.updateGameProgress(
gameId,
updatePayload,
downloadStatus
);
},
onDownloadCompleted: async (ev) => {
const updatePayload: QueryDeepPartialEntity<Game> = {
progress: 1,
decompressionProgress: 0,
bytesDownloaded: ev.item.getReceivedBytes(),
status: GameStatus.Decompressing,
};
const downloadStatus = {
numPeers: 1,
numSeeds: 1,
downloadSpeed: 0,
timeRemaining: 0,
};
await Downloader.updateGameProgress(
gameId,
updatePayload,
downloadStatus
);
},
onDownloadProgress: async (ev) => {
const updatePayload: QueryDeepPartialEntity<Game> = {
progress: ev.percentCompleted / 100,
bytesDownloaded: ev.item.getReceivedBytes(),
};
const downloadStatus = {
numPeers: 1,
numSeeds: 1,
downloadSpeed: ev.downloadRateBytesPerSecond,
timeRemaining: ev.estimatedTimeRemainingSeconds,
};
await Downloader.updateGameProgress(
gameId,
updatePayload,
downloadStatus
);
},
},
directory: `${destination}/.rd/`,
});
}
pause() {
if (this.downloadId) {
this.downloadManager.pauseDownload(this.downloadId);
}
}
cancel() {
if (this.downloadId) {
this.downloadManager.cancelDownload(this.downloadId);
}
}
resume() {
if (this.downloadId) {
this.downloadManager.resumeDownload(this.downloadId);
}
}
}

View File

@ -0,0 +1,101 @@
import { Game } from "@main/entity";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import path from "node:path";
import EasyDL from "easydl";
import { GameStatus } from "@shared";
import { Downloader } from "./downloader";
import { RealDebridClient } from "../real-debrid";
export class HTTPDownloader 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;
}
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 startDownload(game: Game) {
if (this.download) this.download.destroy();
const download = await this.getDownloadUrl(game);
this.download = new EasyDL(
download,
path.join(game.downloadPath!, game.repack.title)
);
const metadata = await this.download.metadata();
this.downloadSize = metadata.size;
const updatePayload: QueryDeepPartialEntity<Game> = {
status: GameStatus.Downloading,
fileSize: metadata.size,
folderName: game.repack.title,
};
const downloadStatus = {
timeRemaining: Number.POSITIVE_INFINITY,
};
await this.updateGameProgress(game.id, updatePayload, downloadStatus);
this.download.on("progress", async ({ total }) => {
const updatePayload: QueryDeepPartialEntity<Game> = {
status:
total.percentage === 100
? GameStatus.Finished
: GameStatus.Downloading,
progress: 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);
});
}
static destroy() {
if (this.download) {
this.download.destroy();
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./http.downloader";
export * from "./torrent.downloader";

View File

@ -1,74 +0,0 @@
import { userPreferencesRepository } from "@main/repository";
import {
RealDebridAddMagnet,
RealDebridTorrentInfo,
RealDebridUnrestrictLink,
} from "./real-debrid-types";
const base = "https://api.real-debrid.com/rest/1.0";
export class RealDebridClient {
static async addMagnet(magnet: string) {
const response = await fetch(`${base}/torrents/addMagnet`, {
method: "POST",
headers: {
Authorization: `Bearer ${await this.getApiToken()}`,
},
body: `magnet=${encodeURIComponent(magnet)}`,
});
return response.json() as Promise<RealDebridAddMagnet>;
}
static async getInfo(id: string) {
const response = await fetch(`${base}/torrents/info/${id}`, {
headers: {
Authorization: `Bearer ${await this.getApiToken()}`,
},
});
return response.json() as Promise<RealDebridTorrentInfo>;
}
static async selectAllFiles(id: string) {
await fetch(`${base}/torrents/selectFiles/${id}`, {
method: "POST",
headers: {
Authorization: `Bearer ${await this.getApiToken()}`,
},
body: "files=all",
});
}
static async unrestrictLink(link: string) {
const response = await fetch(`${base}/unrestrict/link`, {
method: "POST",
headers: {
Authorization: `Bearer ${await this.getApiToken()}`,
},
body: `link=${link}`,
});
return response.json() as Promise<RealDebridUnrestrictLink>;
}
static async getAllTorrents() {
const response = await fetch(`${base}/torrents`, {
headers: {
Authorization: `Bearer ${await this.getApiToken()}`,
},
});
return response.json() as Promise<RealDebridTorrentInfo[]>;
}
static getApiToken() {
return userPreferencesRepository
.findOne({ where: { id: 1 } })
.then((userPreferences) => userPreferences!.realDebridApiToken);
}
static extractSHA1FromMagnet(magnet: string) {
return magnet.match(/btih:([0-9a-fA-F]*)/)?.[1].toLowerCase();
}
}

View File

@ -1,133 +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 { app, dialog } from "electron";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { Game } from "@main/entity";
import { Downloader } from "./downloader";
import { GameStatus } from "@globals";
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 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;
}
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;
}
Downloader.updateGameProgress(payload.gameId, updatePayload, {
numPeers: payload.numPeers,
numSeeds: payload.numSeeds,
downloadSpeed: payload.downloadSpeed,
timeRemaining: payload.timeRemaining,
});
} catch (err) {
Sentry.captureException(err);
}
}
}

View 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;
}
}

View File

@ -6,6 +6,7 @@ export * from "./steam-grid";
export * from "./update-resolver";
export * from "./window-manager";
export * from "./fifo";
export * from "./downloaders/torrent-client";
export * from "./downloaders";
export * from "./download-manager";
export * from "./how-long-to-beat";
export * from "./process-watcher";

View File

@ -16,6 +16,7 @@ export const startProcessWatcher = async () => {
const games = await gameRepository.find({
where: {
executablePath: Not(IsNull()),
isDeleted: false,
},
});

View File

@ -0,0 +1,73 @@
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 authorize(apiToken: string) {
this.instance = axios.create({
baseURL: base,
headers: {
Authorization: `Bearer ${apiToken}`,
},
});
}
}

View File

@ -28,7 +28,7 @@ export interface RealDebridTorrentInfo {
host: string; // Host main domain
split: number; // Split size of links
progress: number; // Possible values: 0 to 100
status: "downloaded"; // Current status of the torrent: magnet_error, magnet_conversion, waiting_files_selection, queued, downloading, downloaded, error, virus, compressing, uploading, dead
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: [
{
@ -44,9 +44,7 @@ export interface RealDebridTorrentInfo {
selected: number; // 0 or 1
},
];
links: [
"string", // Host URL
];
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

View File

@ -1,30 +0,0 @@
import { Extractor, createExtractorFromFile } from "node-unrar-js";
import fs from "node:fs";
import path from "node:path";
import { app } from "electron";
const wasmPath = app.isPackaged
? path.join(process.resourcesPath, "unrar.wasm")
: path.join(__dirname, "..", "..", "unrar.wasm");
const wasmBinary = fs.readFileSync(require.resolve(wasmPath));
export class Unrar {
private constructor(private extractor: Extractor<Uint8Array>) {}
static async fromFilePath(filePath: string, targetFolder: string) {
const extractor = await createExtractorFromFile({
filepath: filePath,
targetPath: targetFolder,
wasmBinary,
});
return new Unrar(extractor);
}
extract() {
const files = this.extractor.extract().files;
for (const file of files) {
console.log("File:", file.fileHeader.name);
}
}
}

View File

@ -20,6 +20,7 @@ import {
setRepackersFriendlyNames,
toggleDraggingDisabled,
} from "@renderer/features";
import { GameStatusHelper } from "@shared";
document.body.classList.add(themeClass);
@ -57,7 +58,7 @@ export function App({ children }: AppProps) {
useEffect(() => {
const unsubscribe = window.electron.onDownloadProgress(
(downloadProgress) => {
if (downloadProgress.game.progress === 1) {
if (GameStatusHelper.isReady(downloadProgress.game.status)) {
clearDownload();
updateLibrary();
return;

View File

@ -7,14 +7,17 @@ import { vars } from "../../theme.css";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { VERSION_CODENAME } from "@renderer/constants";
import { GameStatus } from "@globals";
import { GameStatus, GameStatusHelper } from "@shared";
export function BottomPanel() {
const { t } = useTranslation("bottom_panel");
const navigate = useNavigate();
const { game, progress, downloadSpeed, eta, isDownloading } = useDownload();
const { game, progress, downloadSpeed, eta } = useDownload();
const isGameDownloading =
game && GameStatusHelper.isDownloading(game.status ?? null);
const [version, setVersion] = useState("");
@ -23,7 +26,7 @@ export function BottomPanel() {
}, []);
const status = useMemo(() => {
if (isDownloading && game) {
if (isGameDownloading) {
if (game.status === GameStatus.DownloadingMetadata)
return t("downloading_metadata", { title: game.title });
@ -42,13 +45,13 @@ export function BottomPanel() {
}
return t("no_downloads_in_progress");
}, [t, game, progress, eta, isDownloading, downloadSpeed]);
}, [t, isGameDownloading, game, progress, eta, downloadSpeed]);
return (
<footer
className={styles.bottomPanel}
style={{
background: isDownloading
background: isGameDownloading
? `linear-gradient(90deg, ${vars.color.background} ${progress}, ${vars.color.darkBackground} ${progress})`
: vars.color.darkBackground,
}}

View File

@ -24,7 +24,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
/>
{props.checked && <CheckIcon />}
</div>
<label htmlFor={id} className={styles.checkboxLabel}>
<label htmlFor={id} className={styles.checkboxLabel} tabIndex={0}>
{label}
</label>
</div>

View File

@ -14,7 +14,7 @@ import DiscordLogo from "@renderer/assets/discord-icon.svg?react";
import XLogo from "@renderer/assets/x-icon.svg?react";
import * as styles from "./sidebar.css";
import { GameStatus } from "@globals";
import { GameStatus, GameStatusHelper } from "@shared";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@ -61,7 +61,7 @@ export function Sidebar() {
}, [gameDownloading?.id, updateLibrary]);
const isDownloading = library.some((game) =>
GameStatus.isDownloading(game.status)
GameStatusHelper.isDownloading(game.status)
);
const sidebarRef = useRef<HTMLElement>(null);
@ -124,7 +124,7 @@ export function Sidebar() {
return t("paused", { title: game.title });
if (gameDownloading?.id === game.id) {
const isVerifying = GameStatus.isVerifying(gameDownloading.status);
const isVerifying = GameStatusHelper.isVerifying(gameDownloading.status);
if (isVerifying)
return t(gameDownloading.status!, {

View File

@ -27,7 +27,7 @@ export function TextField({
return (
<div style={{ flex: 1 }}>
{label && (
<label htmlFor={id} className={styles.label}>
<label htmlFor={id} className={styles.label} tabIndex={0}>
{label}
</label>
)}

View File

@ -1,3 +1,4 @@
export * from "./use-download";
export * from "./use-library";
export * from "./use-date";
export * from "./redux";

View File

@ -12,7 +12,7 @@ import {
import type { GameShop, TorrentProgress } from "@types";
import { useDate } from "./use-date";
import { formatBytes } from "@renderer/utils";
import { GameStatus } from "@globals";
import { GameStatus, GameStatusHelper } from "@shared";
export function useDownload() {
const { updateLibrary } = useLibrary();
@ -64,7 +64,9 @@ export function useDownload() {
updateLibrary();
});
const isVerifying = GameStatus.isVerifying(lastPacket?.game.status);
const isVerifying = GameStatusHelper.isVerifying(
lastPacket?.game.status ?? null
);
const getETA = () => {
if (isVerifying || !isFinite(lastPacket?.timeRemaining ?? 0)) {
@ -85,8 +87,6 @@ export function useDownload() {
const getProgress = () => {
if (lastPacket?.game.status === GameStatus.CheckingFiles) {
return formatDownloadProgress(lastPacket?.game.fileVerificationProgress);
} else if (lastPacket?.game.status === GameStatus.Decompressing) {
return formatDownloadProgress(lastPacket?.game.decompressionProgress);
}
return formatDownloadProgress(lastPacket?.game.progress);
@ -116,7 +116,6 @@ export function useDownload() {
isVerifying,
gameId: lastPacket?.game.id,
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
isDownloading: Boolean(lastPacket),
progress: getProgress(),
numPeers: lastPacket?.numPeers,
numSeeds: lastPacket?.numSeeds,

View File

@ -11,7 +11,7 @@ import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./downloads.css";
import { DeleteModal } from "./delete-modal";
import { formatBytes } from "@renderer/utils";
import { GameStatus } from "@globals";
import { Downloader, GameStatus, GameStatusHelper } from "@shared";
export function Downloads() {
const { library, updateLibrary } = useLibrary();
@ -29,7 +29,6 @@ export function Downloads() {
const {
game: gameDownloading,
progress,
isDownloading,
numPeers,
numSeeds,
pauseDownload,
@ -55,7 +54,7 @@ export function Downloads() {
});
const getFinalDownloadSize = (game: Game) => {
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const isGameDownloading = gameDownloading?.id === game?.id;
if (!game) return "N/A";
if (game.fileSize) return formatBytes(game.fileSize);
@ -67,7 +66,7 @@ export function Downloads() {
};
const getGameInfo = (game: Game) => {
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const isGameDownloading = gameDownloading?.id === game?.id;
const finalDownloadSize = getFinalDownloadSize(game);
if (isGameDeleting(game?.id)) {
@ -79,7 +78,8 @@ export function Downloads() {
<>
<p>{progress}</p>
{gameDownloading?.status !== GameStatus.Downloading ? (
{gameDownloading?.status &&
gameDownloading?.status !== GameStatus.Downloading ? (
<p>{t(gameDownloading?.status)}</p>
) : (
<>
@ -87,16 +87,18 @@ export function Downloads() {
{formatBytes(gameDownloading?.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
<p>
{numPeers} peers / {numSeeds} seeds
</p>
{game.downloader === Downloader.Torrent && (
<p>
{numPeers} peers / {numSeeds} seeds
</p>
)}
</>
)}
</>
);
}
if (GameStatus.isReady(game?.status)) {
if (GameStatusHelper.isReady(game?.status)) {
return (
<>
<p>{game?.repack.title}</p>
@ -126,7 +128,7 @@ export function Downloads() {
};
const getGameActions = (game: Game) => {
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const isGameDownloading = gameDownloading?.id === game?.id;
const deleting = isGameDeleting(game.id);
@ -156,7 +158,7 @@ export function Downloads() {
);
}
if (GameStatus.isReady(game?.status)) {
if (GameStatusHelper.isReady(game?.status)) {
return (
<>
<Button

View File

@ -67,7 +67,7 @@ export function GameDetails() {
const dispatch = useAppDispatch();
const { game: gameDownloading, startDownload, isDownloading } = useDownload();
const { game: gameDownloading, startDownload } = useDownload();
const heroImage = steamUrlBuilder.libraryHero(objectID!);
@ -122,7 +122,7 @@ export function GameDetails() {
setHowLongToBeat({ isLoading: true, data: null });
}, [getGame, dispatch, navigate, objectID, i18n.language]);
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const isGameDownloading = gameDownloading?.id === game?.id;
useEffect(() => {
if (isGameDownloading)

View File

@ -1,4 +1,4 @@
import { GameStatus } from "@globals";
import { GameStatus, GameStatusHelper } from "@shared";
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
@ -174,10 +174,13 @@ export function HeroPanelActions({
);
}
if (GameStatus.isReady(game?.status) || (game && !game.status)) {
if (
GameStatusHelper.isReady(game?.status ?? null) ||
(game && !game.status)
) {
return (
<>
{GameStatus.isReady(game?.status) ? (
{GameStatusHelper.isReady(game?.status ?? null) ? (
<Button
onClick={openGameInstaller}
theme="outline"

View File

@ -0,0 +1,78 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Game } from "@types";
import { useDate } from "@renderer/hooks";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export interface HeroPanelPlaytimeProps {
game: Game;
isGamePlaying: boolean;
}
export function HeroPanelPlaytime({
game,
isGamePlaying,
}: HeroPanelPlaytimeProps) {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { i18n, t } = useTranslation("game_details");
const { formatDistance } = useDate();
useEffect(() => {
if (game?.lastTimePlayed) {
setLastTimePlayed(
formatDistance(game.lastTimePlayed, new Date(), {
addSuffix: true,
})
);
}
}, [game?.lastTimePlayed, formatDistance]);
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 1,
});
}, [i18n.language]);
const formatPlayTime = () => {
const milliseconds = game?.playTimeInMilliseconds || 0;
const seconds = milliseconds / 1000;
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
if (!game.lastTimePlayed) {
return <p>{t("not_played_yet", { title: game.title })}</p>;
}
return (
<>
<p>
{t("play_time", {
amount: formatPlayTime(),
})}
</p>
{isGamePlaying ? (
<p>{t("playing_now")}</p>
) : (
<p>
{t("last_time_played", {
period: lastTimePlayed,
})}
</p>
)}
</>
);
}

View File

@ -1,18 +1,18 @@
import { format } from "date-fns";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDownload } from "@renderer/hooks";
import type { Game, ShopDetails } from "@types";
import { formatDownloadProgress } from "@renderer/helpers";
import { useDate } from "@renderer/hooks/use-date";
import { formatBytes } from "@renderer/utils";
import { HeroPanelActions } from "./hero-panel-actions";
import { GameStatus } from "@globals";
import { Downloader, GameStatus, GameStatusHelper } from "@shared";
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
import * as styles from "./hero-panel.css";
import { HeroPanelPlaytime } from "./hero-panel-playtime";
export interface HeroPanelProps {
game: Game | null;
@ -23,8 +23,6 @@ export interface HeroPanelProps {
getGame: () => void;
}
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export function HeroPanel({
game,
gameDetails,
@ -33,54 +31,22 @@ export function HeroPanel({
getGame,
isGamePlaying,
}: HeroPanelProps) {
const { t, i18n } = useTranslation("game_details");
const { t } = useTranslation("game_details");
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { formatDistance } = useDate();
const {
game: gameDownloading,
isDownloading,
progress,
eta,
numPeers,
numSeeds,
isGameDeleting,
} = useDownload();
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
useEffect(() => {
if (game?.lastTimePlayed) {
setLastTimePlayed(
formatDistance(game.lastTimePlayed, new Date(), {
addSuffix: true,
})
);
}
}, [game?.lastTimePlayed, formatDistance]);
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 1,
});
}, [i18n]);
const formatPlayTime = () => {
const milliseconds = game?.playTimeInMilliseconds || 0;
const seconds = milliseconds / 1000;
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
const isGameDownloading =
gameDownloading?.id === game?.id &&
GameStatusHelper.isDownloading(game?.status ?? null);
const finalDownloadSize = useMemo(() => {
if (!game) return "N/A";
@ -117,7 +83,8 @@ export function HeroPanel({
{formatBytes(gameDownloading.bytesDownloaded)} /{" "}
{finalDownloadSize}
<small>
{numPeers} peers / {numSeeds} seeds
{game?.downloader === Downloader.Torrent &&
`${numPeers} peers / ${numSeeds} seeds`}
</small>
</p>
)}
@ -140,30 +107,8 @@ export function HeroPanel({
);
}
if (GameStatus.isReady(game?.status) || (game && !game.status)) {
if (!game.lastTimePlayed) {
return <p>{t("not_played_yet", { title: game.title })}</p>;
}
return (
<>
<p>
{t("play_time", {
amount: formatPlayTime(),
})}
</p>
{isGamePlaying ? (
<p>{t("playing_now")}</p>
) : (
<p>
{t("last_time_played", {
period: lastTimePlayed,
})}
</p>
)}
</>
);
if (game && GameStatusHelper.isReady(game?.status ?? null)) {
return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />;
}
const [latestRepack] = gameDetails.repacks;

View File

@ -129,6 +129,7 @@ export function Settings() {
<TextField
label={t("real_debrid_api_token_description")}
value={form.realDebridApiToken ?? ""}
type="password"
onChange={(event) => {
updateUserPreferences("realDebridApiToken", event.target.value);
}}

35
src/shared/index.ts Normal file
View File

@ -0,0 +1,35 @@
export enum GameStatus {
Seeding = "seeding",
Downloading = "downloading",
Paused = "paused",
CheckingFiles = "checking_files",
DownloadingMetadata = "downloading_metadata",
Cancelled = "cancelled",
Finished = "finished",
}
export enum Downloader {
Http,
Torrent,
}
export class GameStatusHelper {
public static isDownloading(status: GameStatus | null) {
return (
status === GameStatus.Downloading ||
status === GameStatus.DownloadingMetadata ||
status === GameStatus.CheckingFiles
);
}
public static isVerifying(status: GameStatus | null) {
return (
GameStatus.DownloadingMetadata == status ||
GameStatus.CheckingFiles == status
);
}
public static isReady(status: GameStatus | null) {
return status === GameStatus.Finished || status === GameStatus.Seeding;
}
}

View File

@ -1,4 +1,4 @@
import { GameStatus } from "@globals";
import type { Downloader, GameStatus } from "@shared";
export type GameShop = "steam" | "epic";
export type CatalogueCategory = "recently_added" | "trending";
@ -87,6 +87,7 @@ export interface Game extends Omit<CatalogueEntry, "cover"> {
decompressionProgress: number;
bytesDownloaded: number;
playTimeInMilliseconds: number;
downloader: Downloader;
executablePath: string | null;
lastTimePlayed: Date | null;
fileSize: number;

View File

@ -24,9 +24,12 @@ class Fifo:
return self.socket_handle.recv(bufSize)
def send_message(self, msg: str):
buffer = bytearray(1024 * 2)
buffer[:len(msg)] = bytes(msg, "utf-8")
if platform.system() == "Windows":
import win32file
win32file.WriteFile(self.socket_handle, bytes(msg, "utf-8"))
win32file.WriteFile(self.socket_handle, buffer)
else:
self.socket_handle.send(bytes(msg, "utf-8"))
self.socket_handle.send(buffer)

View File

@ -1,6 +1,6 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/globals.ts"],
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/shared/index.ts"],
"compilerOptions": {
"module": "ESNext",
"composite": true,
@ -15,7 +15,7 @@
"@types": ["src/types/index.ts"],
"@locales": ["src/locales/index.ts"],
"@resources": ["src/resources/index.ts"],
"@globals": ["src/globals.ts"]
"@shared": ["src/shared/index.ts"]
}
}
}

View File

@ -6,7 +6,7 @@
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts",
"src/locales/index.ts",
"src/globals.ts"
"src/shared/index.ts"
],
"compilerOptions": {
"composite": true,
@ -18,7 +18,7 @@
],
"@types": ["src/types/index.ts"],
"@locales": ["src/locales/index.ts"],
"@globals": ["src/globals.ts"]
"@shared": ["src/shared/index.ts"]
}
}
}

View File

@ -2327,6 +2327,11 @@ eastasianwidth@^0.2.0:
resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
easydl@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/easydl/-/easydl-1.1.1.tgz"
integrity sha512-DOInkODIEh7Z6areIv33eIo7ZXH7RulEvi7tZex4k1AmL0p1ODKH9/4rOCEGafUCZ/H/cbR701L27RT2RPe2/w==
ejs@^3.1.8:
version "3.1.10"
resolved "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz"
@ -2361,14 +2366,6 @@ electron-builder@^24.9.1:
simple-update-notifier "2.0.0"
yargs "^17.6.2"
electron-dl-manager@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/electron-dl-manager/-/electron-dl-manager-3.0.0.tgz#1b6ef6ee59f45733a5f13e8e916cb8189a21f8c8"
integrity sha512-DRyic9aY/6mSg7MvokrFWWY+NLYOnZcKGarujcBE4snobWND0hvV79s9b91kbo7+PLlANroK+jc/NDVliMSfbQ==
dependencies:
ext-name "^5.0.0"
unused-filename "^3.0.1"
electron-publish@24.13.1:
version "24.13.1"
resolved "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz"
@ -2801,21 +2798,6 @@ expand-template@^2.0.3:
resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz"
integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==
ext-list@^2.0.0:
version "2.2.2"
resolved "https://registry.yarnpkg.com/ext-list/-/ext-list-2.2.2.tgz#0b98e64ed82f5acf0f2931babf69212ef52ddd37"
integrity sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==
dependencies:
mime-db "^1.28.0"
ext-name@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/ext-name/-/ext-name-5.0.0.tgz#70781981d183ee15d13993c8822045c506c8f0a6"
integrity sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==
dependencies:
ext-list "^2.0.0"
sort-keys-length "^1.0.0"
extract-zip@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz"
@ -3630,11 +3612,6 @@ is-path-inside@^3.0.3:
resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz"
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
is-plain-obj@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==
is-potential-custom-element-name@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz"
@ -4074,7 +4051,7 @@ micromatch@^4.0.4:
braces "^3.0.2"
picomatch "^2.3.1"
mime-db@1.52.0, mime-db@^1.28.0:
mime-db@1.52.0:
version "1.52.0"
resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
@ -4299,7 +4276,7 @@ node-releases@^2.0.14:
node-unrar-js@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/node-unrar-js/-/node-unrar-js-2.0.2.tgz#03ef602052497263b9aed8ff1e7afb315024f9ec"
resolved "https://registry.npmjs.org/node-unrar-js/-/node-unrar-js-2.0.2.tgz"
integrity sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w==
normalize-path@^3.0.0, normalize-path@~3.0.0:
@ -5174,20 +5151,6 @@ snake-case@^3.0.4:
dot-case "^3.0.4"
tslib "^2.0.3"
sort-keys-length@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188"
integrity sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==
dependencies:
sort-keys "^1.0.0"
sort-keys@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
integrity sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==
dependencies:
is-plain-obj "^1.0.0"
source-map-js@^1.2.0:
version "1.2.0"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz"
@ -5662,14 +5625,6 @@ unplugin@1.0.1:
webpack-sources "^3.2.3"
webpack-virtual-modules "^0.5.0"
unused-filename@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/unused-filename/-/unused-filename-3.0.1.tgz#41b0600f8909e39cbdbbcf2467591bd3dd83fa7b"
integrity sha512-UbMRaEaT+/3mGh40GBRnF2++1VqFG1w0Kjzd5q/uQjagKn5pkCS8goJTgYDpQ6e0tB2GywamMJy1BzbSrMcIWw==
dependencies:
escape-string-regexp "^4.0.0"
path-exists "^4.0.0"
update-browserslist-db@^1.0.13:
version "1.0.14"
resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.14.tgz"