feat: adding libtorrent again

This commit is contained in:
Chubby Granny Chaser 2024-06-27 14:51:13 +01:00
parent 11dffd1b7a
commit 63c13e17cb
No known key found for this signature in database
18 changed files with 1265 additions and 1208 deletions

View File

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

View File

@ -1,6 +1,6 @@
name: Lint
on: [pull_request, push]
on: pull_request
jobs:
lint:

View File

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

View File

@ -3,7 +3,7 @@ productName: Hydra
directories:
buildResources: build
extraResources:
- aria2
- hydra-download-manager
- seeds
- from: node_modules/ps-list/vendor/fastlist-0.3.0-x64.exe
to: fastlist.exe

View File

@ -10,7 +10,6 @@
},
"type": "module",
"engines": {
"npm": "please-use-yarn",
"yarn": ">= 1.19.1"
},
"scripts": {
@ -23,7 +22,6 @@
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node ./postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
@ -40,7 +38,6 @@
"@reduxjs/toolkit": "^2.2.3",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/recipes": "^0.5.2",
"aria2": "^4.1.2",
"auto-launch": "^5.0.6",
"axios": "^1.6.8",
"better-sqlite3": "^9.5.0",

View File

@ -1,50 +0,0 @@
const { default: axios } = require("axios");
const util = require("node:util");
const fs = require("node:fs");
const exec = util.promisify(require("node:child_process").exec);
const downloadAria2 = async () => {
if (fs.existsSync("aria2")) {
console.log("Aria2 already exists, skipping download...");
return;
}
const file =
process.platform === "win32"
? "aria2-1.37.0-win-64bit-build1.zip"
: "aria2-1.37.0-1-x86_64.pkg.tar.zst";
const downloadUrl =
process.platform === "win32"
? `https://github.com/aria2/aria2/releases/download/release-1.37.0/${file}`
: "https://archlinux.org/packages/extra/x86_64/aria2/download/";
console.log(`Downloading ${file}...`);
const response = await axios.get(downloadUrl, { responseType: "stream" });
const stream = response.data.pipe(fs.createWriteStream(file));
stream.on("finish", async () => {
console.log(`Downloaded ${file}, extracting...`);
if (process.platform === "win32") {
await exec(`npx extract-zip ${file}`);
console.log("Extracted. Renaming folder...");
fs.renameSync(file.replace(".zip", ""), "aria2");
} else {
await exec(`tar --zstd -xvf ${file} usr/bin/aria2c`);
console.log("Extracted. Copying binary file...");
fs.mkdirSync("aria2");
fs.copyFileSync("usr/bin/aria2c", "aria2/aria2c");
fs.rmSync("usr", { recursive: true });
}
console.log(`Extracted ${file}, removing compressed downloaded file...`);
fs.rmSync(file);
});
};
downloadAria2();

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
libtorrent
cx_Freeze
cx_Logging; sys_platform == 'win32'
lief; sys_platform == 'win32'
pywin32; sys_platform == 'win32'

View File

@ -17,7 +17,7 @@ declare module "aria2" {
downloadSpeed: string;
uploadSpeed: string;
infoHash?: string;
numSeeders?: string;
numSeeds?: string;
seeder?: boolean;
pieceLength: string;
numPieces: string;

View File

@ -108,7 +108,7 @@ app.on("window-all-closed", () => {
});
app.on("before-quit", () => {
DownloadManager.disconnect();
DownloadManager.kill();
});
app.on("activate", () => {

View File

@ -1,20 +0,0 @@
import path from "node:path";
import { spawn } from "node:child_process";
import { app } from "electron";
export const startAria2 = () => {
const binaryPath = app.isPackaged
? path.join(process.resourcesPath, "aria2", "aria2c")
: path.join(__dirname, "..", "..", "aria2", "aria2c");
return spawn(
binaryPath,
[
"--enable-rpc",
"--rpc-listen-all",
"--file-allocation=none",
"--allow-overwrite=true",
],
{ stdio: "inherit", windowsHide: true }
);
};

View File

@ -1,122 +1,122 @@
import Aria2, { StatusResponse } from "aria2";
import path from "node:path";
import { downloadQueueRepository, gameRepository } from "@main/repository";
import cp from "node:child_process";
import { WindowManager } from "./window-manager";
import { RealDebridClient } from "./real-debrid";
import { Downloader } from "@shared";
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";
import { Game } from "@main/entity";
import { startAria2 } from "./aria2c";
import { sleep } from "@main/helpers";
import { logger } from "./logger";
import type { ChildProcess } from "node:child_process";
import { publishDownloadCompleteNotification } from "./notifications";
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 downloads = new Map<number, string>();
private static torrentClient: cp.ChildProcess | null = null;
private static downloadingGameId = -1;
private static connected = false;
private static gid: string | null = null;
private static game: Game | null = null;
private static realDebridTorrentId: string | null = null;
private static aria2c: ChildProcess | null = null;
private static async spawn() {
this.torrentClient = await startTorrentClient();
}
private static aria2 = new Aria2({});
private static async connect() {
this.aria2c = startAria2();
let retries = 0;
while (retries < 4 && !this.connected) {
try {
await this.aria2.open();
logger.log("Connected to aria2");
this.connected = true;
} catch (err) {
await sleep(100);
logger.log("Failed to connect to aria2, retrying...");
retries++;
}
public static kill() {
if (this.torrentClient) {
this.torrentClient.kill();
this.torrentClient = null;
}
}
public static disconnect() {
if (this.aria2c) {
this.aria2c.kill();
this.connected = false;
}
}
public static async watchDownloads() {
if (!this.downloadingGameId) return;
private static getETA(
totalLength: number,
completedLength: number,
speed: number
) {
const remainingBytes = totalLength - completedLength;
const buf = readPipe.socket?.read(1024 * 2);
if (remainingBytes >= 0 && speed > 0) {
return (remainingBytes / speed) * 1000;
}
if (buf === null) return;
return -1;
}
const message = Buffer.from(buf.slice(0, buf.indexOf(0x00))).toString(
"utf-8"
);
private static getFolderName(status: StatusResponse) {
if (status.bittorrent?.info) return status.bittorrent.info.name;
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;
};
const [file] = status.files;
if (file) return path.win32.basename(file.path);
// TODO: Checking files as metadata is a workaround
const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata ||
status === LibtorrentStatus.CheckingFiles;
return null;
}
if (!isDownloadingMetadata) {
const update: QueryDeepPartialEntity<Game> = {
bytesDownloaded,
fileSize,
progress,
};
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;
await gameRepository.update(
{ id: this.downloadingGameId },
{
...update,
folderName,
}
);
}
if (status === "downloaded") {
const [link] = links;
const { download } = await RealDebridClient.unrestrictLink(link);
return decodeURIComponent(download);
}
const game = await gameRepository.findOne({
where: { id: this.downloadingGameId, isDeleted: false },
});
if (WindowManager.mainWindow) {
const progress = torrentInfo.progress / 100;
const totalDownloaded = progress * torrentInfo.bytes;
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
if (WindowManager.mainWindow && game) {
if (!isNaN(progress))
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,
},
numPeers,
numSeeds,
downloadSpeed,
timeRemaining: getETA(fileSize, bytesDownloaded, downloadSpeed),
isDownloadingMetadata,
game,
} as DownloadProgress;
WindowManager.mainWindow.webContents.send(
@ -124,181 +124,64 @@ export class DownloadManager {
JSON.parse(JSON.stringify(payload))
);
}
}
return null;
}
if (progress === 1 && game) {
publishDownloadCompleteNotification(game);
public static async watchDownloads() {
if (!this.game) return;
await downloadQueueRepository.delete({ game });
if (!this.gid && this.realDebridTorrentId) {
const options = { dir: this.game.downloadPath! };
const downloadUrl = await this.getRealDebridDownloadUrl();
// Clear download
this.downloadingGameId = -1;
if (downloadUrl) {
this.gid = await this.aria2.call("addUri", [downloadUrl], options);
this.downloads.set(this.game.id, this.gid);
this.realDebridTorrentId = null;
}
}
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (!this.gid) return;
const status = await this.aria2.call("tellStatus", this.gid);
const isDownloadingMetadata = status.bittorrent && !status.bittorrent?.info;
if (status.followedBy?.length) {
this.gid = status.followedBy[0];
this.downloads.set(this.game.id, this.gid);
return;
}
const progress =
Number(status.completedLength) / Number(status.totalLength);
if (!isDownloadingMetadata) {
const update: QueryDeepPartialEntity<Game> = {
bytesDownloaded: Number(status.completedLength),
fileSize: Number(status.totalLength),
status: status.status,
};
if (!isNaN(progress)) update.progress = progress;
await gameRepository.update(
{ id: this.game.id },
{
...update,
status: status.status,
folderName: this.getFolderName(status),
if (nextQueueItem) {
this.resumeDownload(nextQueueItem.game);
}
);
}
const game = await gameRepository.findOne({
where: { id: this.game.id, isDeleted: false },
});
if (WindowManager.mainWindow && game) {
if (!isNaN(progress))
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
const payload = {
numPeers: Number(status.connections),
numSeeds: Number(status.numSeeders ?? 0),
downloadSpeed: Number(status.downloadSpeed),
timeRemaining: this.getETA(
Number(status.totalLength),
Number(status.completedLength),
Number(status.downloadSpeed)
),
isDownloadingMetadata: !!isDownloadingMetadata,
game,
} as DownloadProgress;
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify(payload))
);
}
if (progress === 1 && this.game && !isDownloadingMetadata) {
publishDownloadCompleteNotification(this.game);
await downloadQueueRepository.delete({ game: this.game });
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (nextQueueItem) {
this.resumeDownload(nextQueueItem.game);
}
}
}
private static clearCurrentDownload() {
if (this.game) {
this.downloads.delete(this.game.id);
this.gid = null;
this.game = null;
this.realDebridTorrentId = null;
}
}
static async cancelDownload(gameId: number) {
const gid = this.downloads.get(gameId);
if (gid) {
await this.aria2.call("forceRemove", gid);
if (this.gid === gid) {
this.clearCurrentDownload();
WindowManager.mainWindow?.setProgressBar(-1);
} else {
this.downloads.delete(gameId);
}
} catch (err) {
return;
}
}
static async pauseDownload() {
if (this.gid) {
await this.aria2.call("forcePause", this.gid);
this.gid = null;
}
writePipe.write({
action: "pause",
game_id: this.downloadingGameId,
});
this.game = null;
this.realDebridTorrentId = null;
this.downloadingGameId = -1;
WindowManager.mainWindow?.setProgressBar(-1);
}
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.game = game;
this.realDebridTorrentId = null;
} else {
return this.startDownload(game);
}
this.startDownload(game);
}
static async startDownload(game: Game) {
if (!this.connected) await this.connect();
if (!this.torrentClient) await this.spawn();
const options = {
dir: game.downloadPath!,
};
writePipe.write({
action: "start",
game_id: game.id,
magnet: game.uri,
save_path: game.downloadPath,
});
if (game.downloader === Downloader.RealDebrid) {
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
game!.uri!
);
} else {
this.gid = await this.aria2.call("addUri", [game.uri!], options);
this.downloads.set(game.id, this.gid);
}
this.downloadingGameId = game.id;
}
this.game = game;
static async cancelDownload(gameId: number) {
writePipe.write({ action: "cancel", game_id: gameId });
WindowManager.mainWindow?.setProgressBar(-1);
}
}

38
src/main/services/fifo.ts Normal file
View File

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

View File

@ -68,29 +68,29 @@ export class HydraApi {
this.instance.interceptors.request.use(
(request) => {
logger.log(" ---- REQUEST -----");
logger.log(request.method, request.url, request.data);
// logger.log(" ---- REQUEST -----");
// logger.log(request.method, request.url, request.data);
return request;
},
(error) => {
logger.log("request error", error);
// logger.log("request error", error);
return Promise.reject(error);
}
);
this.instance.interceptors.response.use(
(response) => {
logger.log(" ---- RESPONSE -----");
logger.log(
response.status,
response.config.method,
response.config.url,
response.data
);
// logger.log(" ---- RESPONSE -----");
// logger.log(
// response.status,
// response.config.method,
// response.config.url,
// response.data
// );
return response;
},
(error) => {
logger.error("response error", error);
// logger.error("response error", error);
return Promise.reject(error);
}
);
@ -109,7 +109,7 @@ export class HydraApi {
private static async revalidateAccessTokenIfExpired() {
if (!this.userAuth.authToken) {
userAuthRepository.delete({ id: 1 });
logger.error("user is not logged in");
// logger.error("user is not logged in");
throw new Error("user is not logged in");
}
@ -139,10 +139,14 @@ export class HydraApi {
["id"]
);
} catch (err) {
console.log(err, "ddddd");
if (
err instanceof AxiosError &&
(err?.response?.status === 401 || err?.response?.status === 403)
) {
// logger.error("user refresh token expired", err.response?.data);
this.userAuth = {
authToken: "",
expirationTimestamp: 0,
@ -155,7 +159,7 @@ export class HydraApi {
WindowManager.mainWindow.webContents.send("on-signout");
}
logger.log("user refresh token expired");
// logger.log("user refresh token expired");
}
throw err;

View File

@ -0,0 +1,60 @@
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",
linux: "hydra-download-manager",
win32: "hydra-download-manager.exe",
};
export const BITTORRENT_PORT = "5881";
const commonArgs = [BITTORRENT_PORT, writePipe.socketPath, readPipe.socketPath];
export const startTorrentClient = async (): Promise<cp.ChildProcess> => {
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 torrentClient = cp.spawn(binaryPath, commonArgs, {
stdio: "inherit",
windowsHide: true,
});
await Promise.all([writePipe.createPipe(), readPipe.createPipe()]);
return torrentClient;
} else {
const scriptPath = path.join(
__dirname,
"..",
"..",
"torrent-client",
"main.py"
);
const torrentClient = cp.spawn("python3", [scriptPath, ...commonArgs], {
stdio: "inherit",
});
await Promise.all([writePipe.createPipe(), readPipe.createPipe()]);
return torrentClient;
}
};

35
torrent-client/fifo.py Normal file
View File

@ -0,0 +1,35 @@
import platform
class Fifo:
socket_handle = None
def __init__(self, path: str):
if platform.system() == "Windows":
import win32file
self.socket_handle = win32file.CreateFile(path, win32file.GENERIC_READ | win32file.GENERIC_WRITE,
0, None, win32file.OPEN_EXISTING, win32file.FILE_ATTRIBUTE_NORMAL, None)
else:
import socket
self.socket_handle = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.socket_handle.connect(path)
def recv(self, bufSize: int):
if platform.system() == "Windows":
import win32file
result, data = win32file.ReadFile(self.socket_handle, bufSize)
return data
else:
return self.socket_handle.recv(bufSize)
def send_message(self, msg: str):
buffer = bytearray(1024 * 2)
buffer[:len(msg)] = bytes(msg, "utf-8")
if platform.system() == "Windows":
import win32file
win32file.WriteFile(self.socket_handle, buffer)
else:
self.socket_handle.send(buffer)

101
torrent-client/main.py Normal file
View File

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

20
torrent-client/setup.py Normal file
View File

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

1702
yarn.lock

File diff suppressed because it is too large Load Diff