mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-25 11:51:02 +00:00
fix: fixing errors with electron dl manager
This commit is contained in:
76
src/main/services/download-manager.ts
Normal file
76
src/main/services/download-manager.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
import type { Game } from "@main/entity";
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
import { writePipe } from "./fifo";
|
||||
import { HTTPDownloader } from "./downloaders";
|
||||
|
||||
export class DownloadManager {
|
||||
private static gameDownloading: Game;
|
||||
|
||||
static async getGame(gameId: number) {
|
||||
return gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
relations: {
|
||||
repack: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async cancelDownload() {
|
||||
if (
|
||||
this.gameDownloading &&
|
||||
this.gameDownloading.downloader === Downloader.Torrent
|
||||
) {
|
||||
writePipe.write({ action: "cancel" });
|
||||
} else {
|
||||
HTTPDownloader.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
if (
|
||||
this.gameDownloading &&
|
||||
this.gameDownloading.downloader === Downloader.Torrent
|
||||
) {
|
||||
writePipe.write({ action: "pause" });
|
||||
} else {
|
||||
HTTPDownloader.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
static async resumeDownload(gameId: number) {
|
||||
const game = await this.getGame(gameId);
|
||||
|
||||
if (game!.downloader === Downloader.Torrent) {
|
||||
writePipe.write({
|
||||
action: "start",
|
||||
game_id: game!.id,
|
||||
magnet: game!.repack.magnet,
|
||||
save_path: game!.downloadPath,
|
||||
});
|
||||
} else {
|
||||
HTTPDownloader.startDownload(game!);
|
||||
}
|
||||
|
||||
this.gameDownloading = game!;
|
||||
}
|
||||
|
||||
static async downloadGame(gameId: number) {
|
||||
const game = await this.getGame(gameId);
|
||||
|
||||
if (game!.downloader === Downloader.Torrent) {
|
||||
writePipe.write({
|
||||
action: "start",
|
||||
game_id: game!.id,
|
||||
magnet: game!.repack.magnet,
|
||||
save_path: game!.downloadPath,
|
||||
});
|
||||
} else {
|
||||
HTTPDownloader.startDownload(game!);
|
||||
}
|
||||
|
||||
this.gameDownloading = game!;
|
||||
}
|
||||
}
|
||||
@@ -1,105 +1,29 @@
|
||||
import { Game, Repack } from "@main/entity";
|
||||
import { writePipe } from "../fifo";
|
||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||
import { RealDebridClient } from "./real-debrid";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { t } from "i18next";
|
||||
import { Notification } from "electron";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { TorrentUpdate } from "./torrent-client";
|
||||
import { HTTPDownloader } from "./http-downloader";
|
||||
import { Unrar } from "../unrar";
|
||||
import { GameStatus } from "@globals";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { app } from "electron";
|
||||
import type { TorrentUpdate } from "./torrent.downloader";
|
||||
|
||||
import { GameStatus, GameStatusHelper } from "@shared";
|
||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||
|
||||
interface DownloadStatus {
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
downloadSpeed: number;
|
||||
timeRemaining: number;
|
||||
numPeers?: number;
|
||||
numSeeds?: number;
|
||||
downloadSpeed?: number;
|
||||
timeRemaining?: number;
|
||||
}
|
||||
|
||||
export class Downloader {
|
||||
private static lastHttpDownloader: HTTPDownloader | null = null;
|
||||
static getGameProgress(game: Game) {
|
||||
if (game.status === GameStatus.CheckingFiles)
|
||||
return game.fileVerificationProgress;
|
||||
|
||||
static async usesRealDebrid() {
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
return userPreferences!.realDebridApiToken !== null;
|
||||
}
|
||||
|
||||
static async cancelDownload() {
|
||||
if (!(await this.usesRealDebrid())) {
|
||||
writePipe.write({ action: "cancel" });
|
||||
} else {
|
||||
if (this.lastHttpDownloader) {
|
||||
this.lastHttpDownloader.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
if (!(await this.usesRealDebrid())) {
|
||||
writePipe.write({ action: "pause" });
|
||||
} else {
|
||||
if (this.lastHttpDownloader) {
|
||||
this.lastHttpDownloader.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async resumeDownload() {
|
||||
if (!(await this.usesRealDebrid())) {
|
||||
writePipe.write({ action: "pause" });
|
||||
} else {
|
||||
if (this.lastHttpDownloader) {
|
||||
this.lastHttpDownloader.resume();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async downloadGame(game: Game, repack: Repack) {
|
||||
if (!(await this.usesRealDebrid())) {
|
||||
writePipe.write({
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
magnet: repack.magnet,
|
||||
save_path: game.downloadPath,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
// Lets try first to find the torrent on RealDebrid
|
||||
const torrents = await RealDebridClient.getAllTorrents();
|
||||
const hash = RealDebridClient.extractSHA1FromMagnet(repack.magnet);
|
||||
let torrent = torrents.find((t) => t.hash === hash);
|
||||
|
||||
if (!torrent) {
|
||||
// Torrent is missing, lets add it
|
||||
const magnet = await RealDebridClient.addMagnet(repack.magnet);
|
||||
if (magnet && magnet.id) {
|
||||
await RealDebridClient.selectAllFiles(magnet.id);
|
||||
torrent = await RealDebridClient.getInfo(magnet.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (torrent) {
|
||||
const { links } = torrent;
|
||||
const { download } = await RealDebridClient.unrestrictLink(links[0]);
|
||||
this.lastHttpDownloader = new HTTPDownloader();
|
||||
this.lastHttpDownloader.download(
|
||||
download,
|
||||
game.downloadPath!,
|
||||
game.id
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return game.progress;
|
||||
}
|
||||
|
||||
static async updateGameProgress(
|
||||
@@ -110,23 +34,17 @@ export class Downloader {
|
||||
await gameRepository.update({ id: gameId }, gameUpdate);
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: gameId },
|
||||
where: { id: gameId, isDeleted: false },
|
||||
relations: { repack: true },
|
||||
});
|
||||
|
||||
if (
|
||||
game?.progress === 1 &&
|
||||
gameUpdate.status !== GameStatus.Decompressing
|
||||
) {
|
||||
if (game?.progress === 1) {
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (userPreferences?.downloadNotificationsEnabled) {
|
||||
const iconPath = await this.createTempIcon(game.iconUrl);
|
||||
|
||||
new Notification({
|
||||
icon: iconPath,
|
||||
title: t("download_complete", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences.language,
|
||||
@@ -140,26 +58,6 @@ export class Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
game &&
|
||||
gameUpdate.decompressionProgress === 0 &&
|
||||
gameUpdate.status === GameStatus.Decompressing
|
||||
) {
|
||||
const unrar = await Unrar.fromFilePath(
|
||||
game.rarPath!,
|
||||
path.join(game.downloadPath!, game.folderName!)
|
||||
);
|
||||
unrar.extract();
|
||||
this.updateGameProgress(
|
||||
gameId,
|
||||
{
|
||||
decompressionProgress: 1,
|
||||
status: GameStatus.Finished,
|
||||
},
|
||||
downloadStatus
|
||||
);
|
||||
}
|
||||
|
||||
if (WindowManager.mainWindow && game) {
|
||||
const progress = this.getGameProgress(game);
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
@@ -184,31 +82,4 @@ export class Downloader {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static getGameProgress(game: Game) {
|
||||
if (game.status === GameStatus.CheckingFiles)
|
||||
return game.fileVerificationProgress;
|
||||
if (game.status === GameStatus.Decompressing)
|
||||
return game.decompressionProgress;
|
||||
return game.progress;
|
||||
}
|
||||
|
||||
private static createTempIcon(encodedIcon: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.randomBytes(16).toString("hex");
|
||||
const iconPath = path.join(app.getPath("temp"), `${hash}.png`);
|
||||
|
||||
fs.writeFile(
|
||||
iconPath,
|
||||
Buffer.from(
|
||||
encodedIcon.replace("data:image/jpeg;base64,", ""),
|
||||
"base64"
|
||||
),
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
resolve(iconPath);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Game } from "@main/entity";
|
||||
import { ElectronDownloadManager } from "electron-dl-manager";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { Downloader } from "./downloader";
|
||||
import { GameStatus } from "@globals";
|
||||
|
||||
function dropExtension(fileName: string) {
|
||||
return fileName.split(".").slice(0, -1).join(".");
|
||||
}
|
||||
|
||||
export class HTTPDownloader {
|
||||
private downloadManager: ElectronDownloadManager;
|
||||
private downloadId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.downloadManager = new ElectronDownloadManager();
|
||||
}
|
||||
|
||||
async download(url: string, destination: string, gameId: number) {
|
||||
const window = WindowManager.mainWindow;
|
||||
|
||||
this.downloadId = await this.downloadManager.download({
|
||||
url,
|
||||
window: window!,
|
||||
callbacks: {
|
||||
onDownloadStarted: async (ev) => {
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
status: GameStatus.Downloading,
|
||||
progress: 0,
|
||||
bytesDownloaded: 0,
|
||||
fileSize: ev.item.getTotalBytes(),
|
||||
rarPath: `${destination}/.rd/${ev.resolvedFilename}`,
|
||||
folderName: dropExtension(ev.resolvedFilename),
|
||||
};
|
||||
const downloadStatus = {
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
downloadSpeed: 0,
|
||||
timeRemaining: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
await Downloader.updateGameProgress(
|
||||
gameId,
|
||||
updatePayload,
|
||||
downloadStatus
|
||||
);
|
||||
},
|
||||
onDownloadCompleted: async (ev) => {
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
progress: 1,
|
||||
decompressionProgress: 0,
|
||||
bytesDownloaded: ev.item.getReceivedBytes(),
|
||||
status: GameStatus.Decompressing,
|
||||
};
|
||||
const downloadStatus = {
|
||||
numPeers: 1,
|
||||
numSeeds: 1,
|
||||
downloadSpeed: 0,
|
||||
timeRemaining: 0,
|
||||
};
|
||||
await Downloader.updateGameProgress(
|
||||
gameId,
|
||||
updatePayload,
|
||||
downloadStatus
|
||||
);
|
||||
},
|
||||
onDownloadProgress: async (ev) => {
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
progress: ev.percentCompleted / 100,
|
||||
bytesDownloaded: ev.item.getReceivedBytes(),
|
||||
};
|
||||
const downloadStatus = {
|
||||
numPeers: 1,
|
||||
numSeeds: 1,
|
||||
downloadSpeed: ev.downloadRateBytesPerSecond,
|
||||
timeRemaining: ev.estimatedTimeRemainingSeconds,
|
||||
};
|
||||
await Downloader.updateGameProgress(
|
||||
gameId,
|
||||
updatePayload,
|
||||
downloadStatus
|
||||
);
|
||||
},
|
||||
},
|
||||
directory: `${destination}/.rd/`,
|
||||
});
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.downloadId) {
|
||||
this.downloadManager.pauseDownload(this.downloadId);
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this.downloadId) {
|
||||
this.downloadManager.cancelDownload(this.downloadId);
|
||||
}
|
||||
}
|
||||
|
||||
resume() {
|
||||
if (this.downloadId) {
|
||||
this.downloadManager.resumeDownload(this.downloadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/main/services/downloaders/http.downloader.ts
Normal file
101
src/main/services/downloaders/http.downloader.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Game } from "@main/entity";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import path from "node:path";
|
||||
import EasyDL from "easydl";
|
||||
import { GameStatus } from "@shared";
|
||||
|
||||
import { Downloader } from "./downloader";
|
||||
import { RealDebridClient } from "../real-debrid";
|
||||
|
||||
export class HTTPDownloader extends Downloader {
|
||||
private static download: EasyDL;
|
||||
private static downloadSize = 0;
|
||||
|
||||
private static getEta(bytesDownloaded: number, speed: number) {
|
||||
const remainingBytes = this.downloadSize - bytesDownloaded;
|
||||
|
||||
if (remainingBytes >= 0 && speed > 0) {
|
||||
return (remainingBytes / speed) * 1000;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static async getDownloadUrl(game: Game) {
|
||||
const torrents = await RealDebridClient.getAllTorrentsFromUser();
|
||||
const hash = RealDebridClient.extractSHA1FromMagnet(game!.repack.magnet);
|
||||
let torrent = torrents.find((t) => t.hash === hash);
|
||||
|
||||
if (!torrent) {
|
||||
const magnet = await RealDebridClient.addMagnet(game!.repack.magnet);
|
||||
|
||||
if (magnet && magnet.id) {
|
||||
await RealDebridClient.selectAllFiles(magnet.id);
|
||||
torrent = await RealDebridClient.getInfo(magnet.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (torrent) {
|
||||
const { links } = torrent;
|
||||
const { download } = await RealDebridClient.unrestrictLink(links[0]);
|
||||
|
||||
if (!download) {
|
||||
throw new Error("Torrent not cached on Real Debrid");
|
||||
}
|
||||
|
||||
return download;
|
||||
}
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (this.download) this.download.destroy();
|
||||
const download = await this.getDownloadUrl(game);
|
||||
|
||||
this.download = new EasyDL(
|
||||
download,
|
||||
path.join(game.downloadPath!, game.repack.title)
|
||||
);
|
||||
|
||||
const metadata = await this.download.metadata();
|
||||
|
||||
this.downloadSize = metadata.size;
|
||||
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
status: GameStatus.Downloading,
|
||||
fileSize: metadata.size,
|
||||
folderName: game.repack.title,
|
||||
};
|
||||
|
||||
const downloadStatus = {
|
||||
timeRemaining: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
|
||||
await this.updateGameProgress(game.id, updatePayload, downloadStatus);
|
||||
|
||||
this.download.on("progress", async ({ total }) => {
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
status:
|
||||
total.percentage === 100
|
||||
? GameStatus.Finished
|
||||
: GameStatus.Downloading,
|
||||
progress: total.percentage / 100,
|
||||
bytesDownloaded: total.bytes,
|
||||
};
|
||||
|
||||
const downloadStatus = {
|
||||
downloadSpeed: total.speed,
|
||||
timeRemaining: this.getEta(total.bytes ?? 0, total.speed ?? 0),
|
||||
};
|
||||
|
||||
await this.updateGameProgress(game.id, updatePayload, downloadStatus);
|
||||
});
|
||||
}
|
||||
|
||||
static destroy() {
|
||||
if (this.download) {
|
||||
this.download.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/main/services/downloaders/index.ts
Normal file
2
src/main/services/downloaders/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./http.downloader";
|
||||
export * from "./torrent.downloader";
|
||||
@@ -1,74 +0,0 @@
|
||||
import { userPreferencesRepository } from "@main/repository";
|
||||
import {
|
||||
RealDebridAddMagnet,
|
||||
RealDebridTorrentInfo,
|
||||
RealDebridUnrestrictLink,
|
||||
} from "./real-debrid-types";
|
||||
|
||||
const base = "https://api.real-debrid.com/rest/1.0";
|
||||
|
||||
export class RealDebridClient {
|
||||
static async addMagnet(magnet: string) {
|
||||
const response = await fetch(`${base}/torrents/addMagnet`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await this.getApiToken()}`,
|
||||
},
|
||||
body: `magnet=${encodeURIComponent(magnet)}`,
|
||||
});
|
||||
|
||||
return response.json() as Promise<RealDebridAddMagnet>;
|
||||
}
|
||||
|
||||
static async getInfo(id: string) {
|
||||
const response = await fetch(`${base}/torrents/info/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await this.getApiToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.json() as Promise<RealDebridTorrentInfo>;
|
||||
}
|
||||
|
||||
static async selectAllFiles(id: string) {
|
||||
await fetch(`${base}/torrents/selectFiles/${id}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await this.getApiToken()}`,
|
||||
},
|
||||
body: "files=all",
|
||||
});
|
||||
}
|
||||
|
||||
static async unrestrictLink(link: string) {
|
||||
const response = await fetch(`${base}/unrestrict/link`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await this.getApiToken()}`,
|
||||
},
|
||||
body: `link=${link}`,
|
||||
});
|
||||
|
||||
return response.json() as Promise<RealDebridUnrestrictLink>;
|
||||
}
|
||||
|
||||
static async getAllTorrents() {
|
||||
const response = await fetch(`${base}/torrents`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await this.getApiToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.json() as Promise<RealDebridTorrentInfo[]>;
|
||||
}
|
||||
|
||||
static getApiToken() {
|
||||
return userPreferencesRepository
|
||||
.findOne({ where: { id: 1 } })
|
||||
.then((userPreferences) => userPreferences!.realDebridApiToken);
|
||||
}
|
||||
|
||||
static extractSHA1FromMagnet(magnet: string) {
|
||||
return magnet.match(/btih:([0-9a-fA-F]*)/)?.[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import * as Sentry from "@sentry/electron/main";
|
||||
import { app, dialog } from "electron";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
import { Downloader } from "./downloader";
|
||||
import { GameStatus } from "@globals";
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "hydra-download-manager",
|
||||
linux: "hydra-download-manager",
|
||||
win32: "hydra-download-manager.exe",
|
||||
};
|
||||
|
||||
enum TorrentState {
|
||||
CheckingFiles = 1,
|
||||
DownloadingMetadata = 2,
|
||||
Downloading = 3,
|
||||
Finished = 4,
|
||||
Seeding = 5,
|
||||
}
|
||||
|
||||
export interface TorrentUpdate {
|
||||
gameId: number;
|
||||
progress: number;
|
||||
downloadSpeed: number;
|
||||
timeRemaining: number;
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
status: TorrentState;
|
||||
folderName: string;
|
||||
fileSize: number;
|
||||
bytesDownloaded: number;
|
||||
}
|
||||
|
||||
export const BITTORRENT_PORT = "5881";
|
||||
|
||||
export class TorrentClient {
|
||||
public static startTorrentClient(
|
||||
writePipePath: string,
|
||||
readPipePath: string
|
||||
) {
|
||||
const commonArgs = [BITTORRENT_PORT, writePipePath, readPipePath];
|
||||
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
||||
const binaryPath = path.join(
|
||||
process.resourcesPath,
|
||||
"hydra-download-manager",
|
||||
binaryName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
dialog.showErrorBox(
|
||||
"Fatal",
|
||||
"Hydra download manager binary not found. Please check if it has been removed by Windows Defender."
|
||||
);
|
||||
|
||||
app.quit();
|
||||
}
|
||||
|
||||
cp.spawn(binaryPath, commonArgs, {
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"torrent-client",
|
||||
"main.py"
|
||||
);
|
||||
|
||||
cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
private static getTorrentStateName(state: TorrentState) {
|
||||
if (state === TorrentState.CheckingFiles) return GameStatus.CheckingFiles;
|
||||
if (state === TorrentState.Downloading) return GameStatus.Downloading;
|
||||
if (state === TorrentState.DownloadingMetadata)
|
||||
return GameStatus.DownloadingMetadata;
|
||||
if (state === TorrentState.Finished) return GameStatus.Finished;
|
||||
if (state === TorrentState.Seeding) return GameStatus.Seeding;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async onSocketData(data: Buffer) {
|
||||
const message = Buffer.from(data).toString("utf-8");
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(message) as TorrentUpdate;
|
||||
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded: payload.bytesDownloaded,
|
||||
status: this.getTorrentStateName(payload.status),
|
||||
};
|
||||
|
||||
if (payload.status === TorrentState.CheckingFiles) {
|
||||
updatePayload.fileVerificationProgress = payload.progress;
|
||||
} else {
|
||||
if (payload.folderName) {
|
||||
updatePayload.folderName = payload.folderName;
|
||||
updatePayload.fileSize = payload.fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
[TorrentState.Downloading, TorrentState.Seeding].includes(
|
||||
payload.status
|
||||
)
|
||||
) {
|
||||
updatePayload.progress = payload.progress;
|
||||
}
|
||||
|
||||
Downloader.updateGameProgress(payload.gameId, updatePayload, {
|
||||
numPeers: payload.numPeers,
|
||||
numSeeds: payload.numSeeds,
|
||||
downloadSpeed: payload.downloadSpeed,
|
||||
timeRemaining: payload.timeRemaining,
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
src/main/services/downloaders/torrent.downloader.ts
Normal file
160
src/main/services/downloaders/torrent.downloader.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import * as Sentry from "@sentry/electron/main";
|
||||
import { app, dialog } from "electron";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
import { GameStatus } from "@shared";
|
||||
import { Downloader } from "./downloader";
|
||||
import { readPipe, writePipe } from "../fifo";
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "hydra-download-manager",
|
||||
linux: "hydra-download-manager",
|
||||
win32: "hydra-download-manager.exe",
|
||||
};
|
||||
|
||||
enum TorrentState {
|
||||
CheckingFiles = 1,
|
||||
DownloadingMetadata = 2,
|
||||
Downloading = 3,
|
||||
Finished = 4,
|
||||
Seeding = 5,
|
||||
}
|
||||
|
||||
export interface TorrentUpdate {
|
||||
gameId: number;
|
||||
progress: number;
|
||||
downloadSpeed: number;
|
||||
timeRemaining: number;
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
status: TorrentState;
|
||||
folderName: string;
|
||||
fileSize: number;
|
||||
bytesDownloaded: number;
|
||||
}
|
||||
|
||||
export const BITTORRENT_PORT = "5881";
|
||||
|
||||
export class TorrentDownloader extends Downloader {
|
||||
private static messageLength = 1024 * 2;
|
||||
|
||||
public static async attachListener() {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const buffer = readPipe.socket?.read(this.messageLength);
|
||||
|
||||
if (buffer === null) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = Buffer.from(
|
||||
buffer.slice(0, buffer.indexOf(0x00))
|
||||
).toString("utf-8");
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(message) as TorrentUpdate;
|
||||
|
||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded: payload.bytesDownloaded,
|
||||
status: this.getTorrentStateName(payload.status),
|
||||
};
|
||||
|
||||
if (payload.status === TorrentState.CheckingFiles) {
|
||||
updatePayload.fileVerificationProgress = payload.progress;
|
||||
} else {
|
||||
if (payload.folderName) {
|
||||
updatePayload.folderName = payload.folderName;
|
||||
updatePayload.fileSize = payload.fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
[TorrentState.Downloading, TorrentState.Seeding].includes(
|
||||
payload.status
|
||||
)
|
||||
) {
|
||||
updatePayload.progress = payload.progress;
|
||||
}
|
||||
|
||||
this.updateGameProgress(payload.gameId, updatePayload, {
|
||||
numPeers: payload.numPeers,
|
||||
numSeeds: payload.numSeeds,
|
||||
downloadSpeed: payload.downloadSpeed,
|
||||
timeRemaining: payload.timeRemaining,
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
} finally {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static startClient() {
|
||||
return new Promise((resolve) => {
|
||||
const commonArgs = [
|
||||
BITTORRENT_PORT,
|
||||
writePipe.socketPath,
|
||||
readPipe.socketPath,
|
||||
];
|
||||
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
||||
const binaryPath = path.join(
|
||||
process.resourcesPath,
|
||||
"hydra-download-manager",
|
||||
binaryName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
dialog.showErrorBox(
|
||||
"Fatal",
|
||||
"Hydra download manager binary not found. Please check if it has been removed by Windows Defender."
|
||||
);
|
||||
|
||||
app.quit();
|
||||
}
|
||||
|
||||
cp.spawn(binaryPath, commonArgs, {
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"torrent-client",
|
||||
"main.py"
|
||||
);
|
||||
|
||||
cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(
|
||||
async () => {
|
||||
this.attachListener();
|
||||
resolve(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private static getTorrentStateName(state: TorrentState) {
|
||||
if (state === TorrentState.CheckingFiles) return GameStatus.CheckingFiles;
|
||||
if (state === TorrentState.Downloading) return GameStatus.Downloading;
|
||||
if (state === TorrentState.DownloadingMetadata)
|
||||
return GameStatus.DownloadingMetadata;
|
||||
if (state === TorrentState.Finished) return GameStatus.Finished;
|
||||
if (state === TorrentState.Seeding) return GameStatus.Seeding;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export * from "./steam-grid";
|
||||
export * from "./update-resolver";
|
||||
export * from "./window-manager";
|
||||
export * from "./fifo";
|
||||
export * from "./downloaders/torrent-client";
|
||||
export * from "./downloaders";
|
||||
export * from "./download-manager";
|
||||
export * from "./how-long-to-beat";
|
||||
export * from "./process-watcher";
|
||||
|
||||
@@ -16,6 +16,7 @@ export const startProcessWatcher = async () => {
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
executablePath: Not(IsNull()),
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
73
src/main/services/real-debrid.ts
Normal file
73
src/main/services/real-debrid.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
RealDebridAddMagnet,
|
||||
RealDebridTorrentInfo,
|
||||
RealDebridUnrestrictLink,
|
||||
} from "./real-debrid.types";
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
|
||||
const base = "https://api.real-debrid.com/rest/1.0";
|
||||
|
||||
export class RealDebridClient {
|
||||
private static instance: AxiosInstance;
|
||||
|
||||
static async addMagnet(magnet: string) {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("magnet", magnet);
|
||||
|
||||
const response = await this.instance.post<RealDebridAddMagnet>(
|
||||
"/torrents/addMagnet",
|
||||
searchParams.toString()
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async getInfo(id: string) {
|
||||
const response = await this.instance.get<RealDebridTorrentInfo>(
|
||||
`/torrents/info/${id}`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async selectAllFiles(id: string) {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("files", "all");
|
||||
|
||||
await this.instance.post(
|
||||
`/torrents/selectFiles/${id}`,
|
||||
searchParams.toString()
|
||||
);
|
||||
}
|
||||
|
||||
static async unrestrictLink(link: string) {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("link", link);
|
||||
|
||||
const response = await this.instance.post<RealDebridUnrestrictLink>(
|
||||
"/unrestrict/link",
|
||||
searchParams.toString()
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async getAllTorrentsFromUser() {
|
||||
const response =
|
||||
await this.instance.get<RealDebridTorrentInfo[]>("/torrents");
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static extractSHA1FromMagnet(magnet: string) {
|
||||
return magnet.match(/btih:([0-9a-fA-F]*)/)?.[1].toLowerCase();
|
||||
}
|
||||
|
||||
static async authorize(apiToken: string) {
|
||||
this.instance = axios.create({
|
||||
baseURL: base,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export interface RealDebridTorrentInfo {
|
||||
host: string; // Host main domain
|
||||
split: number; // Split size of links
|
||||
progress: number; // Possible values: 0 to 100
|
||||
status: "downloaded"; // Current status of the torrent: magnet_error, magnet_conversion, waiting_files_selection, queued, downloading, downloaded, error, virus, compressing, uploading, dead
|
||||
status: string; // Current status of the torrent: magnet_error, magnet_conversion, waiting_files_selection, queued, downloading, downloaded, error, virus, compressing, uploading, dead
|
||||
added: string; // jsonDate
|
||||
files: [
|
||||
{
|
||||
@@ -44,9 +44,7 @@ export interface RealDebridTorrentInfo {
|
||||
selected: number; // 0 or 1
|
||||
},
|
||||
];
|
||||
links: [
|
||||
"string", // Host URL
|
||||
];
|
||||
links: string[];
|
||||
ended: string; // !! Only present when finished, jsonDate
|
||||
speed: number; // !! Only present in "downloading", "compressing", "uploading" status
|
||||
seeders: number; // !! Only present in "downloading", "magnet_conversion" status
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Extractor, createExtractorFromFile } from "node-unrar-js";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
|
||||
const wasmPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "unrar.wasm")
|
||||
: path.join(__dirname, "..", "..", "unrar.wasm");
|
||||
|
||||
const wasmBinary = fs.readFileSync(require.resolve(wasmPath));
|
||||
|
||||
export class Unrar {
|
||||
private constructor(private extractor: Extractor<Uint8Array>) {}
|
||||
|
||||
static async fromFilePath(filePath: string, targetFolder: string) {
|
||||
const extractor = await createExtractorFromFile({
|
||||
filepath: filePath,
|
||||
targetPath: targetFolder,
|
||||
wasmBinary,
|
||||
});
|
||||
return new Unrar(extractor);
|
||||
}
|
||||
|
||||
extract() {
|
||||
const files = this.extractor.extract().files;
|
||||
for (const file of files) {
|
||||
console.log("File:", file.fileHeader.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user