feat: adding aria2

This commit is contained in:
Chubby Granny Chaser 2024-05-20 02:21:11 +01:00
parent a89e6760da
commit 4941709296
No known key found for this signature in database
58 changed files with 895 additions and 1329 deletions

View File

@ -22,17 +22,6 @@ jobs:
- name: Install dependencies
run: yarn
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: pip install -r requirements.txt
- name: Build with cx_Freeze
run: python torrent-client/setup.py build
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: yarn build:linux

View File

@ -24,17 +24,6 @@ jobs:
- name: Install dependencies
run: yarn
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: pip install -r requirements.txt
- name: Build with cx_Freeze
run: python torrent-client/setup.py build
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: yarn build:linux

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
.vscode
node_modules
hydra-download-manager
aria2*
fastlist.exe
__pycache__
dist

View File

@ -3,7 +3,6 @@ productName: Hydra
directories:
buildResources: build
extraResources:
- hydra-download-manager
- hydra.db
- fastlist.exe
- seeds

BIN
hydra.db

Binary file not shown.

View File

@ -41,6 +41,7 @@
"@reduxjs/toolkit": "^2.2.3",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/recipes": "^0.5.2",
"aria2": "^4.1.2",
"auto-launch": "^5.0.6",
"axios": "^1.6.8",
"better-sqlite3": "^9.5.0",

80
src/main/declaration.d.ts vendored Normal file
View File

@ -0,0 +1,80 @@
declare module "aria2" {
export type Aria2Status =
| "active"
| "waiting"
| "paused"
| "error"
| "complete"
| "removed";
export interface StatusResponse {
gid: string;
status: Aria2Status;
totalLength: string;
completedLength: string;
uploadLength: string;
bitfield: string;
downloadSpeed: string;
uploadSpeed: string;
infoHash?: string;
numSeeders?: string;
seeder?: boolean;
pieceLength: string;
numPieces: string;
connections: string;
errorCode?: string;
errorMessage?: string;
followedBy?: string[];
following: string;
belongsTo: string;
dir: string;
files: {
path: string;
length: string;
completedLength: string;
selected: string;
}[];
bittorrent?: {
announceList: string[][];
comment: string;
creationDate: string;
mode: "single" | "multi";
info: {
name: string;
verifiedLength: string;
verifyIntegrityPending: string;
};
};
}
export default class Aria2 {
constructor(options: any);
open: () => Promise<void>;
call(
method: "addUri",
uris: string[],
options: { dir: string }
): Promise<string>;
call(
method: "tellStatus",
gid: string,
keys?: string[]
): Promise<StatusResponse>;
call(method: "pause", gid: string): Promise<string>;
call(method: "forcePause", gid: string): Promise<string>;
call(method: "unpause", gid: string): Promise<string>;
call(method: "remove", gid: string): Promise<string>;
call(method: "forceRemove", gid: string): Promise<string>;
call(method: "pauseAll"): Promise<string>;
call(method: "forcePauseAll"): Promise<string>;
listNotifications: () => [
"onDownloadStart",
"onDownloadPause",
"onDownloadStop",
"onDownloadComplete",
"onDownloadError",
"onBtDownloadComplete",
];
on: (event: string, callback: (params: any) => void) => void;
}
}

View File

@ -10,7 +10,8 @@ import {
import { Repack } from "./repack.entity";
import type { GameShop } from "@types";
import { Downloader, GameStatus } from "@shared";
import { Downloader } from "@shared";
import type { Aria2Status } from "aria2";
@Entity("game")
export class Game {
@ -42,7 +43,7 @@ export class Game {
shop: GameShop;
@Column("text", { nullable: true })
status: GameStatus | null;
status: Aria2Status | null;
@Column("int", { default: Downloader.Torrent })
downloader: Downloader;
@ -53,9 +54,6 @@ export class Game {
@Column("float", { default: 0 })
progress: number;
@Column("float", { default: 0 })
fileVerificationProgress: number;
@Column("int", { default: 0 })
bytesDownloaded: number;

View File

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

View File

@ -2,7 +2,6 @@ import { gameRepository } from "@main/repository";
import { searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
import { GameStatus } from "@shared";
import { sortBy } from "lodash-es";
const getLibrary = async () =>
@ -24,7 +23,7 @@ const getLibrary = async () =>
...game,
repacks: searchRepacks(game.title),
})),
(game) => (game.status !== GameStatus.Cancelled ? 0 : 1)
(game) => (game.status !== "removed" ? 0 : 1)
)
);

View File

@ -1,6 +1,5 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { GameStatus } from "@shared";
const removeGame = async (
_event: Electron.IpcMainInvokeEvent,
@ -9,7 +8,7 @@ const removeGame = async (
await gameRepository.update(
{
id: gameId,
status: GameStatus.Cancelled,
status: "removed",
},
{
status: null,

View File

@ -1,53 +1,25 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
import { In } from "typeorm";
import { DownloadManager } from "@main/services";
import { GameStatus } from "@shared";
const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
await DownloadManager.cancelDownload(gameId);
await gameRepository.update(
{
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(
{
id: game.id,
},
{
status: GameStatus.Cancelled,
bytesDownloaded: 0,
progress: 0,
}
)
.then((result) => {
if (
game.status !== GameStatus.Paused &&
game.status !== GameStatus.Seeding
) {
if (result.affected) WindowManager.mainWindow?.setProgressBar(-1);
}
});
{
status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
};
registerEvent("cancelGameDownload", cancelGameDownload);

View File

@ -1,30 +1,13 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { In } from "typeorm";
import { DownloadManager, WindowManager } from "@main/services";
import { GameStatus } from "@shared";
import { DownloadManager } from "@main/services";
const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
DownloadManager.pauseDownload();
await gameRepository
.update(
{
id: gameId,
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
)
.then((result) => {
if (result.affected) WindowManager.mainWindow?.setProgressBar(-1);
});
await DownloadManager.pauseDownload();
await gameRepository.update({ id: gameId }, { status: "paused" });
};
registerEvent("pauseGameDownload", pauseGameDownload);

View File

@ -1,9 +1,7 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { In } from "typeorm";
import { DownloadManager } from "@main/services";
import { GameStatus } from "@shared";
const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -18,31 +16,13 @@ const resumeGameDownload = async (
});
if (!game) return;
DownloadManager.pauseDownload();
if (game.status === GameStatus.Paused) {
const downloadsPath = game.downloadPath ?? (await getDownloadsPath());
if (game.status === "paused") {
await DownloadManager.pauseDownload();
DownloadManager.resumeDownload(gameId);
await gameRepository.update({ status: "active" }, { status: "paused" });
await gameRepository.update(
{
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
);
await gameRepository.update(
{ id: game.id },
{
status: GameStatus.Downloading,
downloadPath: downloadsPath,
}
);
await DownloadManager.resumeDownload(gameId);
}
};

View File

@ -8,9 +8,8 @@ import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { In } from "typeorm";
import { DownloadManager } from "@main/services";
import { Downloader, GameStatus } from "@shared";
import { Downloader } from "@shared";
import { stateManager } from "@main/state-manager";
const startGameDownload = async (
@ -42,19 +41,9 @@ const startGameDownload = async (
}),
]);
if (!repack || game?.status === GameStatus.Downloading) return;
DownloadManager.pauseDownload();
if (!repack || game?.status === "active") return;
await gameRepository.update(
{
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
);
await gameRepository.update({ status: "active" }, { status: "paused" });
if (game) {
await gameRepository.update(
@ -62,17 +51,17 @@ const startGameDownload = async (
id: game.id,
},
{
status: GameStatus.DownloadingMetadata,
downloadPath: downloadPath,
status: "active",
downloadPath,
downloader,
repack: { id: repackId },
isDeleted: false,
}
);
DownloadManager.downloadGame(game.id);
await DownloadManager.startDownload(game.id);
game.status = GameStatus.DownloadingMetadata;
game.status = "active";
return game;
} else {
@ -91,7 +80,7 @@ const startGameDownload = async (
objectID,
downloader,
shop: gameShop,
status: GameStatus.Downloading,
status: "active",
downloadPath,
repack: { id: repackId },
})
@ -105,7 +94,7 @@ const startGameDownload = async (
return result;
});
DownloadManager.downloadGame(createdGame.id);
DownloadManager.startDownload(createdGame.id);
const { repack: _, ...rest } = createdGame;

View File

@ -13,17 +13,15 @@ import {
repackRepository,
userPreferencesRepository,
} from "./repository";
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 fs from "node:fs";
import path from "node:path";
import { RealDebridClient } from "./services/real-debrid";
import { orderBy } from "lodash-es";
import { SteamGame } from "@types";
import { Not } from "typeorm";
startProcessWatcher();
@ -72,7 +70,7 @@ const checkForNewRepacks = async (userPreferences: UserPreferences | null) => {
};
const loadState = async (userPreferences: UserPreferences | null) => {
const repacks = await repackRepository.find({
const repacks = repackRepository.find({
order: {
createdAt: "desc",
},
@ -82,7 +80,7 @@ const loadState = async (userPreferences: UserPreferences | null) => {
fs.readFileSync(path.join(seedsPath, "steam-games.json"), "utf-8")
) as SteamGame[];
stateManager.setValue("repacks", repacks);
stateManager.setValue("repacks", await repacks);
stateManager.setValue("steamGames", orderBy(steamGames, ["name"], "asc"));
import("./events");
@ -90,22 +88,19 @@ const loadState = async (userPreferences: UserPreferences | null) => {
if (userPreferences?.realDebridApiToken)
await RealDebridClient.authorize(userPreferences?.realDebridApiToken);
await DownloadManager.connect();
const game = await gameRepository.findOne({
where: {
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
status: "active",
progress: Not(1),
isDeleted: false,
},
relations: { repack: true },
});
await TorrentDownloader.startClient();
if (game) {
DownloadManager.resumeDownload(game.id);
DownloadManager.startDownload(game.id);
}
};

View File

@ -1,13 +1,156 @@
import { gameRepository } from "@main/repository";
import Aria2, { StatusResponse } from "aria2";
import { spawn } from "node:child_process";
import type { Game } from "@main/entity";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import path from "node:path";
import { WindowManager } from "./window-manager";
import { RealDebridClient } from "./real-debrid";
import { Notification } from "electron";
import { t } from "i18next";
import { Downloader } from "@shared";
import { writePipe } from "./fifo";
import { RealDebridDownloader } from "./downloaders";
import { DownloadProgress } from "@types";
export class DownloadManager {
private static gameDownloading: Game;
private static downloads = new Map<number, string>();
private static gid: string | null = null;
private static gameId: number | null = null;
private static aria2 = new Aria2({});
static async connect() {
const binary = path.join(
__dirname,
"..",
"..",
"aria2-1.37.0-win-64bit-build1",
"aria2c"
);
spawn(binary, ["--enable-rpc", "--rpc-listen-all"], { stdio: "inherit" });
await this.aria2.open();
this.attachListener();
}
private static getETA(status: StatusResponse) {
const remainingBytes =
Number(status.totalLength) - Number(status.completedLength);
const speed = Number(status.downloadSpeed);
if (remainingBytes >= 0 && speed > 0) {
return (remainingBytes / speed) * 1000;
}
return -1;
}
static async publishNotification() {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.downloadNotificationsEnabled && this.gameId) {
const game = await this.getGame(this.gameId);
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();
}
}
private static getFolderName(status: StatusResponse) {
if (status.bittorrent?.info) return status.bittorrent.info.name;
return "";
}
private static async attachListener() {
while (true) {
try {
if (!this.gid || !this.gameId) {
continue;
}
const status = await this.aria2.call("tellStatus", this.gid);
const downloadingMetadata =
status.bittorrent && !status.bittorrent?.info;
if (status.followedBy?.length) {
this.gid = status.followedBy[0];
this.downloads.set(this.gameId, this.gid);
continue;
}
const progress =
Number(status.completedLength) / Number(status.totalLength);
await gameRepository.update(
{ id: this.gameId },
{
progress:
isNaN(progress) || downloadingMetadata ? undefined : progress,
bytesDownloaded: Number(status.completedLength),
fileSize: Number(status.totalLength),
status: status.status,
folderName: this.getFolderName(status),
}
);
const game = await gameRepository.findOne({
where: { id: this.gameId, isDeleted: false },
relations: { repack: true },
});
if (progress === 1 && game && !downloadingMetadata) {
await this.publishNotification();
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(game.id);
} else {
this.clearCurrentDownload();
}
}
if (WindowManager.mainWindow && game) {
WindowManager.mainWindow.setProgressBar(
progress === 1 || downloadingMetadata ? -1 : progress,
{ mode: downloadingMetadata ? "indeterminate" : "normal" }
);
const payload = {
progress,
bytesDownloaded: Number(status.completedLength),
fileSize: Number(status.totalLength),
numPeers: Number(status.connections),
numSeeds: Number(status.numSeeders ?? 0),
downloadSpeed: Number(status.downloadSpeed),
timeRemaining: this.getETA(status),
downloadingMetadata: !!downloadingMetadata,
game,
} as DownloadProgress;
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify(payload))
);
}
} finally {
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
static async getGame(gameId: number) {
return gameRepository.findOne({
@ -18,59 +161,80 @@ export class DownloadManager {
});
}
static async cancelDownload() {
if (
this.gameDownloading &&
this.gameDownloading.downloader === Downloader.Torrent
) {
writePipe.write({ action: "cancel" });
} else {
RealDebridDownloader.destroy();
private static clearCurrentDownload() {
if (this.gameId) {
this.downloads.delete(this.gameId);
this.gid = null;
this.gameId = null;
}
}
static async cancelDownload(gameId: number) {
const gid = this.downloads.get(gameId);
if (gid) {
await this.aria2.call("remove", gid);
if (this.gid === gid) {
this.clearCurrentDownload();
WindowManager.mainWindow?.setProgressBar(-1);
} else {
this.downloads.delete(gameId);
}
}
}
static async pauseDownload() {
if (
this.gameDownloading &&
this.gameDownloading.downloader === Downloader.Torrent
) {
writePipe.write({ action: "pause" });
} else {
RealDebridDownloader.destroy();
if (this.gid) {
await this.aria2.call("forcePause", this.gid);
this.gid = null;
this.gameId = null;
WindowManager.mainWindow?.setProgressBar(-1);
}
}
static async resumeDownload(gameId: number) {
const game = await this.getGame(gameId);
await this.aria2.call("forcePauseAll");
if (game!.downloader === Downloader.Torrent) {
writePipe.write({
action: "start",
game_id: game!.id,
magnet: game!.repack.magnet,
save_path: game!.downloadPath,
});
if (this.downloads.has(gameId)) {
const gid = this.downloads.get(gameId)!;
await this.aria2.call("unpause", gid);
this.gid = gid;
this.gameId = gameId;
} else {
RealDebridDownloader.startDownload(game!);
return this.startDownload(gameId);
}
this.gameDownloading = game!;
}
static async downloadGame(gameId: number) {
const game = await this.getGame(gameId);
static async startDownload(gameId: number) {
await this.aria2.call("forcePauseAll");
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!);
const game = await this.getGame(gameId)!;
if (game) {
const options = {
dir: game.downloadPath!,
};
if (game.downloader === Downloader.RealDebrid) {
const downloadUrl = decodeURIComponent(
await RealDebridClient.getDownloadUrl(game)
);
this.gid = await this.aria2.call("addUri", [downloadUrl], options);
} else {
this.gid = await this.aria2.call(
"addUri",
[game.repack.magnet],
options
);
}
this.gameId = gameId;
this.downloads.set(gameId, this.gid);
}
this.gameDownloading = game!;
}
}

View File

@ -1,85 +0,0 @@
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,
})
)
);
}
}
}

View File

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

View File

@ -1,115 +0,0 @@
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
// );
});
}
}

View File

@ -1,156 +0,0 @@
import path from "node:path";
import cp from "node:child_process";
import fs from "node:fs";
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,
});
} 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,
});
} else {
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

@ -1,38 +0,0 @@
import path from "node:path";
import net from "node:net";
import crypto from "node:crypto";
import os from "node:os";
export class FIFO {
public socket: null | net.Socket = null;
public socketPath = this.generateSocketFilename();
private generateSocketFilename() {
const hash = crypto.randomBytes(16).toString("hex");
if (process.platform === "win32") {
return "\\\\.\\pipe\\" + hash;
}
return path.join(os.tmpdir(), hash);
}
public write(data: any) {
if (!this.socket) return;
this.socket.write(Buffer.from(JSON.stringify(data)));
}
public createPipe() {
return new Promise((resolve) => {
const server = net.createServer((socket) => {
this.socket = socket;
resolve(null);
});
server.listen(this.socketPath);
});
}
}
export const writePipe = new FIFO();
export const readPipe = new FIFO();

View File

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

View File

@ -5,7 +5,7 @@ import { contextBridge, ipcRenderer } from "electron";
import type {
CatalogueCategory,
GameShop,
TorrentProgress,
DownloadProgress,
UserPreferences,
} from "@types";
@ -32,10 +32,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("pauseGameDownload", gameId),
resumeGameDownload: (gameId: number) =>
ipcRenderer.invoke("resumeGameDownload", gameId),
onDownloadProgress: (cb: (value: TorrentProgress) => void) => {
onDownloadProgress: (cb: (value: DownloadProgress) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
value: TorrentProgress
value: DownloadProgress
) => cb(value);
ipcRenderer.on("on-download-progress", listener);
return () => ipcRenderer.removeListener("on-download-progress", listener);

View File

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

View File

@ -43,5 +43,11 @@ export const backdrop = recipe({
backgroundColor: "rgba(0, 0, 0, 0)",
},
},
windows: {
true: {
// SPACING_UNIT * 3 + title bar spacing
paddingTop: `${SPACING_UNIT * 3 + 35}px`,
},
},
},
});

View File

@ -7,6 +7,13 @@ export interface BackdropProps {
export function Backdrop({ isClosing = false, children }: BackdropProps) {
return (
<div className={styles.backdrop({ closing: isClosing })}>{children}</div>
<div
className={styles.backdrop({
closing: isClosing,
windows: window.electron.platform === "win32",
})}
>
{children}
</div>
);
}

View File

@ -7,17 +7,16 @@ 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, GameStatusHelper } from "@shared";
export function BottomPanel() {
const { t } = useTranslation("bottom_panel");
const navigate = useNavigate();
const { game, progress, downloadSpeed, eta } = useDownload();
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const isGameDownloading =
game && GameStatusHelper.isDownloading(game.status ?? null);
lastPacket?.game && lastPacket?.game.status === "active";
const [version, setVersion] = useState("");
@ -27,17 +26,8 @@ export function BottomPanel() {
const status = useMemo(() => {
if (isGameDownloading) {
if (game.status === GameStatus.DownloadingMetadata)
return t("downloading_metadata", { title: game.title });
if (game.status === GameStatus.CheckingFiles)
return t("checking_files", {
title: game.title,
percentage: progress,
});
return t("downloading", {
title: game?.title,
title: lastPacket?.game.title,
percentage: progress,
eta,
speed: downloadSpeed,
@ -45,7 +35,7 @@ export function BottomPanel() {
}
return t("no_downloads_in_progress");
}, [t, isGameDownloading, game, progress, eta, downloadSpeed]);
}, [t, isGameDownloading, lastPacket?.game, progress, eta, downloadSpeed]);
return (
<footer

View File

@ -10,7 +10,6 @@ import { useDownload, useLibrary } from "@renderer/hooks";
import { routes } from "./routes";
import * as styles from "./sidebar.css";
import { GameStatus, GameStatusHelper } from "@shared";
import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@ -35,14 +34,14 @@ export function Sidebar() {
const location = useLocation();
const { game: gameDownloading, progress } = useDownload();
const { lastPacket, progress } = useDownload();
useEffect(() => {
updateLibrary();
}, [gameDownloading?.id, updateLibrary]);
}, [lastPacket?.game.id, updateLibrary]);
const isDownloading = library.some((game) =>
GameStatusHelper.isDownloading(game.status)
const isDownloading = library.some(
(game) => game.status === "active" && game.progress !== 1
);
const sidebarRef = useRef<HTMLElement>(null);
@ -101,18 +100,9 @@ export function Sidebar() {
}, [isResizing]);
const getGameTitle = (game: Game) => {
if (game.status === GameStatus.Paused)
return t("paused", { title: game.title });
if (gameDownloading?.id === game.id) {
const isVerifying = GameStatusHelper.isVerifying(gameDownloading.status);
if (isVerifying)
return t(gameDownloading.status!, {
title: game.title,
percentage: progress,
});
if (game.status === "paused") return t("paused", { title: game.title });
if (lastPacket?.game.id === game.id) {
return t("downloading", {
title: game.title,
percentage: progress,
@ -183,7 +173,7 @@ export function Sidebar() {
className={styles.menuItem({
active:
location.pathname === `/game/${game.shop}/${game.objectID}`,
muted: game.status === GameStatus.Cancelled,
muted: game.status === "removed",
})}
>
<button

View File

@ -7,7 +7,7 @@ import type {
HowLongToBeatCategory,
ShopDetails,
Steam250Game,
TorrentProgress,
DownloadProgress,
UserPreferences,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -31,7 +31,7 @@ declare global {
pauseGameDownload: (gameId: number) => Promise<void>;
resumeGameDownload: (gameId: number) => Promise<void>;
onDownloadProgress: (
cb: (value: TorrentProgress) => void
cb: (value: DownloadProgress) => void
) => () => Electron.IpcRenderer;
/* Catalogue */

View File

@ -1,9 +1,9 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { TorrentProgress } from "@types";
import type { DownloadProgress } from "@types";
export interface DownloadState {
lastPacket: TorrentProgress | null;
lastPacket: DownloadProgress | null;
gameId: number | null;
gamesWithDeletionInProgress: number[];
}
@ -18,7 +18,7 @@ export const downloadSlice = createSlice({
name: "download",
initialState,
reducers: {
setLastPacket: (state, action: PayloadAction<TorrentProgress>) => {
setLastPacket: (state, action: PayloadAction<DownloadProgress>) => {
state.lastPacket = action.payload;
if (!state.gameId) state.gameId = action.payload.game.id;
},

View File

@ -9,9 +9,9 @@ import {
setGameDeleting,
removeGameFromDeleting,
} from "@renderer/features";
import type { GameShop, TorrentProgress } from "@types";
import type { DownloadProgress, GameShop } from "@types";
import { useDate } from "./use-date";
import { GameStatus, GameStatusHelper, formatBytes } from "@shared";
import { formatBytes } from "@shared";
export function useDownload() {
const { updateLibrary } = useLibrary();
@ -38,16 +38,16 @@ export function useDownload() {
return game;
});
const pauseDownload = (gameId: number) =>
window.electron.pauseGameDownload(gameId).then(() => {
dispatch(clearDownload());
updateLibrary();
});
const pauseDownload = async (gameId: number) => {
await window.electron.pauseGameDownload(gameId);
await updateLibrary();
dispatch(clearDownload());
};
const resumeDownload = (gameId: number) =>
window.electron.resumeGameDownload(gameId).then(() => {
updateLibrary();
});
const resumeDownload = async (gameId: number) => {
await window.electron.resumeGameDownload(gameId);
return updateLibrary();
};
const cancelDownload = (gameId: number) =>
window.electron.cancelGameDownload(gameId).then(() => {
@ -61,14 +61,8 @@ export function useDownload() {
updateLibrary();
});
const isVerifying = GameStatusHelper.isVerifying(
lastPacket?.game.status ?? null
);
const getETA = () => {
if (isVerifying || !isFinite(lastPacket?.timeRemaining ?? 0)) {
return "";
}
if (lastPacket && lastPacket.timeRemaining < 0) return "";
try {
return formatDistance(
@ -81,14 +75,6 @@ export function useDownload() {
}
};
const getProgress = () => {
if (lastPacket?.game.status === GameStatus.CheckingFiles) {
return formatDownloadProgress(lastPacket?.game.fileVerificationProgress);
}
return formatDownloadProgress(lastPacket?.game.progress);
};
const deleteGame = (gameId: number) =>
window.electron
.cancelGameDownload(gameId)
@ -107,15 +93,9 @@ export function useDownload() {
};
return {
game: lastPacket?.game,
bytesDownloaded: lastPacket?.game.bytesDownloaded,
fileSize: lastPacket?.game.fileSize,
isVerifying,
gameId: lastPacket?.game.id,
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
progress: getProgress(),
numPeers: lastPacket?.numPeers,
numSeeds: lastPacket?.numSeeds,
progress: formatDownloadProgress(lastPacket?.game.progress ?? 0),
lastPacket,
eta: getETA(),
startDownload,
pauseDownload,
@ -125,6 +105,7 @@ export function useDownload() {
deleteGame,
isGameDeleting,
clearDownload: () => dispatch(clearDownload()),
setLastPacket: (packet: TorrentProgress) => dispatch(setLastPacket(packet)),
setLastPacket: (packet: DownloadProgress) =>
dispatch(setLastPacket(packet)),
};
}

View File

@ -26,10 +26,7 @@ export function Downloads() {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const {
game: gameDownloading,
progress,
numPeers,
numSeeds,
pauseDownload,
resumeDownload,
removeGameFromLibrary,

View File

@ -1,25 +1,25 @@
import { useTranslation } from "react-i18next";
import type { ShopDetails } from "@types";
import * as styles from "./game-details.css";
import { useContext } from "react";
import { gameDetailsContext } from "./game-details.context";
export interface DescriptionHeaderProps {
gameDetails: ShopDetails;
}
export function DescriptionHeader() {
const { shopDetails } = useContext(gameDetailsContext);
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
const { t } = useTranslation("game_details");
if (!shopDetails) return null;
return (
<div className={styles.descriptionHeader}>
<section className={styles.descriptionHeaderInfo}>
<p>
{t("release_date", {
date: gameDetails?.release_date.date,
date: shopDetails?.release_date.date,
})}
</p>
<p>{t("publisher", { publisher: gameDetails.publishers[0] })}</p>
<p>{t("publisher", { publisher: shopDetails.publishers[0] })}</p>
</section>
</div>
);

View File

@ -1,37 +1,36 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
import type { ShopDetails } from "@types";
import * as styles from "./gallery-slider.css";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "./game-details.context";
export interface GallerySliderProps {
gameDetails: ShopDetails;
}
export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext);
export function GallerySlider({ gameDetails }: GallerySliderProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const mediaContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation("game_details");
const hasScreenshots = gameDetails && gameDetails.screenshots.length;
const hasMovies = gameDetails && gameDetails.movies?.length;
const hasScreenshots = shopDetails && shopDetails.screenshots.length;
const hasMovies = shopDetails && shopDetails.movies?.length;
const [mediaCount] = useState<number>(() => {
if (gameDetails.screenshots && gameDetails.movies) {
return gameDetails.screenshots.length + gameDetails.movies.length;
} else if (gameDetails.movies) {
return gameDetails.movies.length;
} else if (gameDetails.screenshots) {
return gameDetails.screenshots.length;
const mediaCount = useMemo(() => {
if (!shopDetails) return 0;
if (shopDetails.screenshots && shopDetails.movies) {
return shopDetails.screenshots.length + shopDetails.movies.length;
} else if (shopDetails.movies) {
return shopDetails.movies.length;
} else if (shopDetails.screenshots) {
return shopDetails.screenshots.length;
}
return 0;
});
}, [shopDetails]);
const [mediaIndex, setMediaIndex] = useState<number>(0);
const [mediaIndex, setMediaIndex] = useState(0);
const [showArrows, setShowArrows] = useState(false);
const showNextImage = () => {
@ -52,7 +51,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
useEffect(() => {
setMediaIndex(0);
}, [gameDetails]);
}, [shopDetails]);
useEffect(() => {
if (hasMovies && mediaContainerRef.current) {
@ -76,17 +75,17 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
const scrollLeft = mediaIndex * itemWidth;
container.scrollLeft = scrollLeft;
}
}, [gameDetails, mediaIndex, mediaCount]);
}, [shopDetails, mediaIndex, mediaCount]);
const previews = useMemo(() => {
const screenshotPreviews =
gameDetails?.screenshots.map(({ id, path_thumbnail }) => ({
shopDetails?.screenshots.map(({ id, path_thumbnail }) => ({
id,
thumbnail: path_thumbnail,
})) ?? [];
if (gameDetails?.movies) {
const moviePreviews = gameDetails.movies.map(({ id, thumbnail }) => ({
if (shopDetails?.movies) {
const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({
id,
thumbnail,
}));
@ -95,7 +94,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
}
return screenshotPreviews;
}, [gameDetails]);
}, [shopDetails]);
return (
<>
@ -107,8 +106,8 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
className={styles.gallerySliderAnimationContainer}
ref={mediaContainerRef}
>
{gameDetails.movies &&
gameDetails.movies.map((video) => (
{shopDetails.movies &&
shopDetails.movies.map((video) => (
<video
key={video.id}
controls
@ -124,7 +123,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
))}
{hasScreenshots &&
gameDetails.screenshots.map((image, i) => (
shopDetails.screenshots.map((image, i) => (
<img
key={image.id}
className={styles.gallerySliderMedia}

View File

@ -0,0 +1,206 @@
import { createContext, useCallback, useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers";
import { useAppDispatch, useDownload } from "@renderer/hooks";
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
import { useTranslation } from "react-i18next";
import {
DODIInstallationGuide,
DONT_SHOW_DODI_INSTRUCTIONS_KEY,
DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY,
OnlineFixInstallationGuide,
RepacksModal,
} from "./modals";
export interface GameDetailsContext {
game: Game | null;
shopDetails: ShopDetails | null;
repacks: GameRepack[];
gameTitle: string;
isGameRunning: boolean;
isLoading: boolean;
objectID: string | undefined;
gameColor: string;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
openRepacksModal: () => void;
updateGame: () => Promise<void>;
}
export const gameDetailsContext = createContext<GameDetailsContext>({
game: null,
shopDetails: null,
repacks: [],
gameTitle: "",
isGameRunning: false,
isLoading: false,
objectID: undefined,
gameColor: "",
setGameColor: () => {},
openRepacksModal: () => {},
updateGame: async () => {},
});
const { Provider } = gameDetailsContext;
export const { Consumer: GameDetailsContextConsumer } = gameDetailsContext;
export interface GameDetailsContextProps {
children: React.ReactNode;
}
export function GameDetailsContextProvider({
children,
}: GameDetailsContextProps) {
const { objectID, shop } = useParams();
const [shopDetails, setGameDetails] = useState<ShopDetails | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [gameColor, setGameColor] = useState("");
const [showInstructionsModal, setShowInstructionsModal] = useState<
null | "onlinefix" | "DODI"
>(null);
const [isGameRunning, setisGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [searchParams] = useSearchParams();
const gameTitle = searchParams.get("title")!;
const { i18n } = useTranslation("game_details");
const dispatch = useAppDispatch();
const { startDownload, lastPacket } = useDownload();
const updateGame = useCallback(async () => {
return window.electron
.getGameByObjectID(objectID!)
.then((result) => setGame(result));
}, [setGame, objectID]);
const isGameDownloading = lastPacket?.game.id === game?.id;
useEffect(() => {
updateGame();
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
useEffect(() => {
Promise.all([
window.electron.getGameShopDetails(
objectID!,
shop as GameShop,
getSteamLanguage(i18n.language)
),
window.electron.searchGameRepacks(gameTitle),
])
.then(([appDetails, repacks]) => {
if (appDetails) setGameDetails(appDetails);
setRepacks(repacks);
})
.finally(() => {
setIsLoading(false);
});
updateGame();
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
useEffect(() => {
setGameDetails(null);
setGame(null);
setIsLoading(true);
setisGameRunning(false);
dispatch(setHeaderTitle(gameTitle));
}, [objectID, gameTitle, dispatch]);
useEffect(() => {
const listeners = [
window.electron.onGameClose(() => {
if (isGameRunning) setisGameRunning(false);
}),
window.electron.onPlaytime((gameId) => {
if (gameId === game?.id) {
if (!isGameRunning) setisGameRunning(true);
updateGame();
}
}),
];
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [game?.id, isGameRunning, updateGame]);
const handleStartDownload = async (
repack: GameRepack,
downloadPath: string
) => {
await startDownload(
repack.id,
objectID!,
gameTitle,
shop as GameShop,
downloadPath
);
await updateGame();
setShowRepacksModal(false);
if (
repack.repacker === "onlinefix" &&
!window.localStorage.getItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("onlinefix");
} else if (
repack.repacker === "DODI" &&
!window.localStorage.getItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("DODI");
}
};
const openRepacksModal = () => setShowRepacksModal(true);
return (
<Provider
value={{
game,
shopDetails,
repacks,
gameTitle,
isGameRunning,
isLoading,
objectID,
gameColor,
setGameColor,
openRepacksModal,
updateGame,
}}
>
<>
<RepacksModal
visible={showRepacksModal}
startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)}
/>
<OnlineFixInstallationGuide
visible={showInstructionsModal === "onlinefix"}
onClose={() => setShowInstructionsModal(null)}
/>
<DODIInstallationGuide
visible={showInstructionsModal === "DODI"}
onClose={() => setShowInstructionsModal(null)}
/>
{children}
</>
</Provider>
);
}

View File

@ -1,24 +1,11 @@
import Color from "color";
import { average } from "color.js";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { average } from "color.js";
import {
Steam250Game,
type Game,
type GameRepack,
type GameShop,
type ShopDetails,
} from "@types";
import { Steam250Game } from "@types";
import { Button } from "@renderer/components";
import { setHeaderTitle } from "@renderer/features";
import {
buildGameDetailsPath,
getSteamLanguage,
steamUrlBuilder,
} from "@renderer/helpers";
import { useAppDispatch, useDownload } from "@renderer/hooks";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
import starsAnimation from "@renderer/assets/lottie/stars.json";
@ -29,153 +16,34 @@ import { DescriptionHeader } from "./description-header";
import { GameDetailsSkeleton } from "./game-details-skeleton";
import * as styles from "./game-details.css";
import { HeroPanel } from "./hero";
import { RepacksModal } from "./repacks-modal";
import { vars } from "../../theme.css";
import {
DODIInstallationGuide,
DONT_SHOW_DODI_INSTRUCTIONS_KEY,
DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY,
OnlineFixInstallationGuide,
} from "./installation-guides";
import { GallerySlider } from "./gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
import {
GameDetailsContextConsumer,
GameDetailsContextProvider,
} from "./game-details.context";
export function GameDetails() {
const { objectID, shop } = useParams();
const [isLoading, setIsLoading] = useState(false);
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [color, setColor] = useState({ dark: "", light: "" });
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [isGamePlaying, setIsGamePlaying] = useState(false);
const [showInstructionsModal, setShowInstructionsModal] = useState<
null | "onlinefix" | "DODI"
>(null);
const navigate = useNavigate();
const { objectID } = useParams();
const [searchParams] = useSearchParams();
const fromRandomizer = searchParams.get("fromRandomizer");
const title = searchParams.get("title")!;
const { t, i18n } = useTranslation("game_details");
const { t } = useTranslation("game_details");
const [showRepacksModal, setShowRepacksModal] = useState(false);
const dispatch = useAppDispatch();
const { game: gameDownloading, startDownload } = useDownload();
const heroImage = steamUrlBuilder.libraryHero(objectID!);
const handleHeroLoad = () => {
average(heroImage, { amount: 1, format: "hex" })
.then((color) => {
const darkColor = new Color(color).darken(0.6).toString() as string;
setColor({ light: color as string, dark: darkColor });
})
.catch(() => {});
};
const getGame = useCallback(() => {
window.electron
.getGameByObjectID(objectID!)
.then((result) => setGame(result));
}, [setGame, objectID]);
const navigate = useNavigate();
useEffect(() => {
getGame();
}, [getGame, gameDownloading?.id]);
useEffect(() => {
setGameDetails(null);
setGame(null);
setIsLoading(true);
setIsGamePlaying(false);
dispatch(setHeaderTitle(title));
setRandomGame(null);
window.electron.getRandomGame().then((randomGame) => {
setRandomGame(randomGame);
});
Promise.all([
window.electron.getGameShopDetails(
objectID!,
"steam",
getSteamLanguage(i18n.language)
),
window.electron.searchGameRepacks(title),
])
.then(([appDetails, repacks]) => {
if (appDetails) setGameDetails(appDetails);
setRepacks(repacks);
})
.finally(() => {
setIsLoading(false);
});
getGame();
}, [getGame, dispatch, navigate, title, objectID, i18n.language]);
const isGameDownloading = gameDownloading?.id === game?.id;
useEffect(() => {
if (isGameDownloading)
setGame((prev) => {
if (prev === null || !gameDownloading?.status) return prev;
return { ...prev, status: gameDownloading?.status };
});
}, [isGameDownloading, gameDownloading?.status]);
useEffect(() => {
const listeners = [
window.electron.onGameClose(() => {
if (isGamePlaying) setIsGamePlaying(false);
}),
window.electron.onPlaytime((gameId) => {
if (gameId === game?.id) {
if (!isGamePlaying) setIsGamePlaying(true);
getGame();
}
}),
];
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [game?.id, isGamePlaying, getGame]);
const handleStartDownload = async (
repack: GameRepack,
downloadPath: string
) => {
return startDownload(
repack.id,
objectID!,
title,
shop as GameShop,
downloadPath
).then(() => {
getGame();
setShowRepacksModal(false);
if (
repack.repacker === "onlinefix" &&
!window.localStorage.getItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("onlinefix");
} else if (
repack.repacker === "DODI" &&
!window.localStorage.getItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("DODI");
}
});
};
}, [objectID]);
const handleRandomizerClick = () => {
if (randomGame) {
@ -189,97 +57,95 @@ export function GameDetails() {
};
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<RepacksModal
visible={showRepacksModal}
repacks={repacks}
startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)}
/>
<GameDetailsContextProvider>
<GameDetailsContextConsumer>
{({ game, shopDetails, isLoading, setGameColor }) => {
const handleHeroLoad = async () => {
const output = await average(
steamUrlBuilder.libraryHero(objectID!),
{
amount: 1,
format: "hex",
}
);
<OnlineFixInstallationGuide
visible={showInstructionsModal === "onlinefix"}
onClose={() => setShowInstructionsModal(null)}
/>
setGameColor(output as string);
};
<DODIInstallationGuide
windowColor={color.light}
visible={showInstructionsModal === "DODI"}
onClose={() => setShowInstructionsModal(null)}
/>
return (
<SkeletonTheme
baseColor={vars.color.background}
highlightColor="#444"
>
{isLoading ? (
<GameDetailsSkeleton />
) : (
<section className={styles.container}>
<div className={styles.hero}>
<img
src={steamUrlBuilder.libraryHero(objectID!)}
className={styles.heroImage}
alt={game?.title}
onLoad={handleHeroLoad}
/>
<div className={styles.heroBackdrop}>
<div className={styles.heroContent}>
<img
src={steamUrlBuilder.logo(objectID!)}
style={{ width: 300, alignSelf: "flex-end" }}
alt={game?.title}
/>
</div>
</div>
</div>
{isLoading ? (
<GameDetailsSkeleton />
) : (
<section className={styles.container}>
<div className={styles.hero}>
<img
src={heroImage}
className={styles.heroImage}
alt={game?.title}
onLoad={handleHeroLoad}
/>
<div className={styles.heroBackdrop}>
<div className={styles.heroContent}>
<img
src={steamUrlBuilder.logo(objectID!)}
style={{ width: 300, alignSelf: "flex-end" }}
alt={game?.title}
/>
</div>
</div>
</div>
<HeroPanel />
<HeroPanel
game={game}
color={color.dark}
objectID={objectID!}
title={title}
repacks={repacks}
openRepacksModal={() => setShowRepacksModal(true)}
getGame={getGame}
isGamePlaying={isGamePlaying}
/>
<div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}>
<DescriptionHeader />
<GallerySlider />
<div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}>
{gameDetails && <DescriptionHeader gameDetails={gameDetails} />}
{gameDetails && <GallerySlider gameDetails={gameDetails} />}
<div
dangerouslySetInnerHTML={{
__html:
shopDetails?.about_the_game ?? t("no_shop_details"),
}}
className={styles.description}
/>
</div>
<div
dangerouslySetInnerHTML={{
__html: gameDetails?.about_the_game ?? t("no_shop_details"),
}}
className={styles.description}
/>
</div>
<Sidebar />
</div>
</section>
)}
<Sidebar
objectID={objectID!}
title={title}
gameDetails={gameDetails}
/>
</div>
</section>
)}
{fromRandomizer && (
<Button
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
disabled={!randomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie
animationData={starsAnimation}
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
loop
/>
</div>
{t("next_suggestion")}
</Button>
)}
</SkeletonTheme>
{fromRandomizer && (
<Button
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
disabled={!randomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie
animationData={starsAnimation}
style={{
width: 70,
position: "absolute",
top: -28,
left: -27,
}}
loop
/>
</div>
{t("next_suggestion")}
</Button>
)}
</SkeletonTheme>
);
}}
</GameDetailsContextConsumer>
</GameDetailsContextProvider>
);
}

View File

@ -1,37 +1,14 @@
import { GameStatus, GameStatusHelper } from "@shared";
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import type { Game, GameRepack } from "@types";
import { useState } from "react";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel-actions.css";
import { gameDetailsContext } from "../game-details.context";
export interface HeroPanelActionsProps {
game: Game | null;
repacks: GameRepack[];
isGamePlaying: boolean;
isGameDownloading: boolean;
objectID: string;
title: string;
openRepacksModal: () => void;
openBinaryNotFoundModal: () => void;
getGame: () => void;
}
export function HeroPanelActions({
game,
isGamePlaying,
isGameDownloading,
repacks,
objectID,
title,
openRepacksModal,
openBinaryNotFoundModal,
getGame,
}: HeroPanelActionsProps) {
export function HeroPanelActions() {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
useState(false);
@ -43,6 +20,16 @@ export function HeroPanelActions({
isGameDeleting,
} = useDownload();
const {
game,
repacks,
isGameRunning,
objectID,
gameTitle,
openRepacksModal,
updateGame,
} = useContext(gameDetailsContext);
const { updateLibrary } = useLibrary();
const { t } = useTranslation("game_details");
@ -86,15 +73,15 @@ export function HeroPanelActions({
const gameExecutablePath = await selectGameExecutable();
await window.electron.addGameToLibrary(
objectID,
title,
objectID!,
gameTitle,
"steam",
gameExecutablePath
);
}
updateLibrary();
getGame();
updateGame();
} finally {
setToggleLibraryGameDisabled(false);
}
@ -145,59 +132,14 @@ export function HeroPanelActions({
</Button>
);
if (game && isGameDownloading) {
if (game?.progress === 1) {
return (
<>
<Button
onClick={() => pauseDownload(game.id)}
theme="outline"
className={styles.heroPanelAction}
>
{t("pause")}
</Button>
<Button
onClick={() => cancelDownload(game.id)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === GameStatus.Paused) {
return (
<>
<Button
onClick={() => resumeDownload(game.id)}
theme="outline"
className={styles.heroPanelAction}
>
{t("resume")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(getGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (
GameStatusHelper.isReady(game?.status ?? null) ||
(game && !game.status)
) {
return (
<>
{GameStatusHelper.isReady(game?.status ?? null) ? (
{game?.progress === 1 ? (
<Button
onClick={openGameInstaller}
theme="outline"
disabled={deleting || isGamePlaying}
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
>
{t("install")}
@ -206,7 +148,7 @@ export function HeroPanelActions({
toggleGameOnLibraryButton
)}
{isGamePlaying ? (
{isGameRunning ? (
<Button
onClick={closeGame}
theme="outline"
@ -219,7 +161,7 @@ export function HeroPanelActions({
<Button
onClick={openGame}
theme="outline"
disabled={deleting || isGamePlaying}
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
>
{t("play")}
@ -229,7 +171,49 @@ export function HeroPanelActions({
);
}
if (game?.status === GameStatus.Cancelled) {
if (game?.status === "active") {
return (
<>
<Button
onClick={() => pauseDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("pause")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "paused") {
return (
<>
<Button
onClick={() => resumeDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("resume")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "removed") {
return (
<>
<Button
@ -240,8 +224,9 @@ export function HeroPanelActions({
>
{t("open_download_options")}
</Button>
<Button
onClick={() => removeGameFromLibrary(game.id).then(getGame)}
onClick={() => removeGameFromLibrary(game.id).then(updateGame)}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}

View File

@ -1,22 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Game } from "@types";
import { useDate } from "@renderer/hooks";
import { gameDetailsContext } from "../game-details.context";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export interface HeroPanelPlaytimeProps {
game: Game;
isGamePlaying: boolean;
}
export function HeroPanelPlaytime({
game,
isGamePlaying,
}: HeroPanelPlaytimeProps) {
export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext);
const { i18n, t } = useTranslation("game_details");
const { formatDistance } = useDate();
@ -52,8 +46,8 @@ export function HeroPanelPlaytime({
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
if (!game.lastTimePlayed) {
return <p>{t("not_played_yet", { title: game.title })}</p>;
if (!game?.lastTimePlayed) {
return <p>{t("not_played_yet", { title: game?.title })}</p>;
}
return (
@ -64,7 +58,7 @@ export function HeroPanelPlaytime({
})}
</p>
{isGamePlaying ? (
{isGameRunning ? (
<p>{t("playing_now")}</p>
) : (
<p>

View File

@ -1,72 +1,48 @@
import { format } from "date-fns";
import { useMemo, useState } from "react";
import { useContext, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Color from "color";
import { useDownload } from "@renderer/hooks";
import type { Game, GameRepack } from "@types";
import { formatDownloadProgress } from "@renderer/helpers";
import { HeroPanelActions } from "./hero-panel-actions";
import { Downloader, GameStatus, GameStatusHelper, formatBytes } from "@shared";
import { Downloader, formatBytes } from "@shared";
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
import * as styles from "./hero-panel.css";
import { HeroPanelPlaytime } from "./hero-panel-playtime";
import { gameDetailsContext } from "../game-details.context";
export interface HeroPanelProps {
game: Game | null;
color: string;
isGamePlaying: boolean;
objectID: string;
title: string;
repacks: GameRepack[];
openRepacksModal: () => void;
getGame: () => void;
}
export function HeroPanel({
game,
color,
repacks,
objectID,
title,
isGamePlaying,
openRepacksModal,
getGame,
}: HeroPanelProps) {
export function HeroPanel() {
const { t } = useTranslation("game_details");
const { game, repacks, gameColor } = useContext(gameDetailsContext);
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const {
game: gameDownloading,
progress,
eta,
numPeers,
numSeeds,
isGameDeleting,
} = useDownload();
const isGameDownloading =
gameDownloading?.id === game?.id &&
GameStatusHelper.isDownloading(game?.status ?? null);
const { progress, eta, lastPacket, isGameDeleting } = useDownload();
const finalDownloadSize = useMemo(() => {
if (!game) return "N/A";
if (game.fileSize) return formatBytes(game.fileSize);
if (gameDownloading?.fileSize && isGameDownloading)
return formatBytes(gameDownloading.fileSize);
if (lastPacket?.game.fileSize && game?.status === "active")
return formatBytes(lastPacket?.game.fileSize);
return game.repack?.fileSize ?? "N/A";
}, [game, isGameDownloading, gameDownloading]);
}, [game, lastPacket?.game]);
const getInfo = () => {
if (isGameDeleting(game?.id ?? -1)) {
return <p>{t("deleting")}</p>;
}
if (isGameDeleting(game?.id ?? -1)) return <p>{t("deleting")}</p>;
if (game?.progress === 1) return <HeroPanelPlaytime />;
if (game?.status === "active") {
if (lastPacket?.downloadingMetadata) {
return <p>{t("downloading_metadata")}</p>;
}
if (isGameDownloading && gameDownloading?.status) {
return (
<>
<p className={styles.downloadDetailsRow}>
@ -74,33 +50,25 @@ export function HeroPanel({
{eta && <small>{t("eta", { eta })}</small>}
</p>
{gameDownloading.status !== GameStatus.Downloading ? (
<>
<p>{t(gameDownloading.status)}</p>
{eta && <small>{t("eta", { eta })}</small>}
</>
) : (
<p className={styles.downloadDetailsRow}>
{formatBytes(gameDownloading.bytesDownloaded)} /{" "}
{finalDownloadSize}
<p className={styles.downloadDetailsRow}>
{formatBytes(lastPacket?.game?.bytesDownloaded ?? 0)} /{" "}
{finalDownloadSize}
{game?.downloader === Downloader.Torrent && (
<small>
{game?.downloader === Downloader.Torrent &&
`${numPeers} peers / ${numSeeds} seeds`}
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
</small>
</p>
)}
)}
</p>
</>
);
}
if (game?.status === GameStatus.Paused) {
if (game?.status === "paused") {
const formattedProgress = formatDownloadProgress(game.progress);
return (
<>
<p>
{t("paused_progress", {
progress: formatDownloadProgress(game.progress),
})}
</p>
<p>{t("paused_progress", { progress: formattedProgress })}</p>
<p>
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
</p>
@ -108,10 +76,6 @@ export function HeroPanel({
);
}
if (game && GameStatusHelper.isReady(game?.status ?? GameStatus.Finished)) {
return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />;
}
const [latestRepack] = repacks;
if (latestRepack) {
@ -129,6 +93,10 @@ export function HeroPanel({
return <p>{t("no_downloads")}</p>;
};
const backgroundColor = gameColor
? (new Color(gameColor).darken(0.6).toString() as string)
: "";
return (
<>
<BinaryNotFoundModal
@ -136,19 +104,11 @@ export function HeroPanel({
onClose={() => setShowBinaryNotFoundModal(false)}
/>
<div style={{ backgroundColor: color }} className={styles.panel}>
<div style={{ backgroundColor }} className={styles.panel}>
<div className={styles.content}>{getInfo()}</div>
<div className={styles.actions}>
<HeroPanelActions
game={game}
repacks={repacks}
objectID={objectID}
title={title}
getGame={getGame}
openRepacksModal={openRepacksModal}
openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)}
isGamePlaying={isGamePlaying}
isGameDownloading={isGameDownloading}
/>
</div>
</div>

View File

@ -0,0 +1,3 @@
export * from "./installation-guides";
export * from "./repacks-modal";
export * from "./select-folder-modal";

View File

@ -1,4 +1,4 @@
import { vars } from "../../../theme.css";
import { vars } from "../../../../theme.css";
import { keyframes, style } from "@vanilla-extract/css";
export const slideIn = keyframes({

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useContext, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button, CheckboxField, Modal } from "@renderer/components";
@ -7,18 +7,19 @@ import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./dodi-installation-guide.css";
import { ArrowUpIcon } from "@primer/octicons-react";
import { DONT_SHOW_DODI_INSTRUCTIONS_KEY } from "./constants";
import { gameDetailsContext } from "../../game-details.context";
export interface DODIInstallationGuideProps {
windowColor: string;
visible: boolean;
onClose: () => void;
}
export function DODIInstallationGuide({
windowColor,
visible,
onClose,
}: DODIInstallationGuideProps) {
const { gameColor } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const [dontShowAgain, setDontShowAgain] = useState(false);
@ -53,7 +54,7 @@ export function DODIInstallationGuide({
<div
className={styles.windowContainer}
style={{ backgroundColor: windowColor }}
style={{ backgroundColor: gameColor }}
>
<div className={styles.windowContent}>
<ArrowUpIcon size={24} />

View File

@ -1,4 +1,4 @@
import { SPACING_UNIT } from "../../../theme.css";
import { SPACING_UNIT } from "../../../../theme.css";
import { style } from "@vanilla-extract/css";
export const passwordField = style({

View File

@ -1,5 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const repacks = style({
display: "flex",

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
@ -6,20 +6,19 @@ import type { GameRepack } from "@types";
import * as styles from "./repacks-modal.css";
import { SPACING_UNIT } from "../../theme.css";
import { SPACING_UNIT } from "../../../theme.css";
import { format } from "date-fns";
import { SelectFolderModal } from "./select-folder-modal";
import { gameDetailsContext } from "../game-details.context";
export interface RepacksModalProps {
visible: boolean;
repacks: GameRepack[];
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
onClose: () => void;
}
export function RepacksModal({
visible,
repacks,
startDownload,
onClose,
}: RepacksModalProps) {
@ -27,6 +26,8 @@ export function RepacksModal({
const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const { repacks } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
useEffect(() => {

View File

@ -1,5 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const container = style({
display: "flex",

View File

@ -1,13 +1,14 @@
import { Button, Link, Modal, TextField } from "@renderer/components";
import type { GameRepack } from "@types";
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { DiskSpace } from "check-disk-space";
import * as styles from "./select-folder-modal.css";
import { Button, Link, Modal, TextField } from "@renderer/components";
import { DownloadIcon } from "@primer/octicons-react";
import { formatBytes } from "@shared";
import type { GameRepack } from "@types";
export interface SelectFolderModalProps {
visible: boolean;
onClose: () => void;

View File

@ -1,22 +1,13 @@
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import type {
HowLongToBeatCategory,
ShopDetails,
SteamAppDetails,
} from "@types";
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import * as styles from "./sidebar.css";
import { gameDetailsContext } from "../game-details.context";
export interface SidebarProps {
objectID: string;
title: string;
gameDetails: ShopDetails | null;
}
export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
export function Sidebar() {
const [howLongToBeat, setHowLongToBeat] = useState<{
isLoading: boolean;
data: HowLongToBeatCategory[] | null;
@ -25,20 +16,24 @@ export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { gameTitle, shopDetails, objectID } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
useEffect(() => {
setHowLongToBeat({ isLoading: true, data: null });
if (objectID) {
setHowLongToBeat({ isLoading: true, data: null });
window.electron
.getHowLongToBeat(objectID, "steam", title)
.then((howLongToBeat) => {
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
})
.catch(() => {
setHowLongToBeat({ isLoading: false, data: null });
});
}, [objectID, title]);
window.electron
.getHowLongToBeat(objectID, "steam", gameTitle)
.then((howLongToBeat) => {
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
})
.catch(() => {
setHowLongToBeat({ isLoading: false, data: null });
});
}
}, [objectID, gameTitle]);
return (
<aside className={styles.contentSidebar}>
@ -73,9 +68,9 @@ export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
className={styles.requirementsDetails}
dangerouslySetInnerHTML={{
__html:
gameDetails?.pc_requirements?.[activeRequirement] ??
shopDetails?.pc_requirements?.[activeRequirement] ??
t(`no_${activeRequirement}_requirements`, {
title,
gameTitle,
}),
}}
/>

View File

@ -31,22 +31,11 @@ export const formatBytes = (bytes: number): string => {
};
export class GameStatusHelper {
public static isDownloading(status: GameStatus | null) {
return (
status === GameStatus.Downloading ||
status === GameStatus.DownloadingMetadata ||
status === GameStatus.CheckingFiles
);
public static isDownloading(status: string | null) {
return status === "active";
}
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;
public static isReady(status: string | null) {
return status === "complete";
}
}

View File

@ -1,4 +1,5 @@
import type { Downloader, GameStatus } from "@shared";
import type { Downloader } from "@shared";
import type { Aria2Status } from "aria2";
export type GameShop = "steam" | "epic";
export type CatalogueCategory = "recently_added" | "trending";
@ -91,14 +92,12 @@ export interface Game extends Omit<CatalogueEntry, "cover"> {
id: number;
title: string;
iconUrl: string;
status: GameStatus | null;
status: Aria2Status | null;
folderName: string;
downloadPath: string | null;
repacks: GameRepack[];
repack: GameRepack | null;
progress: number;
fileVerificationProgress: number;
decompressionProgress: number;
bytesDownloaded: number;
playTimeInMilliseconds: number;
downloader: Downloader;
@ -109,11 +108,15 @@ export interface Game extends Omit<CatalogueEntry, "cover"> {
updatedAt: Date;
}
export interface TorrentProgress {
export interface DownloadProgress {
downloadSpeed: number;
timeRemaining: number;
numPeers: number;
numSeeds: number;
downloadingMetadata: boolean;
progress: number;
bytesDownloaded: number;
fileSize: number;
game: Omit<Game, "repacks">;
}

View File

@ -1,35 +0,0 @@
import platform
class Fifo:
socket_handle = None
def __init__(self, path: str):
if platform.system() == "Windows":
import win32file
self.socket_handle = win32file.CreateFile(path, win32file.GENERIC_READ | win32file.GENERIC_WRITE,
0, None, win32file.OPEN_EXISTING, win32file.FILE_ATTRIBUTE_NORMAL, None)
else:
import socket
self.socket_handle = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.socket_handle.connect(path)
def recv(self, bufSize: int):
if platform.system() == "Windows":
import win32file
result, data = win32file.ReadFile(self.socket_handle, bufSize)
return data
else:
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, buffer)
else:
self.socket_handle.send(buffer)

View File

@ -1,103 +0,0 @@
import libtorrent as lt
import sys
from fifo import Fifo
import json
import threading
import time
torrent_port = sys.argv[1]
read_sock_path = sys.argv[2]
write_sock_path = sys.argv[3]
session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=torrent_port)})
read_fifo = Fifo(read_sock_path)
write_fifo = Fifo(write_sock_path)
torrent_handle = None
downloading_game_id = 0
def get_eta(status):
remaining_bytes = status.total_wanted - status.total_wanted_done
if remaining_bytes >= 0 and status.download_rate > 0:
return (remaining_bytes / status.download_rate) * 1000
else:
return 1
def start_download(game_id: int, magnet: str, save_path: str):
global torrent_handle
global downloading_game_id
params = {'url': magnet, 'save_path': save_path}
torrent_handle = session.add_torrent(params)
downloading_game_id = game_id
torrent_handle.set_flags(lt.torrent_flags.auto_managed)
torrent_handle.resume()
def pause_download():
global downloading_game_id
if torrent_handle:
torrent_handle.pause()
torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
downloading_game_id = 0
def cancel_download():
global downloading_game_id
global torrent_handle
if torrent_handle:
torrent_handle.pause()
session.remove_torrent(torrent_handle)
torrent_handle = None
downloading_game_id = 0
def get_download_updates():
while True:
if downloading_game_id == 0:
time.sleep(0.5)
continue
status = torrent_handle.status()
info = torrent_handle.get_torrent_info()
write_fifo.send_message(json.dumps({
'folderName': info.name() if info else "",
'fileSize': info.total_size() if info else 0,
'gameId': downloading_game_id,
'progress': status.progress,
'downloadSpeed': status.download_rate,
'timeRemaining': get_eta(status),
'numPeers': status.num_peers,
'numSeeds': status.num_seeds,
'status': status.state,
'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download,
}))
if status.progress == 1:
cancel_download()
time.sleep(0.5)
def listen_to_socket():
while True:
msg = read_fifo.recv(1024 * 2)
payload = json.loads(msg.decode("utf-8"))
if payload['action'] == "start":
start_download(payload['game_id'], payload['magnet'], payload['save_path'])
continue
if payload['action'] == "pause":
pause_download()
continue
if payload['action'] == "cancel":
cancel_download()
if __name__ == "__main__":
p1 = threading.Thread(target=get_download_updates)
p2 = threading.Thread(target=listen_to_socket)
p1.start()
p2.start()

View File

@ -1,20 +0,0 @@
from cx_Freeze import setup, Executable
# Dependencies are automatically detected, but it might need fine tuning.
build_exe_options = {
"packages": ["libtorrent"],
"build_exe": "hydra-download-manager",
"include_msvcr": True
}
setup(
name="hydra-download-manager",
version="0.1",
description="Hydra Torrent Client",
options={"build_exe": build_exe_options},
executables=[Executable(
"torrent-client/main.py",
target_name="hydra-download-manager",
icon="build/icon.ico"
)]
)

View File

@ -1742,6 +1742,14 @@ aria-query@^5.3.0:
dependencies:
dequal "^2.0.3"
aria2@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/aria2/-/aria2-4.1.2.tgz#0ecbc50beea82856c88b4de71dac336154f67362"
integrity sha512-qTBr2RY8RZQmiUmbj2KXFvkErNxU4aTHZszszzwhE8svy2PEVX+IYR/c4Rp9Tuw4QkeU8cylGy6McV6Yl8i7Qw==
dependencies:
node-fetch "^2.6.1"
ws "^7.4.0"
array-buffer-byte-length@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f"
@ -4685,7 +4693,7 @@ node-domexception@^1.0.0:
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
node-fetch@^2.6.7:
node-fetch@^2.6.1, node-fetch@^2.6.7:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
@ -6356,6 +6364,11 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
ws@^7.4.0:
version "7.5.9"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
ws@^8.16.0:
version "8.17.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea"