mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feat: using rpc to communicate
This commit is contained in:
parent
05cfdefc84
commit
328b7cb137
15 changed files with 332 additions and 298 deletions
|
@ -1,5 +1,5 @@
|
|||
import { registerEvent } from "../register-event";
|
||||
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
|
||||
import { HydraApi, TorrentDownloader, gamesPlaytime } from "@main/services";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import { DownloadQueue, Game, UserAuth } from "@main/entity";
|
||||
|
||||
|
@ -19,8 +19,8 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
|
|||
gamesPlaytime.clear();
|
||||
});
|
||||
|
||||
/* Disconnects aria2 */
|
||||
DownloadManager.kill();
|
||||
/* Disconnects libtorrent */
|
||||
TorrentDownloader.kill();
|
||||
|
||||
await Promise.all([
|
||||
databaseOperations,
|
||||
|
|
|
@ -4,7 +4,7 @@ import i18n from "i18next";
|
|||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
||||
import { DownloadManager, logger, WindowManager } from "@main/services";
|
||||
import { logger, TorrentDownloader, WindowManager } from "@main/services";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import * as resources from "@locales";
|
||||
import { userPreferencesRepository } from "@main/repository";
|
||||
|
@ -108,7 +108,8 @@ app.on("window-all-closed", () => {
|
|||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
DownloadManager.kill();
|
||||
/* Disconnects libtorrent */
|
||||
TorrentDownloader.kill();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
|
|
|
@ -1,187 +0,0 @@
|
|||
import cp from "node:child_process";
|
||||
|
||||
import { WindowManager } from "./window-manager";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
import { startTorrentClient } from "./torrent-client";
|
||||
import { readPipe, writePipe } from "./fifo";
|
||||
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
||||
import { publishDownloadCompleteNotification } from "./notifications";
|
||||
import { DownloadProgress } from "@types";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
|
||||
enum LibtorrentStatus {
|
||||
CheckingFiles = 1,
|
||||
DownloadingMetadata = 2,
|
||||
Downloading = 3,
|
||||
Finished = 4,
|
||||
Seeding = 5,
|
||||
}
|
||||
|
||||
const getETA = (
|
||||
totalLength: number,
|
||||
completedLength: number,
|
||||
speed: number
|
||||
) => {
|
||||
const remainingBytes = totalLength - completedLength;
|
||||
|
||||
if (remainingBytes >= 0 && speed > 0) {
|
||||
return (remainingBytes / speed) * 1000;
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
export class DownloadManager {
|
||||
private static torrentClient: cp.ChildProcess | null = null;
|
||||
private static downloadingGameId = -1;
|
||||
|
||||
private static async spawn() {
|
||||
this.torrentClient = await startTorrentClient();
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
if (this.torrentClient) {
|
||||
this.torrentClient.kill();
|
||||
this.torrentClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static async watchDownloads() {
|
||||
if (!this.downloadingGameId) return;
|
||||
|
||||
const buf = readPipe.socket?.read(1024 * 2);
|
||||
|
||||
if (buf === null) return;
|
||||
|
||||
const message = Buffer.from(buf.slice(0, buf.indexOf(0x00))).toString(
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
try {
|
||||
const {
|
||||
progress,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
folderName,
|
||||
status,
|
||||
} = JSON.parse(message) as {
|
||||
progress: number;
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
downloadSpeed: number;
|
||||
bytesDownloaded: number;
|
||||
fileSize: number;
|
||||
folderName: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
// TODO: Checking files as metadata is a workaround
|
||||
const isDownloadingMetadata =
|
||||
status === LibtorrentStatus.DownloadingMetadata ||
|
||||
status === LibtorrentStatus.CheckingFiles;
|
||||
|
||||
if (!isDownloadingMetadata) {
|
||||
const update: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
progress,
|
||||
};
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: this.downloadingGameId },
|
||||
{
|
||||
...update,
|
||||
folderName,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: this.downloadingGameId, isDeleted: false },
|
||||
});
|
||||
|
||||
if (WindowManager.mainWindow && game) {
|
||||
if (!isNaN(progress))
|
||||
WindowManager.mainWindow.setProgressBar(
|
||||
progress === 1 ? -1 : progress
|
||||
);
|
||||
|
||||
const payload = {
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
timeRemaining: getETA(fileSize, bytesDownloaded, downloadSpeed),
|
||||
isDownloadingMetadata,
|
||||
game,
|
||||
} as DownloadProgress;
|
||||
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(JSON.stringify(payload))
|
||||
);
|
||||
}
|
||||
|
||||
if (progress === 1 && game) {
|
||||
publishDownloadCompleteNotification(game);
|
||||
|
||||
await downloadQueueRepository.delete({ game });
|
||||
|
||||
// Clear download
|
||||
this.downloadingGameId = -1;
|
||||
|
||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||
order: {
|
||||
id: "DESC",
|
||||
},
|
||||
relations: {
|
||||
game: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (nextQueueItem) {
|
||||
this.resumeDownload(nextQueueItem.game);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
writePipe.write({
|
||||
action: "pause",
|
||||
game_id: this.downloadingGameId,
|
||||
});
|
||||
|
||||
this.downloadingGameId = -1;
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
}
|
||||
|
||||
static async resumeDownload(game: Game) {
|
||||
this.startDownload(game);
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (!this.torrentClient) await this.spawn();
|
||||
|
||||
writePipe.write({
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
magnet: game.uri,
|
||||
save_path: game.downloadPath,
|
||||
});
|
||||
|
||||
this.downloadingGameId = game.id;
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
writePipe.write({ action: "cancel", game_id: gameId });
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
}
|
||||
}
|
101
src/main/services/download/download-manager.ts
Normal file
101
src/main/services/download/download-manager.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { Game } from "@main/entity";
|
||||
import { Downloader } from "@shared";
|
||||
import { TorrentDownloader } from "./torrent-downloader";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
||||
import { publishDownloadCompleteNotification } from "../notifications";
|
||||
|
||||
export class DownloadManager {
|
||||
private static currentDownloader: Downloader | null = null;
|
||||
|
||||
public static async watchDownloads() {
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
throw new Error();
|
||||
} else {
|
||||
const status = await TorrentDownloader.getStatus();
|
||||
|
||||
if (status) {
|
||||
const { gameId, progress } = status;
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
});
|
||||
|
||||
if (WindowManager.mainWindow && game) {
|
||||
WindowManager.mainWindow.setProgressBar(
|
||||
progress === 1 ? -1 : progress
|
||||
);
|
||||
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(
|
||||
JSON.stringify({
|
||||
...status,
|
||||
game,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (status.progress === 1 && game) {
|
||||
publishDownloadCompleteNotification(game);
|
||||
|
||||
await downloadQueueRepository.delete({ game });
|
||||
|
||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||
order: {
|
||||
id: "DESC",
|
||||
},
|
||||
relations: {
|
||||
game: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (nextQueueItem) {
|
||||
this.resumeDownload(nextQueueItem.game);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
throw new Error();
|
||||
} else {
|
||||
await TorrentDownloader.pauseDownload();
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
this.currentDownloader = null;
|
||||
}
|
||||
|
||||
static async resumeDownload(game: Game) {
|
||||
if (game.downloader === Downloader.RealDebrid) {
|
||||
throw new Error();
|
||||
} else {
|
||||
TorrentDownloader.resumeDownload(game);
|
||||
this.currentDownloader = Downloader.Torrent;
|
||||
}
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
throw new Error();
|
||||
} else {
|
||||
TorrentDownloader.cancelDownload(gameId);
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
this.currentDownloader = null;
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (game.downloader === Downloader.RealDebrid) {
|
||||
throw new Error();
|
||||
} else {
|
||||
TorrentDownloader.startDownload(game);
|
||||
this.currentDownloader = Downloader.Torrent;
|
||||
}
|
||||
}
|
||||
}
|
13
src/main/services/download/helpers.ts
Normal file
13
src/main/services/download/helpers.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export const calculateETA = (
|
||||
totalLength: number,
|
||||
completedLength: number,
|
||||
speed: number
|
||||
) => {
|
||||
const remainingBytes = totalLength - completedLength;
|
||||
|
||||
if (remainingBytes >= 0 && speed > 0) {
|
||||
return (remainingBytes / speed) * 1000;
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
2
src/main/services/download/index.ts
Normal file
2
src/main/services/download/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from "./download-manager";
|
||||
export * from "./torrent-downloader";
|
|
@ -2,7 +2,6 @@ import path from "node:path";
|
|||
import cp from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { app, dialog } from "electron";
|
||||
import { readPipe, writePipe } from "./fifo";
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "hydra-download-manager",
|
||||
|
@ -11,10 +10,12 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
|||
};
|
||||
|
||||
export const BITTORRENT_PORT = "5881";
|
||||
export const RPC_PORT = "8084";
|
||||
|
||||
const commonArgs = [BITTORRENT_PORT, writePipe.socketPath, readPipe.socketPath];
|
||||
const commonArgs = [BITTORRENT_PORT, RPC_PORT];
|
||||
|
||||
export const startTorrentClient = async (): Promise<cp.ChildProcess> => {
|
||||
export const startTorrentClient = () => {
|
||||
console.log("CALLED");
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
||||
const binaryPath = path.join(
|
||||
|
@ -32,14 +33,10 @@ export const startTorrentClient = async (): Promise<cp.ChildProcess> => {
|
|||
app.quit();
|
||||
}
|
||||
|
||||
const torrentClient = cp.spawn(binaryPath, commonArgs, {
|
||||
return cp.spawn(binaryPath, commonArgs, {
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
await Promise.all([writePipe.createPipe(), readPipe.createPipe()]);
|
||||
|
||||
return torrentClient;
|
||||
} else {
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
|
@ -49,12 +46,8 @@ export const startTorrentClient = async (): Promise<cp.ChildProcess> => {
|
|||
"main.py"
|
||||
);
|
||||
|
||||
const torrentClient = cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||
return cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
await Promise.all([writePipe.createPipe(), readPipe.createPipe()]);
|
||||
|
||||
return torrentClient;
|
||||
}
|
||||
};
|
153
src/main/services/download/torrent-downloader.ts
Normal file
153
src/main/services/download/torrent-downloader.ts
Normal file
|
@ -0,0 +1,153 @@
|
|||
import cp from "node:child_process";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
import { RPC_PORT, startTorrentClient } from "./torrent-client";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { DownloadProgress } from "@types";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { calculateETA } from "./helpers";
|
||||
import axios from "axios";
|
||||
|
||||
enum LibtorrentStatus {
|
||||
CheckingFiles = 1,
|
||||
DownloadingMetadata = 2,
|
||||
Downloading = 3,
|
||||
Finished = 4,
|
||||
Seeding = 5,
|
||||
}
|
||||
|
||||
interface LibtorrentPayload {
|
||||
progress: number;
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
downloadSpeed: number;
|
||||
bytesDownloaded: number;
|
||||
fileSize: number;
|
||||
folderName: string;
|
||||
status: LibtorrentStatus;
|
||||
gameId: number;
|
||||
}
|
||||
|
||||
export class TorrentDownloader {
|
||||
private static torrentClient: cp.ChildProcess | null = null;
|
||||
private static downloadingGameId = -1;
|
||||
private static rpc = axios.create({
|
||||
baseURL: `http://localhost:${RPC_PORT}`,
|
||||
});
|
||||
|
||||
private static spawn() {
|
||||
this.torrentClient = startTorrentClient();
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
if (this.torrentClient) {
|
||||
this.torrentClient.kill();
|
||||
this.torrentClient = null;
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getStatus() {
|
||||
if (!this.torrentClient) this.spawn();
|
||||
if (this.downloadingGameId === -1) return null;
|
||||
|
||||
const response = await this.rpc.get<LibtorrentPayload | null>("/status");
|
||||
|
||||
if (response.data === null) return null;
|
||||
|
||||
try {
|
||||
const {
|
||||
progress,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
folderName,
|
||||
status,
|
||||
gameId,
|
||||
} = response.data;
|
||||
|
||||
this.downloadingGameId = gameId;
|
||||
|
||||
const isDownloadingMetadata =
|
||||
status === LibtorrentStatus.DownloadingMetadata;
|
||||
|
||||
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
||||
|
||||
if (!isDownloadingMetadata) {
|
||||
const update: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
progress,
|
||||
};
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: gameId },
|
||||
{
|
||||
...update,
|
||||
folderName,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (progress === 1 && !isCheckingFiles) {
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
|
||||
return {
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
|
||||
isDownloadingMetadata,
|
||||
isCheckingFiles,
|
||||
progress,
|
||||
gameId,
|
||||
} as DownloadProgress;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
if (!this.torrentClient) this.spawn();
|
||||
|
||||
await this.rpc
|
||||
.post("/action", {
|
||||
action: "pause",
|
||||
game_id: this.downloadingGameId,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
|
||||
static resumeDownload(game: Game) {
|
||||
this.startDownload(game);
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (!this.torrentClient) this.spawn();
|
||||
|
||||
await this.rpc.post("/action", {
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
magnet: game.uri,
|
||||
save_path: game.downloadPath,
|
||||
});
|
||||
|
||||
this.downloadingGameId = game.id;
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
if (!this.torrentClient) this.spawn();
|
||||
|
||||
await this.rpc.post("/action", {
|
||||
action: "cancel",
|
||||
game_id: gameId,
|
||||
});
|
||||
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
}
|
|
@ -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();
|
|
@ -3,7 +3,7 @@ export * from "./steam";
|
|||
export * from "./steam-250";
|
||||
export * from "./steam-grid";
|
||||
export * from "./window-manager";
|
||||
export * from "./download-manager";
|
||||
export * from "./download";
|
||||
export * from "./how-long-to-beat";
|
||||
export * from "./process-watcher";
|
||||
export * from "./main-loop";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { sleep } from "@main/helpers";
|
||||
import { DownloadManager } from "./download-manager";
|
||||
import { DownloadManager } from "./download";
|
||||
import { watchProcesses } from "./process-watcher";
|
||||
|
||||
export const startMainLoop = async () => {
|
||||
|
|
|
@ -32,6 +32,12 @@ export function BottomPanel() {
|
|||
|
||||
const status = useMemo(() => {
|
||||
if (isGameDownloading) {
|
||||
if (lastPacket?.isCheckingFiles)
|
||||
return t("checking_files", {
|
||||
title: lastPacket?.game.title,
|
||||
percentage: progress,
|
||||
});
|
||||
|
||||
if (lastPacket?.isDownloadingMetadata)
|
||||
return t("downloading_metadata", { title: lastPacket?.game.title });
|
||||
|
||||
|
@ -56,6 +62,7 @@ export function BottomPanel() {
|
|||
isGameDownloading,
|
||||
lastPacket?.game,
|
||||
lastPacket?.isDownloadingMetadata,
|
||||
lastPacket?.isCheckingFiles,
|
||||
progress,
|
||||
eta,
|
||||
downloadSpeed,
|
||||
|
|
|
@ -149,6 +149,9 @@ export interface DownloadProgress {
|
|||
numPeers: number;
|
||||
numSeeds: number;
|
||||
isDownloadingMetadata: boolean;
|
||||
isCheckingFiles: boolean;
|
||||
progress: number;
|
||||
gameId: number;
|
||||
game: LibraryGame;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue