feat: adding aria2c

This commit is contained in:
Chubby Granny Chaser 2024-11-28 19:09:13 +00:00
parent 2d8b63c803
commit 4060f7a1a6
No known key found for this signature in database
30 changed files with 371 additions and 1351 deletions

View file

@ -1,10 +1,5 @@
import { registerEvent } from "../register-event";
import {
DownloadManager,
HydraApi,
PythonInstance,
gamesPlaytime,
} from "@main/services";
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, UserAuth, UserSubscription } from "@main/entity";
@ -32,7 +27,8 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
DownloadManager.cancelDownload();
/* Disconnects libtorrent */
PythonInstance.killTorrent();
// TODO
// TorrentDownloader.killTorrent();
HydraApi.handleSignOut();

View file

@ -1,6 +1,6 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { PythonInstance, logger } from "@main/services";
import { logger } from "@main/services";
import sudo from "sudo-prompt";
import { app } from "electron";
@ -16,7 +16,8 @@ const closeGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const processes = await PythonInstance.getProcessList();
// const processes = await PythonInstance.getProcessList();
const processes = [];
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});

View file

@ -1,11 +1,11 @@
import { registerEvent } from "../register-event";
import { PythonInstance } from "@main/services";
const processProfileImage = async (
_event: Electron.IpcMainInvokeEvent,
path: string
) => {
return PythonInstance.processProfileImage(path);
return path;
// return PythonInstance.processProfileImage(path);
};
registerEvent("processProfileImage", processProfileImage);

View file

@ -1,4 +1,4 @@
import { RealDebridClient } from "@main/services/real-debrid";
import { RealDebridClient } from "@main/services/download/real-debrid";
import { registerEvent } from "../register-event";
const authenticateRealDebrid = async (

View file

@ -5,12 +5,14 @@ import path from "node:path";
import url from "node:url";
import fs from "node:fs";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { logger, PythonInstance, WindowManager } from "@main/services";
import { logger, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
import resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
import { knexClient, migrationConfig } from "./knex-client";
import { databaseDirectory } from "./constants";
import { PythonRPC } from "./services/python-rpc";
import { Aria2 } from "./services/aria2";
const { autoUpdater } = updater;
@ -146,7 +148,8 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => {
/* Disconnects libtorrent */
PythonInstance.kill();
PythonRPC.kill();
Aria2.kill();
});
app.on("activate", () => {

View file

@ -1,21 +1,20 @@
import {
DownloadManager,
Ludusavi,
PythonInstance,
startMainLoop,
} from "./services";
import { DownloadManager, Ludusavi, startMainLoop } from "./services";
import {
downloadQueueRepository,
userPreferencesRepository,
} from "./repository";
import { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/real-debrid";
import { RealDebridClient } from "./services/download/real-debrid";
import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
import { PythonRPC } from "./services/python-rpc";
import { Aria2 } from "./services/aria2";
const loadState = async (userPreferences: UserPreferences | null) => {
import("./events");
Aria2.spawn();
if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
}
@ -35,11 +34,14 @@ const loadState = async (userPreferences: UserPreferences | null) => {
},
});
if (nextQueueItem?.game.status === "active") {
DownloadManager.startDownload(nextQueueItem.game);
} else {
PythonInstance.spawn();
}
PythonRPC.spawn();
// start download
// if (nextQueueItem?.game.status === "active") {
// DownloadManager.startDownload(nextQueueItem.game);
// } else {
// PythonInstance.spawn();
// }
startMainLoop();
};

View file

@ -1,42 +1,120 @@
import { Game } from "@main/entity";
import { Downloader } from "@shared";
import { PythonInstance } from "./python-instance";
import { WindowManager } from "../window-manager";
import { downloadQueueRepository, gameRepository } from "@main/repository";
import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications";
import { RealDebridDownloader } from "./real-debrid-downloader";
import type { DownloadProgress } from "@types";
import { GofileApi, QiwiApi } from "../hosters";
import { GenericHttpDownloader } from "./generic-http-downloader";
import { In, Not } from "typeorm";
import path from "path";
import fs from "fs";
import { PythonRPC } from "../python-rpc";
import {
LibtorrentPayload,
LibtorrentStatus,
PauseDownloadPayload,
} from "./types";
import { calculateETA } from "./helpers";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { RealDebridClient } from "./real-debrid";
export class DownloadManager {
private static currentDownloader: Downloader | null = null;
private static downloadingGameId: number | null = null;
public static async watchDownloads() {
let status: DownloadProgress | null = null;
private static async getDownloadStatus() {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status"
);
if (this.currentDownloader === Downloader.Torrent) {
status = await PythonInstance.getStatus();
} else if (this.currentDownloader === Downloader.RealDebrid) {
status = await RealDebridDownloader.getStatus();
} else {
status = await GenericHttpDownloader.getStatus();
if (response.data === null || !this.downloadingGameId) return null;
const gameId = this.downloadingGameId;
try {
const {
progress,
numPeers,
numSeeds,
downloadSpeed,
bytesDownloaded,
fileSize,
folderName,
status,
} = response.data;
const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata;
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
if (!isDownloadingMetadata && !isCheckingFiles) {
const update: QueryDeepPartialEntity<Game> = {
bytesDownloaded,
fileSize,
progress,
status: "active",
};
await gameRepository.update(
{ id: gameId },
{
...update,
folderName,
}
);
}
if (progress === 1 && !isCheckingFiles) {
const userPreferences = await userPreferencesRepository.findOneBy({
id: 1,
});
if (userPreferences?.seedAfterDownloadComplete) {
gameRepository.update(
{ id: gameId },
{ status: "seeding", shouldSeed: true }
);
} else {
gameRepository.update(
{ id: gameId },
{ status: "complete", shouldSeed: false }
);
this.pauseSeeding(gameId);
}
this.downloadingGameId = -1;
}
return {
numPeers,
numSeeds,
downloadSpeed,
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
isDownloadingMetadata,
isCheckingFiles,
progress,
gameId,
} as DownloadProgress;
} catch (err) {
return null;
}
}
public static async watchDownloads() {
const status = await this.getDownloadStatus();
// // status = await RealDebridDownloader.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(
@ -47,12 +125,9 @@ export class DownloadManager {
)
);
}
if (progress === 1 && game) {
publishDownloadCompleteNotification(game);
await downloadQueueRepository.delete({ game });
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
@ -61,7 +136,6 @@ export class DownloadManager {
game: true,
},
});
if (nextQueueItem) {
this.resumeDownload(nextQueueItem.game);
}
@ -70,88 +144,80 @@ export class DownloadManager {
}
public static async getSeedStatus() {
const gamesToSeed = await gameRepository.find({
where: { shouldSeed: true, isDeleted: false },
});
if (gamesToSeed.length === 0) return;
const seedStatus = await PythonInstance.getSeedStatus();
if (seedStatus.length === 0) {
for (const game of gamesToSeed) {
if (game.uri && game.downloadPath) {
await this.resumeSeeding(game.id, game.uri, game.downloadPath);
}
}
}
const gameIds = seedStatus.map((status) => status.gameId);
for (const gameId of gameIds) {
const game = await gameRepository.findOne({
where: { id: gameId },
});
if (game) {
const isNotDeleted = fs.existsSync(
path.join(game.downloadPath!, game.folderName!)
);
if (!isNotDeleted) {
await this.pauseSeeding(game.id);
await gameRepository.update(game.id, {
status: "complete",
shouldSeed: false,
});
WindowManager.mainWindow?.webContents.send("on-hard-delete");
}
}
}
const updateList = await gameRepository.find({
where: {
id: In(gameIds),
status: Not(In(["complete", "seeding"])),
shouldSeed: true,
isDeleted: false,
},
});
if (updateList.length > 0) {
await gameRepository.update(
{ id: In(updateList.map((game) => game.id)) },
{ status: "seeding" }
);
}
WindowManager.mainWindow?.webContents.send(
"on-seeding-status",
JSON.parse(JSON.stringify(seedStatus))
);
// const gamesToSeed = await gameRepository.find({
// where: { shouldSeed: true, isDeleted: false },
// });
// if (gamesToSeed.length === 0) return;
// const seedStatus = await PythonRPC.rpc
// .get<LibtorrentPayload[] | null>("/seed-status")
// .then((results) => {
// if (results === null) return [];
// return results.data;
// });
// if (!seedStatus.length === 0) {
// for (const game of gamesToSeed) {
// if (game.uri && game.downloadPath) {
// await this.resumeSeeding(game.id, game.uri, game.downloadPath);
// }
// }
// }
// const gameIds = seedStatus.map((status) => status.gameId);
// for (const gameId of gameIds) {
// const game = await gameRepository.findOne({
// where: { id: gameId },
// });
// if (game) {
// const isNotDeleted = fs.existsSync(
// path.join(game.downloadPath!, game.folderName!)
// );
// if (!isNotDeleted) {
// await this.pauseSeeding(game.id);
// await gameRepository.update(game.id, {
// status: "complete",
// shouldSeed: false,
// });
// WindowManager.mainWindow?.webContents.send("on-hard-delete");
// }
// }
// }
// const updateList = await gameRepository.find({
// where: {
// id: In(gameIds),
// status: Not(In(["complete", "seeding"])),
// shouldSeed: true,
// isDeleted: false,
// },
// });
// if (updateList.length > 0) {
// await gameRepository.update(
// { id: In(updateList.map((game) => game.id)) },
// { status: "seeding" }
// );
// }
// WindowManager.mainWindow?.webContents.send(
// "on-seeding-status",
// JSON.parse(JSON.stringify(seedStatus))
// );
}
static async pauseSeeding(gameId: number) {
await PythonInstance.pauseSeeding(gameId);
// await TorrentDownloader.pauseSeeding(gameId);
}
static async resumeSeeding(gameId: number, magnet: string, savePath: string) {
await PythonInstance.resumeSeeding(gameId, magnet, savePath);
// await TorrentDownloader.resumeSeeding(gameId, magnet, savePath);
}
static async pauseDownload() {
if (this.currentDownloader === Downloader.Torrent) {
await PythonInstance.pauseDownload();
} else if (this.currentDownloader === Downloader.RealDebrid) {
await RealDebridDownloader.pauseDownload();
} else {
await GenericHttpDownloader.pauseDownload();
}
await PythonRPC.rpc
.post("/action", {
action: "pause",
game_id: this.downloadingGameId,
} as PauseDownloadPayload)
.catch(() => {});
WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null;
this.downloadingGameId = null;
}
@ -160,16 +226,13 @@ export class DownloadManager {
}
static async cancelDownload(gameId = this.downloadingGameId!) {
if (this.currentDownloader === Downloader.Torrent) {
PythonInstance.cancelDownload(gameId);
} else if (this.currentDownloader === Downloader.RealDebrid) {
RealDebridDownloader.cancelDownload(gameId);
} else {
GenericHttpDownloader.cancelDownload(gameId);
}
await PythonRPC.rpc.post("/action", {
action: "cancel",
game_id: gameId,
});
WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null;
this.downloadingGameId = null;
}
@ -181,34 +244,57 @@ export class DownloadManager {
const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!);
GenericHttpDownloader.startDownload(game, downloadLink, {
Cookie: `accountToken=${token}`,
await PythonRPC.rpc.post("/action", {
action: "start",
game_id: game.id,
url: downloadLink,
save_path: game.downloadPath,
header: `Cookie: accountToken=${token}`,
});
break;
}
case Downloader.PixelDrain: {
const id = game!.uri!.split("/").pop();
await GenericHttpDownloader.startDownload(
game,
`https://pixeldrain.com/api/file/${id}?download`
);
await PythonRPC.rpc.post("/action", {
action: "start",
game_id: game.id,
url: `https://pixeldrain.com/api/file/${id}?download`,
save_path: game.downloadPath,
});
break;
}
case Downloader.Qiwi: {
const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!);
await GenericHttpDownloader.startDownload(game, downloadUrl);
await PythonRPC.rpc.post("/action", {
action: "start",
game_id: game.id,
url: downloadUrl,
save_path: game.downloadPath,
});
break;
}
case Downloader.Torrent:
PythonInstance.startDownload(game);
await PythonRPC.rpc.post("/action", {
action: "start",
game_id: game.id,
url: game.uri,
save_path: game.downloadPath,
});
break;
case Downloader.RealDebrid:
RealDebridDownloader.startDownload(game);
case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl(game.uri!);
await PythonRPC.rpc.post("/action", {
action: "start",
game_id: game.id,
url: downloadUrl,
save_path: game.downloadPath,
});
}
}
this.currentDownloader = game.downloader;
this.downloadingGameId = game.id;
}
}

View file

@ -1,109 +0,0 @@
import { Game } from "@main/entity";
import { gameRepository } from "@main/repository";
import { calculateETA } from "./helpers";
import { DownloadProgress } from "@types";
import { HttpDownload } from "./http-download";
export class GenericHttpDownloader {
public static downloads = new Map<number, HttpDownload>();
public static downloadingGame: Game | null = null;
public static async getStatus() {
if (this.downloadingGame) {
const download = this.downloads.get(this.downloadingGame.id)!;
const status = download.getStatus();
if (status) {
const progress =
Number(status.completedLength) / Number(status.totalLength);
await gameRepository.update(
{ id: this.downloadingGame!.id },
{
bytesDownloaded: Number(status.completedLength),
fileSize: Number(status.totalLength),
progress,
status: "active",
folderName: status.folderName,
}
);
const result = {
numPeers: 0,
numSeeds: 0,
downloadSpeed: status.downloadSpeed,
timeRemaining: calculateETA(
status.totalLength,
status.completedLength,
status.downloadSpeed
),
isDownloadingMetadata: false,
isCheckingFiles: false,
progress,
gameId: this.downloadingGame!.id,
} as DownloadProgress;
if (progress === 1) {
this.downloads.delete(this.downloadingGame.id);
this.downloadingGame = null;
}
return result;
}
}
return null;
}
static async pauseDownload() {
if (this.downloadingGame) {
const httpDownload = this.downloads.get(this.downloadingGame!.id!);
if (httpDownload) {
await httpDownload.pauseDownload();
}
this.downloadingGame = null;
}
}
static async startDownload(
game: Game,
downloadUrl: string,
headers?: Record<string, string>
) {
this.downloadingGame = game;
if (this.downloads.has(game.id)) {
await this.resumeDownload(game.id!);
return;
}
const httpDownload = new HttpDownload(
game.downloadPath!,
downloadUrl,
headers
);
httpDownload.startDownload();
this.downloads.set(game.id!, httpDownload);
}
static async cancelDownload(gameId: number) {
const httpDownload = this.downloads.get(gameId);
if (httpDownload) {
await httpDownload.cancelDownload();
this.downloads.delete(gameId);
}
}
static async resumeDownload(gameId: number) {
const httpDownload = this.downloads.get(gameId);
if (httpDownload) {
await httpDownload.resumeDownload();
}
}
}

View file

@ -1,54 +0,0 @@
import { WindowManager } from "../window-manager";
import path from "node:path";
export class HttpDownload {
private downloadItem: Electron.DownloadItem;
constructor(
private downloadPath: string,
private downloadUrl: string,
private headers?: Record<string, string>
) {}
public getStatus() {
return {
completedLength: this.downloadItem.getReceivedBytes(),
totalLength: this.downloadItem.getTotalBytes(),
downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(),
folderName: this.downloadItem.getFilename(),
};
}
async cancelDownload() {
this.downloadItem.cancel();
}
async pauseDownload() {
this.downloadItem.pause();
}
async resumeDownload() {
this.downloadItem.resume();
}
async startDownload() {
return new Promise((resolve) => {
const options = this.headers ? { headers: this.headers } : {};
WindowManager.mainWindow?.webContents.downloadURL(
this.downloadUrl,
options
);
WindowManager.mainWindow?.webContents.session.once(
"will-download",
(_event, item, _webContents) => {
this.downloadItem = item;
item.setSavePath(path.join(this.downloadPath, item.getFilename()));
resolve(null);
}
);
});
}
}

View file

@ -1,2 +1 @@
export * from "./download-manager";
export * from "./python-instance";

View file

@ -1,236 +0,0 @@
import cp from "node:child_process";
import { Game } from "@main/entity";
import {
RPC_PASSWORD,
RPC_PORT,
startTorrentClient as startRPCClient,
} from "./torrent-client";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import type { DownloadProgress } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { calculateETA } from "./helpers";
import axios from "axios";
import {
CancelDownloadPayload,
StartDownloadPayload,
PauseDownloadPayload,
LibtorrentStatus,
LibtorrentPayload,
ProcessPayload,
PauseSeedingPayload,
ResumeSeedingPayload,
} from "./types";
import { pythonInstanceLogger as logger } from "../logger";
export class PythonInstance {
private static pythonProcess: cp.ChildProcess | null = null;
private static downloadingGameId = -1;
private static rpc = axios.create({
baseURL: `http://localhost:${RPC_PORT}`,
headers: {
"x-hydra-rpc-password": RPC_PASSWORD,
},
});
public static spawn(args?: StartDownloadPayload) {
logger.log("spawning python process with args:", args);
this.pythonProcess = startRPCClient(args);
}
public static kill() {
if (this.pythonProcess) {
logger.log("killing python process");
this.pythonProcess.kill();
this.pythonProcess = null;
this.downloadingGameId = -1;
}
}
public static killTorrent() {
if (this.pythonProcess) {
logger.log("killing torrent in python process");
this.rpc.post("/action", { action: "kill-torrent" });
this.downloadingGameId = -1;
}
}
public static async getProcessList() {
return (
(await this.rpc.get<ProcessPayload[] | null>("/process-list")).data || []
);
}
public static async getStatus() {
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 && !isCheckingFiles) {
const update: QueryDeepPartialEntity<Game> = {
bytesDownloaded,
fileSize,
progress,
status: "active",
};
await gameRepository.update(
{ id: gameId },
{
...update,
folderName,
}
);
}
if (progress === 1 && !isCheckingFiles) {
const userPreferences = await userPreferencesRepository.findOneBy({
id: 1,
});
if (userPreferences?.seedAfterDownloadComplete) {
gameRepository.update(
{ id: gameId },
{ status: "seeding", shouldSeed: true }
);
} else {
gameRepository.update(
{ id: gameId },
{ status: "complete", shouldSeed: false }
);
this.pauseSeeding(gameId);
}
this.downloadingGameId = -1;
}
return {
numPeers,
numSeeds,
downloadSpeed,
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
isDownloadingMetadata,
isCheckingFiles,
progress,
gameId,
} as DownloadProgress;
} catch (err) {
return null;
}
}
public static async getSeedStatus() {
const response = await this.rpc.get<LibtorrentPayload[] | null>(
"/seed-status"
);
if (response.data === null) return [];
return response.data;
}
static async pauseSeeding(gameId: number) {
await this.rpc
.post("/action", {
action: "pause-seeding",
game_id: gameId,
} as PauseSeedingPayload)
.catch(() => {});
}
static async resumeSeeding(gameId: number, magnet: string, savePath: string) {
await this.rpc
.post("/action", {
action: "resume-seeding",
game_id: gameId,
magnet,
save_path: savePath,
} as ResumeSeedingPayload)
.catch(() => {});
}
static async pauseDownload() {
await this.rpc
.post("/action", {
action: "pause",
game_id: this.downloadingGameId,
} as PauseDownloadPayload)
.catch(() => {});
this.downloadingGameId = -1;
}
static async startDownload(game: Game) {
if (!this.pythonProcess) {
this.spawn({
game_id: game.id,
magnet: game.uri!,
save_path: game.downloadPath!,
});
} else {
await this.rpc
.post("/action", {
action: "start",
game_id: game.id,
magnet: game.uri,
save_path: game.downloadPath,
} as StartDownloadPayload)
.catch(this.handleRpcError);
}
this.downloadingGameId = game.id;
}
static async cancelDownload(gameId: number) {
await this.rpc
.post("/action", {
action: "cancel",
game_id: gameId,
} as CancelDownloadPayload)
.catch(() => {});
this.downloadingGameId = -1;
}
static async processProfileImage(imagePath: string) {
return this.rpc
.post<{ imagePath: string; mimeType: string }>("/profile-image", {
image_path: imagePath,
})
.then((response) => response.data);
}
private static async handleRpcError(_error: unknown) {
await this.rpc.get("/healthcheck").catch(() => {
logger.error(
"RPC healthcheck failed. Killing process and starting again"
);
this.kill();
this.spawn();
});
}
}

View file

@ -1,72 +0,0 @@
import { Game } from "@main/entity";
import { RealDebridClient } from "../real-debrid";
import { HttpDownload } from "./http-download";
import { GenericHttpDownloader } from "./generic-http-downloader";
export class RealDebridDownloader extends GenericHttpDownloader {
private static realDebridTorrentId: string | null = null;
private static async getRealDebridDownloadUrl() {
if (this.realDebridTorrentId) {
let torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
if (torrentInfo.status === "waiting_files_selection") {
await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
}
const { links, status } = torrentInfo;
if (status === "downloaded") {
const [link] = links;
const { download } = await RealDebridClient.unrestrictLink(link);
return decodeURIComponent(download);
}
return null;
}
if (this.downloadingGame?.uri) {
const { download } = await RealDebridClient.unrestrictLink(
this.downloadingGame?.uri
);
return decodeURIComponent(download);
}
return null;
}
static async startDownload(game: Game) {
if (this.downloads.has(game.id)) {
await this.resumeDownload(game.id!);
this.downloadingGame = game;
return;
}
if (game.uri?.startsWith("magnet:")) {
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
game!.uri!
);
}
this.downloadingGame = game;
const downloadUrl = await this.getRealDebridDownloadUrl();
if (downloadUrl) {
this.realDebridTorrentId = null;
const httpDownload = new HttpDownload(game.downloadPath!, downloadUrl);
httpDownload.startDownload();
this.downloads.set(game.id!, httpDownload);
}
}
}

View file

@ -1,77 +0,0 @@
import path from "node:path";
import cp from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import { app, dialog } from "electron";
import type { StartDownloadPayload } from "./types";
import { Readable } from "node:stream";
import { pythonInstanceLogger as logger } from "../logger";
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: "hydra-download-manager",
linux: "hydra-download-manager",
win32: "hydra-download-manager.exe",
};
export const BITTORRENT_PORT = "5881";
export const RPC_PORT = "8084";
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
const logStderr = (readable: Readable | null) => {
if (!readable) return;
readable.setEncoding("utf-8");
readable.on("data", logger.log);
};
export const startTorrentClient = (args?: StartDownloadPayload) => {
const commonArgs = [
BITTORRENT_PORT,
RPC_PORT,
RPC_PASSWORD,
args ? encodeURIComponent(JSON.stringify(args)) : "",
];
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();
}
const childProcess = cp.spawn(binaryPath, commonArgs, {
windowsHide: true,
stdio: ["inherit", "inherit"],
});
logStderr(childProcess.stderr);
return childProcess;
} else {
const scriptPath = path.join(
__dirname,
"..",
"..",
"torrent-client",
"main.py"
);
const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], {
stdio: ["inherit", "inherit"],
});
logStderr(childProcess.stderr);
return childProcess;
}
};

View file

@ -1,9 +1,3 @@
export interface StartDownloadPayload {
game_id: number;
magnet: string;
save_path: string;
}
export interface PauseDownloadPayload {
game_id: number;
}

View file

@ -30,7 +30,7 @@ export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true;
private static readonly ADD_LOG_INTERCEPTOR = false;
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;

View file

@ -1,7 +1,6 @@
export * from "./logger";
export * from "./steam";
export * from "./steam-250";
export * from "./steam-grid";
export * from "./window-manager";
export * from "./download";
export * from "./process-watcher";

View file

@ -3,7 +3,7 @@ import { gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
import type { GameRunning } from "@types";
import { PythonInstance } from "./download";
// import { PythonInstance } from "./download";
import { Game } from "@main/entity";
export const gamesPlaytime = new Map<
@ -14,69 +14,7 @@ export const gamesPlaytime = new Map<
const TICKS_TO_UPDATE_API = 120;
let currentTick = 1;
export const watchProcesses = async () => {
const games = await gameRepository.find({
where: {
executablePath: Not(IsNull()),
isDeleted: false,
},
});
if (games.length === 0) return;
const processes = await PythonInstance.getProcessList();
const processSet = new Set(processes.map((process) => process.exe));
for (const game of games) {
const executablePath = game.executablePath!;
const gameProcess = processSet.has(executablePath);
if (gameProcess) {
if (gamesPlaytime.has(game.id)) {
onTickGame(game);
} else {
onOpenGame(game);
}
} else if (gamesPlaytime.has(game.id)) {
onCloseGame(game);
}
}
currentTick++;
if (WindowManager.mainWindow) {
const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => {
return {
id: entry[0],
sessionDurationInMillis: performance.now() - entry[1].firstTick,
};
});
WindowManager.mainWindow.webContents.send(
"on-games-running",
gamesRunning as Pick<GameRunning, "id" | "sessionDurationInMillis">[]
);
}
};
function onOpenGame(game: Game) {
const now = performance.now();
gamesPlaytime.set(game.id, {
lastTick: now,
firstTick: now,
lastSyncTick: now,
});
if (game.remoteId) {
updateGamePlaytime(game, 0, new Date()).catch(() => {});
} else {
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
}
}
function onTickGame(game: Game) {
const onGameTick = (game: Game) => {
const now = performance.now();
const gamePlaytime = gamesPlaytime.get(game.id)!;
@ -110,7 +48,23 @@ function onTickGame(game: Game) {
})
.catch(() => {});
}
}
};
const onOpenGame = (game: Game) => {
const now = performance.now();
gamesPlaytime.set(game.id, {
lastTick: now,
firstTick: now,
lastSyncTick: now,
});
if (game.remoteId) {
updateGamePlaytime(game, 0, new Date()).catch(() => {});
} else {
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
}
};
const onCloseGame = (game: Game) => {
const gamePlaytime = gamesPlaytime.get(game.id)!;
@ -126,3 +80,50 @@ const onCloseGame = (game: Game) => {
createGame(game).catch(() => {});
}
};
export const watchProcesses = async () => {
const games = await gameRepository.find({
where: {
executablePath: Not(IsNull()),
isDeleted: false,
},
});
if (games.length === 0) return;
// const processes = await PythonInstance.getProcessList();
const processes = [];
const processSet = new Set(processes.map((process) => process.exe));
for (const game of games) {
const executablePath = game.executablePath!;
const gameProcess = processSet.has(executablePath);
if (gameProcess) {
if (gamesPlaytime.has(game.id)) {
onGameTick(game);
} else {
onOpenGame(game);
}
} else if (gamesPlaytime.has(game.id)) {
onCloseGame(game);
}
}
currentTick++;
if (WindowManager.mainWindow) {
const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => {
return {
id: entry[0],
sessionDurationInMillis: performance.now() - entry[1].firstTick,
};
});
WindowManager.mainWindow.webContents.send(
"on-games-running",
gamesRunning as Pick<GameRunning, "id" | "sessionDurationInMillis">[]
);
}
};

View file

@ -1,86 +0,0 @@
import axios, { AxiosInstance } from "axios";
import parseTorrent from "parse-torrent";
import type {
RealDebridAddMagnet,
RealDebridTorrentInfo,
RealDebridUnrestrictLink,
RealDebridUser,
} from "@types";
export class RealDebridClient {
private static instance: AxiosInstance;
private static baseURL = "https://api.real-debrid.com/rest/1.0";
static authorize(apiToken: string) {
this.instance = axios.create({
baseURL: this.baseURL,
headers: {
Authorization: `Bearer ${apiToken}`,
},
});
}
static async addMagnet(magnet: string) {
const searchParams = new URLSearchParams({ magnet });
const response = await this.instance.post<RealDebridAddMagnet>(
"/torrents/addMagnet",
searchParams.toString()
);
return response.data;
}
static async getTorrentInfo(id: string) {
const response = await this.instance.get<RealDebridTorrentInfo>(
`/torrents/info/${id}`
);
return response.data;
}
static async getUser() {
const response = await this.instance.get<RealDebridUser>(`/user`);
return response.data;
}
static async selectAllFiles(id: string) {
const searchParams = new URLSearchParams({ files: "all" });
return this.instance.post(
`/torrents/selectFiles/${id}`,
searchParams.toString()
);
}
static async unrestrictLink(link: string) {
const searchParams = new URLSearchParams({ link });
const response = await this.instance.post<RealDebridUnrestrictLink>(
"/unrestrict/link",
searchParams.toString()
);
return response.data;
}
private static async getAllTorrentsFromUser() {
const response =
await this.instance.get<RealDebridTorrentInfo[]>("/torrents");
return response.data;
}
static async getTorrentId(magnetUri: string) {
const userTorrents = await RealDebridClient.getAllTorrentsFromUser();
const { infoHash } = await parseTorrent(magnetUri);
const userTorrent = userTorrents.find(
(userTorrent) => userTorrent.hash === infoHash
);
if (userTorrent) return userTorrent.id;
const torrent = await RealDebridClient.addMagnet(magnetUri);
return torrent.id;
}
}

View file

@ -1,69 +0,0 @@
import type { GameShop } from "@types";
import axios from "axios";
export interface SteamGridResponse {
success: boolean;
data: {
id: number;
};
}
export interface SteamGridGameResponse {
data: {
platforms: {
steam: {
metadata: {
clienticon: string;
};
};
};
};
}
export const getSteamGridData = async (
objectId: string,
path: string,
shop: GameShop,
params: Record<string, string> = {}
): Promise<SteamGridResponse> => {
const searchParams = new URLSearchParams(params);
if (!import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY) {
throw new Error("MAIN_VITE_STEAMGRIDDB_API_KEY is not set");
}
const response = await axios.get(
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectId}?${searchParams.toString()}`,
{
headers: {
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
},
}
);
return response.data;
};
export const getSteamGridGameById = async (
id: number
): Promise<SteamGridGameResponse> => {
const response = await axios.get(
`https://www.steamgriddb.com/api/public/game/${id}`,
{
headers: {
Referer: "https://www.steamgriddb.com/",
},
}
);
return response.data;
};
export const getSteamGameClientIcon = async (objectId: string) => {
const {
data: { id: steamGridGameId },
} = await getSteamGridData(objectId, "games", "steam");
const steamGridGame = await getSteamGridGameById(steamGridGameId);
return steamGridGame.data.platforms.steam.metadata.clienticon;
};

View file

@ -1,7 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
readonly MAIN_VITE_API_URL: string;
readonly MAIN_VITE_ANALYTICS_API_URL: string;
readonly MAIN_VITE_AUTH_URL: string;

View file

@ -6,45 +6,8 @@
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src * 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://do.featurebase.app/js/sdk.css; img-src 'self' data: local: *; media-src 'self' local: data: *; connect-src *; font-src *;"
content="default-src 'self'; script-src *; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *; connect-src *; font-src *;"
/>
<script>
!(function (e, t) {
const a = "featurebase-sdk";
function n() {
if (!t.getElementById(a)) {
var e = t.createElement("script");
(e.id = a),
(e.src = "https://do.featurebase.app/js/sdk.js"),
t
.getElementsByTagName("script")[0]
.parentNode.insertBefore(
e,
t.getElementsByTagName("script")[0]
);
}
}
"function" != typeof e.Featurebase &&
(e.Featurebase = function () {
(e.Featurebase.q = e.Featurebase.q || []).push(arguments);
}),
"complete" === t.readyState || "interactive" === t.readyState
? n()
: t.addEventListener("DOMContentLoaded", n);
})(window, document);
</script>
<script>
Featurebase("initialize_feedback_widget", {
organization: "https://hydralauncher.featurebase.app", // Replace this with your organization name, copy-paste the subdomain part from your Featurebase workspace url (e.g. https://*yourorg*.featurebase.app)
theme: "light", // required
placement: "right", // optional - remove to hide the floating button
email: "youruser@example.com", // optional
defaultBoard: "yourboardname", // optional - preselect a board
locale: "en", // Change the language, view all available languages from https://help.featurebase.app/en/articles/8879098-using-featurebase-in-my-language
metadata: null, // Attach session-specific metadata to feedback. Refer to the advanced section for the details: https://help.featurebase.app/en/articles/3774671-advanced#7k8iriyap66
});
</script>
</head>
<body>
<div id="root"></div>

View file

@ -127,7 +127,7 @@ export default function Downloads() {
<DownloadGroup
key={group.title}
title={group.title}
library={group.library}
library={orderBy(group.library, ["updatedAt"], ["desc"])}
openDeleteGameModal={handleOpenDeleteGameModal}
openGameInstaller={handleOpenGameInstaller}
seedingStatus={seedingStatus}

View file

@ -386,3 +386,4 @@ export * from "./steam.types";
export * from "./real-debrid.types";
export * from "./ludusavi.types";
export * from "./how-long-to-beat.types";
export * from "./torbox.types";