mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-21 18:13:55 +00:00
feat: updating real-debrid translations
This commit is contained in:
27
src/main/services/aria2.ts
Normal file
27
src/main/services/aria2.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { app } from "electron";
|
||||
|
||||
export const startAria2 = (): Promise<ChildProcessWithoutNullStreams> => {
|
||||
return new Promise((resolve) => {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2", "aria2c")
|
||||
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
||||
|
||||
const cp = spawn(binaryPath, [
|
||||
"--enable-rpc",
|
||||
"--rpc-listen-all",
|
||||
"--file-allocation=none",
|
||||
"--allow-overwrite=true",
|
||||
]);
|
||||
|
||||
cp.stdout.on("data", async (data) => {
|
||||
const msg = Buffer.from(data).toString("utf-8");
|
||||
|
||||
if (msg.includes("IPv6 RPC: listening on TCP")) {
|
||||
resolve(cp);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,57 +1,39 @@
|
||||
import Aria2, { StatusResponse } from "aria2";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||
|
||||
import path from "node:path";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { RealDebridClient } from "./real-debrid";
|
||||
import { Notification, app } from "electron";
|
||||
import { Notification } from "electron";
|
||||
import { t } from "i18next";
|
||||
import { Downloader } from "@shared";
|
||||
import { DownloadProgress } from "@types";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { Game } from "@main/entity";
|
||||
import { startAria2 } from "./aria2";
|
||||
|
||||
export class DownloadManager {
|
||||
private static downloads = new Map<number, string>();
|
||||
|
||||
private static connected = false;
|
||||
private static gid: string | null = null;
|
||||
private static gameId: number | null = null;
|
||||
private static game: Game | null = null;
|
||||
private static realDebridTorrentId: string | null = null;
|
||||
|
||||
private static aria2 = new Aria2({});
|
||||
|
||||
private static connect(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2", "aria2c")
|
||||
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
||||
|
||||
const cp = spawn(binaryPath, [
|
||||
"--enable-rpc",
|
||||
"--rpc-listen-all",
|
||||
"--file-allocation=none",
|
||||
"--allow-overwrite=true",
|
||||
]);
|
||||
|
||||
cp.stdout.on("data", async (data) => {
|
||||
const msg = Buffer.from(data).toString("utf-8");
|
||||
|
||||
if (msg.includes("IPv6 RPC: listening on TCP")) {
|
||||
await this.aria2.open();
|
||||
this.connected = true;
|
||||
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
private static async connect() {
|
||||
await startAria2();
|
||||
await this.aria2.open();
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
private static getETA(status: StatusResponse) {
|
||||
const remainingBytes =
|
||||
Number(status.totalLength) - Number(status.completedLength);
|
||||
const speed = Number(status.downloadSpeed);
|
||||
private static getETA(
|
||||
totalLength: number,
|
||||
completedLength: number,
|
||||
speed: number
|
||||
) {
|
||||
const remainingBytes = totalLength - completedLength;
|
||||
|
||||
if (remainingBytes >= 0 && speed > 0) {
|
||||
return (remainingBytes / speed) * 1000;
|
||||
@@ -65,9 +47,7 @@ export class DownloadManager {
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (userPreferences?.downloadNotificationsEnabled && this.gameId) {
|
||||
const game = await this.getGame(this.gameId);
|
||||
|
||||
if (userPreferences?.downloadNotificationsEnabled && this.game) {
|
||||
new Notification({
|
||||
title: t("download_complete", {
|
||||
ns: "notifications",
|
||||
@@ -76,7 +56,7 @@ export class DownloadManager {
|
||||
body: t("game_ready_to_install", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences.language,
|
||||
title: game?.title,
|
||||
title: this.game.title,
|
||||
}),
|
||||
}).show();
|
||||
}
|
||||
@@ -87,8 +67,73 @@ export class DownloadManager {
|
||||
return "";
|
||||
}
|
||||
|
||||
private static async getRealDebridDownloadUrl() {
|
||||
if (this.realDebridTorrentId) {
|
||||
const torrentInfo = await RealDebridClient.getTorrentInfo(
|
||||
this.realDebridTorrentId
|
||||
);
|
||||
|
||||
const { status, links } = torrentInfo;
|
||||
|
||||
if (status === "waiting_files_selection") {
|
||||
await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === "downloaded") {
|
||||
const [link] = links;
|
||||
const { download } = await RealDebridClient.unrestrictLink(link);
|
||||
return decodeURIComponent(download);
|
||||
}
|
||||
|
||||
if (WindowManager.mainWindow) {
|
||||
const progress = torrentInfo.progress / 100;
|
||||
const totalDownloaded = progress * torrentInfo.bytes;
|
||||
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
|
||||
const payload = {
|
||||
numPeers: 0,
|
||||
numSeeds: torrentInfo.seeders,
|
||||
downloadSpeed: torrentInfo.speed,
|
||||
timeRemaining: this.getETA(
|
||||
torrentInfo.bytes,
|
||||
totalDownloaded,
|
||||
torrentInfo.speed
|
||||
),
|
||||
isDownloadingMetadata: status === "magnet_conversion",
|
||||
game: {
|
||||
...this.game,
|
||||
bytesDownloaded: progress * torrentInfo.bytes,
|
||||
progress,
|
||||
},
|
||||
} as DownloadProgress;
|
||||
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(JSON.stringify(payload))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async watchDownloads() {
|
||||
if (!this.gid || !this.gameId) return;
|
||||
if (!this.game) return;
|
||||
|
||||
if (!this.gid && this.realDebridTorrentId) {
|
||||
const options = { dir: this.game.downloadPath! };
|
||||
const downloadUrl = await this.getRealDebridDownloadUrl();
|
||||
|
||||
if (downloadUrl) {
|
||||
this.gid = await this.aria2.call("addUri", [downloadUrl], options);
|
||||
this.downloads.set(this.game.id, this.gid);
|
||||
this.realDebridTorrentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.gid) return;
|
||||
|
||||
const status = await this.aria2.call("tellStatus", this.gid);
|
||||
|
||||
@@ -96,7 +141,7 @@ export class DownloadManager {
|
||||
|
||||
if (status.followedBy?.length) {
|
||||
this.gid = status.followedBy[0];
|
||||
this.downloads.set(this.gameId, this.gid);
|
||||
this.downloads.set(this.game.id, this.gid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,7 +158,7 @@ export class DownloadManager {
|
||||
if (!isNaN(progress)) update.progress = progress;
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: this.gameId },
|
||||
{ id: this.game.id },
|
||||
{
|
||||
...update,
|
||||
status: status.status,
|
||||
@@ -123,17 +168,18 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: this.gameId, isDeleted: false },
|
||||
where: { id: this.game.id, isDeleted: false },
|
||||
relations: { repack: true },
|
||||
});
|
||||
|
||||
if (progress === 1 && game && !isDownloadingMetadata) {
|
||||
if (progress === 1 && this.game && !isDownloadingMetadata) {
|
||||
await this.publishNotification();
|
||||
|
||||
/*
|
||||
Only cancel bittorrent downloads to stop seeding
|
||||
*/
|
||||
if (status.bittorrent) {
|
||||
await this.cancelDownload(game.id);
|
||||
await this.cancelDownload(this.game.id);
|
||||
} else {
|
||||
this.clearCurrentDownload();
|
||||
}
|
||||
@@ -143,13 +189,14 @@ export class DownloadManager {
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
|
||||
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),
|
||||
timeRemaining: this.getETA(
|
||||
Number(status.totalLength),
|
||||
Number(status.completedLength),
|
||||
Number(status.downloadSpeed)
|
||||
),
|
||||
isDownloadingMetadata: !!isDownloadingMetadata,
|
||||
game,
|
||||
} as DownloadProgress;
|
||||
@@ -161,20 +208,12 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
static async getGame(gameId: number) {
|
||||
return gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
relations: {
|
||||
repack: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static clearCurrentDownload() {
|
||||
if (this.gameId) {
|
||||
this.downloads.delete(this.gameId);
|
||||
if (this.game) {
|
||||
this.downloads.delete(this.game.id);
|
||||
this.gid = null;
|
||||
this.gameId = null;
|
||||
this.game = null;
|
||||
this.realDebridTorrentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,50 +237,42 @@ export class DownloadManager {
|
||||
if (this.gid) {
|
||||
await this.aria2.call("forcePause", this.gid);
|
||||
this.gid = null;
|
||||
this.gameId = null;
|
||||
this.game = null;
|
||||
this.realDebridTorrentId = null;
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
}
|
||||
}
|
||||
|
||||
static async resumeDownload(gameId: number) {
|
||||
if (this.downloads.has(gameId)) {
|
||||
const gid = this.downloads.get(gameId)!;
|
||||
static async resumeDownload(game: Game) {
|
||||
if (this.downloads.has(game.id)) {
|
||||
const gid = this.downloads.get(game.id)!;
|
||||
await this.aria2.call("unpause", gid);
|
||||
|
||||
this.gid = gid;
|
||||
this.gameId = gameId;
|
||||
this.game = game;
|
||||
} else {
|
||||
return this.startDownload(gameId);
|
||||
return this.startDownload(game);
|
||||
}
|
||||
}
|
||||
|
||||
static async startDownload(gameId: number) {
|
||||
static async startDownload(game: Game) {
|
||||
if (!this.connected) await this.connect();
|
||||
|
||||
const game = await this.getGame(gameId)!;
|
||||
const options = {
|
||||
dir: game.downloadPath!,
|
||||
};
|
||||
|
||||
if (game) {
|
||||
const options = {
|
||||
dir: game.downloadPath!,
|
||||
};
|
||||
if (game.downloader === Downloader.RealDebrid) {
|
||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
|
||||
game!.repack.magnet
|
||||
);
|
||||
} else {
|
||||
this.gid = await this.aria2.call("addUri", [game.repack.magnet], options);
|
||||
|
||||
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.downloads.set(game.id, this.gid);
|
||||
}
|
||||
|
||||
this.game = game;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Game } from "@main/entity";
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import parseTorrent from "parse-torrent";
|
||||
import type {
|
||||
RealDebridAddMagnet,
|
||||
RealDebridTorrentInfo,
|
||||
@@ -7,10 +7,18 @@ import type {
|
||||
RealDebridUser,
|
||||
} from "@types";
|
||||
|
||||
const base = "https://api.real-debrid.com/rest/1.0";
|
||||
|
||||
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 });
|
||||
@@ -23,7 +31,7 @@ export class RealDebridClient {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async getInfo(id: string) {
|
||||
static async getTorrentInfo(id: string) {
|
||||
const response = await this.instance.get<RealDebridTorrentInfo>(
|
||||
`/torrents/info/${id}`
|
||||
);
|
||||
@@ -55,50 +63,24 @@ export class RealDebridClient {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async getAllTorrentsFromUser() {
|
||||
private 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 getTorrentId(magnetUri: string) {
|
||||
const userTorrents = await RealDebridClient.getAllTorrentsFromUser();
|
||||
|
||||
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);
|
||||
const { infoHash } = await parseTorrent(magnetUri);
|
||||
const userTorrent = userTorrents.find(
|
||||
(userTorrent) => userTorrent.hash === infoHash
|
||||
);
|
||||
|
||||
// User haven't downloaded this torrent yet
|
||||
if (!torrent) {
|
||||
const magnet = await RealDebridClient.addMagnet(game!.repack.magnet);
|
||||
if (userTorrent) return userTorrent.id;
|
||||
|
||||
if (magnet) {
|
||||
await RealDebridClient.selectAllFiles(magnet.id);
|
||||
torrent = await RealDebridClient.getInfo(magnet.id);
|
||||
|
||||
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 authorize(apiToken: string) {
|
||||
this.instance = axios.create({
|
||||
baseURL: base,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
const torrent = await RealDebridClient.addMagnet(magnetUri);
|
||||
return torrent.id;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user