mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-09 03:37:45 +03:00
feat: adding libtorrent again
This commit is contained in:
parent
11dffd1b7a
commit
63c13e17cb
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@ -22,6 +22,17 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
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
|
- name: Build Linux
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: yarn build:linux
|
run: yarn build:linux
|
||||||
|
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@ -1,6 +1,6 @@
|
|||||||
name: Lint
|
name: Lint
|
||||||
|
|
||||||
on: [pull_request, push]
|
on: pull_request
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
|
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@ -24,6 +24,17 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
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
|
- name: Build Linux
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: yarn build:linux
|
run: yarn build:linux
|
||||||
|
@ -3,7 +3,7 @@ productName: Hydra
|
|||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
extraResources:
|
extraResources:
|
||||||
- aria2
|
- hydra-download-manager
|
||||||
- seeds
|
- seeds
|
||||||
- from: node_modules/ps-list/vendor/fastlist-0.3.0-x64.exe
|
- from: node_modules/ps-list/vendor/fastlist-0.3.0-x64.exe
|
||||||
to: fastlist.exe
|
to: fastlist.exe
|
||||||
|
@ -10,7 +10,6 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"npm": "please-use-yarn",
|
|
||||||
"yarn": ">= 1.19.1"
|
"yarn": ">= 1.19.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -23,7 +22,6 @@
|
|||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"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:unpack": "npm run build && electron-builder --dir",
|
||||||
"build:win": "electron-vite build && electron-builder --win",
|
"build:win": "electron-vite build && electron-builder --win",
|
||||||
"build:mac": "electron-vite build && electron-builder --mac",
|
"build:mac": "electron-vite build && electron-builder --mac",
|
||||||
@ -40,7 +38,6 @@
|
|||||||
"@reduxjs/toolkit": "^2.2.3",
|
"@reduxjs/toolkit": "^2.2.3",
|
||||||
"@vanilla-extract/css": "^1.14.2",
|
"@vanilla-extract/css": "^1.14.2",
|
||||||
"@vanilla-extract/recipes": "^0.5.2",
|
"@vanilla-extract/recipes": "^0.5.2",
|
||||||
"aria2": "^4.1.2",
|
|
||||||
"auto-launch": "^5.0.6",
|
"auto-launch": "^5.0.6",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
"better-sqlite3": "^9.5.0",
|
"better-sqlite3": "^9.5.0",
|
||||||
|
@ -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
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
libtorrent
|
||||||
|
cx_Freeze
|
||||||
|
cx_Logging; sys_platform == 'win32'
|
||||||
|
lief; sys_platform == 'win32'
|
||||||
|
pywin32; sys_platform == 'win32'
|
2
src/main/declaration.d.ts
vendored
2
src/main/declaration.d.ts
vendored
@ -17,7 +17,7 @@ declare module "aria2" {
|
|||||||
downloadSpeed: string;
|
downloadSpeed: string;
|
||||||
uploadSpeed: string;
|
uploadSpeed: string;
|
||||||
infoHash?: string;
|
infoHash?: string;
|
||||||
numSeeders?: string;
|
numSeeds?: string;
|
||||||
seeder?: boolean;
|
seeder?: boolean;
|
||||||
pieceLength: string;
|
pieceLength: string;
|
||||||
numPieces: string;
|
numPieces: string;
|
||||||
|
@ -108,7 +108,7 @@ app.on("window-all-closed", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on("before-quit", () => {
|
app.on("before-quit", () => {
|
||||||
DownloadManager.disconnect();
|
DownloadManager.kill();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
|
@ -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 }
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,64 +1,28 @@
|
|||||||
import Aria2, { StatusResponse } from "aria2";
|
import cp from "node:child_process";
|
||||||
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
|
||||||
|
|
||||||
import { WindowManager } from "./window-manager";
|
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 { DownloadProgress } from "@types";
|
||||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
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";
|
|
||||||
|
|
||||||
export class DownloadManager {
|
enum LibtorrentStatus {
|
||||||
private static downloads = new Map<number, string>();
|
CheckingFiles = 1,
|
||||||
|
DownloadingMetadata = 2,
|
||||||
private static connected = false;
|
Downloading = 3,
|
||||||
private static gid: string | null = null;
|
Finished = 4,
|
||||||
private static game: Game | null = null;
|
Seeding = 5,
|
||||||
private static realDebridTorrentId: string | null = null;
|
|
||||||
private static aria2c: ChildProcess | null = null;
|
|
||||||
|
|
||||||
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 disconnect() {
|
const getETA = (
|
||||||
if (this.aria2c) {
|
|
||||||
this.aria2c.kill();
|
|
||||||
this.connected = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getETA(
|
|
||||||
totalLength: number,
|
totalLength: number,
|
||||||
completedLength: number,
|
completedLength: number,
|
||||||
speed: number
|
speed: number
|
||||||
) {
|
) => {
|
||||||
const remainingBytes = totalLength - completedLength;
|
const remainingBytes = totalLength - completedLength;
|
||||||
|
|
||||||
if (remainingBytes >= 0 && speed > 0) {
|
if (remainingBytes >= 0 && speed > 0) {
|
||||||
@ -66,135 +30,92 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return -1;
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DownloadManager {
|
||||||
|
private static torrentClient: cp.ChildProcess | null = null;
|
||||||
|
private static downloadingGameId = -1;
|
||||||
|
|
||||||
|
private static async spawn() {
|
||||||
|
this.torrentClient = await startTorrentClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getFolderName(status: StatusResponse) {
|
public static kill() {
|
||||||
if (status.bittorrent?.info) return status.bittorrent.info.name;
|
if (this.torrentClient) {
|
||||||
|
this.torrentClient.kill();
|
||||||
const [file] = status.files;
|
this.torrentClient = null;
|
||||||
if (file) return path.win32.basename(file.path);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
public static async watchDownloads() {
|
||||||
if (!this.game) return;
|
if (!this.downloadingGameId) return;
|
||||||
|
|
||||||
if (!this.gid && this.realDebridTorrentId) {
|
const buf = readPipe.socket?.read(1024 * 2);
|
||||||
const options = { dir: this.game.downloadPath! };
|
|
||||||
const downloadUrl = await this.getRealDebridDownloadUrl();
|
|
||||||
|
|
||||||
if (downloadUrl) {
|
if (buf === null) return;
|
||||||
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 message = Buffer.from(buf.slice(0, buf.indexOf(0x00))).toString(
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
const status = await this.aria2.call("tellStatus", this.gid);
|
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 isDownloadingMetadata = status.bittorrent && !status.bittorrent?.info;
|
// TODO: Checking files as metadata is a workaround
|
||||||
|
const isDownloadingMetadata =
|
||||||
if (status.followedBy?.length) {
|
status === LibtorrentStatus.DownloadingMetadata ||
|
||||||
this.gid = status.followedBy[0];
|
status === LibtorrentStatus.CheckingFiles;
|
||||||
this.downloads.set(this.game.id, this.gid);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress =
|
|
||||||
Number(status.completedLength) / Number(status.totalLength);
|
|
||||||
|
|
||||||
if (!isDownloadingMetadata) {
|
if (!isDownloadingMetadata) {
|
||||||
const update: QueryDeepPartialEntity<Game> = {
|
const update: QueryDeepPartialEntity<Game> = {
|
||||||
bytesDownloaded: Number(status.completedLength),
|
bytesDownloaded,
|
||||||
fileSize: Number(status.totalLength),
|
fileSize,
|
||||||
status: status.status,
|
progress,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isNaN(progress)) update.progress = progress;
|
|
||||||
|
|
||||||
await gameRepository.update(
|
await gameRepository.update(
|
||||||
{ id: this.game.id },
|
{ id: this.downloadingGameId },
|
||||||
{
|
{
|
||||||
...update,
|
...update,
|
||||||
status: status.status,
|
folderName,
|
||||||
folderName: this.getFolderName(status),
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const game = await gameRepository.findOne({
|
const game = await gameRepository.findOne({
|
||||||
where: { id: this.game.id, isDeleted: false },
|
where: { id: this.downloadingGameId, isDeleted: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (WindowManager.mainWindow && game) {
|
if (WindowManager.mainWindow && game) {
|
||||||
if (!isNaN(progress))
|
if (!isNaN(progress))
|
||||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
WindowManager.mainWindow.setProgressBar(
|
||||||
|
progress === 1 ? -1 : progress
|
||||||
|
);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
numPeers: Number(status.connections),
|
numPeers,
|
||||||
numSeeds: Number(status.numSeeders ?? 0),
|
numSeeds,
|
||||||
downloadSpeed: Number(status.downloadSpeed),
|
downloadSpeed,
|
||||||
timeRemaining: this.getETA(
|
timeRemaining: getETA(fileSize, bytesDownloaded, downloadSpeed),
|
||||||
Number(status.totalLength),
|
isDownloadingMetadata,
|
||||||
Number(status.completedLength),
|
|
||||||
Number(status.downloadSpeed)
|
|
||||||
),
|
|
||||||
isDownloadingMetadata: !!isDownloadingMetadata,
|
|
||||||
game,
|
game,
|
||||||
} as DownloadProgress;
|
} as DownloadProgress;
|
||||||
|
|
||||||
@ -204,19 +125,13 @@ export class DownloadManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progress === 1 && this.game && !isDownloadingMetadata) {
|
if (progress === 1 && game) {
|
||||||
publishDownloadCompleteNotification(this.game);
|
publishDownloadCompleteNotification(game);
|
||||||
|
|
||||||
await downloadQueueRepository.delete({ game: this.game });
|
await downloadQueueRepository.delete({ game });
|
||||||
|
|
||||||
/*
|
// Clear download
|
||||||
Only cancel bittorrent downloads to stop seeding
|
this.downloadingGameId = -1;
|
||||||
*/
|
|
||||||
if (status.bittorrent) {
|
|
||||||
await this.cancelDownload(this.game.id);
|
|
||||||
} else {
|
|
||||||
this.clearCurrentDownload();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||||
order: {
|
order: {
|
||||||
@ -231,74 +146,42 @@ export class DownloadManager {
|
|||||||
this.resumeDownload(nextQueueItem.game);
|
this.resumeDownload(nextQueueItem.game);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (err) {
|
||||||
|
return;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async pauseDownload() {
|
static async pauseDownload() {
|
||||||
if (this.gid) {
|
writePipe.write({
|
||||||
await this.aria2.call("forcePause", this.gid);
|
action: "pause",
|
||||||
this.gid = null;
|
game_id: this.downloadingGameId,
|
||||||
}
|
});
|
||||||
|
|
||||||
this.game = null;
|
this.downloadingGameId = -1;
|
||||||
this.realDebridTorrentId = null;
|
|
||||||
|
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async resumeDownload(game: Game) {
|
static async resumeDownload(game: Game) {
|
||||||
if (this.downloads.has(game.id)) {
|
this.startDownload(game);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async startDownload(game: Game) {
|
static async startDownload(game: Game) {
|
||||||
if (!this.connected) await this.connect();
|
if (!this.torrentClient) await this.spawn();
|
||||||
|
|
||||||
const options = {
|
writePipe.write({
|
||||||
dir: game.downloadPath!,
|
action: "start",
|
||||||
};
|
game_id: game.id,
|
||||||
|
magnet: game.uri,
|
||||||
|
save_path: game.downloadPath,
|
||||||
|
});
|
||||||
|
|
||||||
if (game.downloader === Downloader.RealDebrid) {
|
this.downloadingGameId = game.id;
|
||||||
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.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
38
src/main/services/fifo.ts
Normal 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();
|
@ -68,29 +68,29 @@ export class HydraApi {
|
|||||||
|
|
||||||
this.instance.interceptors.request.use(
|
this.instance.interceptors.request.use(
|
||||||
(request) => {
|
(request) => {
|
||||||
logger.log(" ---- REQUEST -----");
|
// logger.log(" ---- REQUEST -----");
|
||||||
logger.log(request.method, request.url, request.data);
|
// logger.log(request.method, request.url, request.data);
|
||||||
return request;
|
return request;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
logger.log("request error", error);
|
// logger.log("request error", error);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.instance.interceptors.response.use(
|
this.instance.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
logger.log(" ---- RESPONSE -----");
|
// logger.log(" ---- RESPONSE -----");
|
||||||
logger.log(
|
// logger.log(
|
||||||
response.status,
|
// response.status,
|
||||||
response.config.method,
|
// response.config.method,
|
||||||
response.config.url,
|
// response.config.url,
|
||||||
response.data
|
// response.data
|
||||||
);
|
// );
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
logger.error("response error", error);
|
// logger.error("response error", error);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -109,7 +109,7 @@ export class HydraApi {
|
|||||||
private static async revalidateAccessTokenIfExpired() {
|
private static async revalidateAccessTokenIfExpired() {
|
||||||
if (!this.userAuth.authToken) {
|
if (!this.userAuth.authToken) {
|
||||||
userAuthRepository.delete({ id: 1 });
|
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");
|
throw new Error("user is not logged in");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,10 +139,14 @@ export class HydraApi {
|
|||||||
["id"]
|
["id"]
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.log(err, "ddddd");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
err instanceof AxiosError &&
|
err instanceof AxiosError &&
|
||||||
(err?.response?.status === 401 || err?.response?.status === 403)
|
(err?.response?.status === 401 || err?.response?.status === 403)
|
||||||
) {
|
) {
|
||||||
|
// logger.error("user refresh token expired", err.response?.data);
|
||||||
|
|
||||||
this.userAuth = {
|
this.userAuth = {
|
||||||
authToken: "",
|
authToken: "",
|
||||||
expirationTimestamp: 0,
|
expirationTimestamp: 0,
|
||||||
@ -155,7 +159,7 @@ export class HydraApi {
|
|||||||
WindowManager.mainWindow.webContents.send("on-signout");
|
WindowManager.mainWindow.webContents.send("on-signout");
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("user refresh token expired");
|
// logger.log("user refresh token expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
|
60
src/main/services/torrent-client.ts
Normal file
60
src/main/services/torrent-client.ts
Normal 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
35
torrent-client/fifo.py
Normal 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
101
torrent-client/main.py
Normal 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
20
torrent-client/setup.py
Normal 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"
|
||||||
|
)]
|
||||||
|
)
|
Loading…
Reference in New Issue
Block a user