feat: using aria2 for http downloads

This commit is contained in:
Chubby Granny Chaser 2024-07-02 15:33:26 +01:00
parent 8eca067aed
commit a39082d326
No known key found for this signature in database
18 changed files with 3014 additions and 156 deletions

80
src/main/declaration.d.ts vendored Normal file
View file

@ -0,0 +1,80 @@
declare module "aria2" {
export type Aria2Status =
| "active"
| "waiting"
| "paused"
| "error"
| "complete"
| "removed";
export interface StatusResponse {
gid: string;
status: Aria2Status;
totalLength: string;
completedLength: string;
uploadLength: string;
bitfield: string;
downloadSpeed: string;
uploadSpeed: string;
infoHash?: string;
numSeeders?: string;
seeder?: boolean;
pieceLength: string;
numPieces: string;
connections: string;
errorCode?: string;
errorMessage?: string;
followedBy?: string[];
following: string;
belongsTo: string;
dir: string;
files: {
path: string;
length: string;
completedLength: string;
selected: string;
}[];
bittorrent?: {
announceList: string[][];
comment: string;
creationDate: string;
mode: "single" | "multi";
info: {
name: string;
verifiedLength: string;
verifyIntegrityPending: string;
};
};
}
export default class Aria2 {
constructor(options: any);
open: () => Promise<void>;
call(
method: "addUri",
uris: string[],
options: { dir: string }
): Promise<string>;
call(
method: "tellStatus",
gid: string,
keys?: string[]
): Promise<StatusResponse>;
call(method: "pause", gid: string): Promise<string>;
call(method: "forcePause", gid: string): Promise<string>;
call(method: "unpause", gid: string): Promise<string>;
call(method: "remove", gid: string): Promise<string>;
call(method: "forceRemove", gid: string): Promise<string>;
call(method: "pauseAll"): Promise<string>;
call(method: "forcePauseAll"): Promise<string>;
listNotifications: () => [
"onDownloadStart",
"onDownloadPause",
"onDownloadStop",
"onDownloadComplete",
"onDownloadError",
"onBtDownloadComplete",
];
on: (event: string, callback: (params: any) => void) => void;
}
}

View file

@ -0,0 +1,23 @@
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",
"--enable-peer-exchange=false",
"--enable-dht=false",
"--bt-enable-lpd=false",
],
{ stdio: "inherit", windowsHide: true }
);
};

View file

@ -84,7 +84,7 @@ export class DownloadManager {
static async cancelDownload(gameId: number) {
if (this.currentDownloader === Downloader.RealDebrid) {
RealDebridDownloader.cancelDownload();
RealDebridDownloader.cancelDownload(gameId);
} else {
TorrentDownloader.cancelDownload(gameId);
}

View file

@ -1,123 +1,77 @@
import path from "node:path";
import fs from "node:fs";
import crypto from "node:crypto";
import axios, { type AxiosProgressEvent } from "axios";
import { app } from "electron";
import type { ChildProcess } from "node:child_process";
import { logger } from "../logger";
import { sleep } from "@main/helpers";
import { startAria2 } from "../aria2c";
import Aria2 from "aria2";
export class HttpDownload {
private abortController: AbortController;
public lastProgressEvent: AxiosProgressEvent;
private trackerFilePath: string;
private static gid: string | null = null;
private static connected = false;
private static aria2c: ChildProcess | null = null;
private trackerProgressEvent: AxiosProgressEvent | null = null;
private downloadPath: string;
private static aria2 = new Aria2({});
private downloadTrackersPath = path.join(
app.getPath("documents"),
"Hydra",
"Downloads"
);
private static async connect() {
this.aria2c = startAria2();
constructor(
private url: string,
private savePath: string
) {
this.abortController = new AbortController();
let retries = 0;
const sha256Hasher = crypto.createHash("sha256");
const hash = sha256Hasher.update(url).digest("hex");
while (retries < 4 && !this.connected) {
try {
await this.aria2.open();
logger.log("Connected to aria2");
this.trackerFilePath = path.join(
this.downloadTrackersPath,
`${hash}.hydradownload`
);
const filename = path.win32.basename(this.url);
this.downloadPath = path.join(this.savePath, filename);
}
private updateTrackerFile() {
if (!fs.existsSync(this.downloadTrackersPath)) {
fs.mkdirSync(this.downloadTrackersPath, {
recursive: true,
});
}
fs.writeFileSync(
this.trackerFilePath,
JSON.stringify(this.lastProgressEvent),
{ encoding: "utf-8" }
);
}
private removeTrackerFile() {
if (fs.existsSync(this.trackerFilePath)) {
fs.rm(this.trackerFilePath, (err) => {
logger.error(err);
});
this.connected = true;
} catch (err) {
await sleep(100);
logger.log("Failed to connect to aria2, retrying...");
retries++;
}
}
}
public async startDownload() {
// Check if there's already a tracker file and download file
if (
fs.existsSync(this.trackerFilePath) &&
fs.existsSync(this.downloadPath)
) {
this.trackerProgressEvent = JSON.parse(
fs.readFileSync(this.trackerFilePath, { encoding: "utf-8" })
);
public static getStatus() {
if (this.connected && this.gid) {
return this.aria2.call("tellStatus", this.gid);
}
const response = await axios.get(this.url, {
responseType: "stream",
signal: this.abortController.signal,
headers: {
Range: `bytes=${this.trackerProgressEvent?.loaded ?? 0}-`,
},
onDownloadProgress: (progressEvent) => {
const total =
this.trackerProgressEvent?.total ?? progressEvent.total ?? 0;
const loaded =
(this.trackerProgressEvent?.loaded ?? 0) + progressEvent.loaded;
const progress = loaded / total;
this.lastProgressEvent = {
...progressEvent,
total,
progress,
loaded,
};
this.updateTrackerFile();
if (progressEvent.progress === 1) {
this.removeTrackerFile();
}
},
});
response.data.pipe(
fs.createWriteStream(this.downloadPath, {
flags: "a",
})
);
return null;
}
public async pauseDownload() {
this.abortController.abort();
public static disconnect() {
if (this.aria2c) {
this.aria2c.kill();
this.connected = false;
}
}
public cancelDownload() {
this.pauseDownload();
static async cancelDownload(gid: string) {
await this.aria2.call("forceRemove", gid);
if (this.gid === gid) {
this.gid = null;
}
}
fs.rm(this.downloadPath, (err) => {
if (err) logger.error(err);
});
fs.rm(this.trackerFilePath, (err) => {
if (err) logger.error(err);
});
static async pauseDownload() {
if (this.gid) {
await this.aria2.call("forcePause", this.gid);
this.gid = null;
}
}
static async resumeDownload(gid: string) {
await this.aria2.call("unpause", gid);
this.gid = gid;
}
static async startDownload(downloadPath: string, downloadUrl: string) {
if (!this.connected) await this.connect();
const options = {
dir: downloadPath,
};
this.gid = await this.aria2.call("addUri", [downloadUrl], options);
return this.gid;
}
}

View file

@ -6,10 +6,10 @@ import { DownloadProgress } from "@types";
import { HttpDownload } from "./http-download";
export class RealDebridDownloader {
private static downloads = new Map<number, string>();
private static downloadingGame: Game | null = null;
private static realDebridTorrentId: string | null = null;
private static httpDownload: HttpDownload | null = null;
private static async getRealDebridDownloadUrl() {
if (this.realDebridTorrentId) {
@ -35,73 +35,43 @@ export class RealDebridDownloader {
}
public static async getStatus() {
const lastProgressEvent = this.httpDownload?.lastProgressEvent;
const status = await HttpDownload.getStatus();
if (status) {
const progress =
Number(status.completedLength) / Number(status.totalLength);
if (lastProgressEvent) {
await gameRepository.update(
{ id: this.downloadingGame!.id },
{
bytesDownloaded: lastProgressEvent.loaded,
fileSize: lastProgressEvent.total,
progress: lastProgressEvent.progress,
bytesDownloaded: Number(status.completedLength),
fileSize: Number(status.totalLength),
progress,
status: "active",
}
);
const progress = {
return {
numPeers: 0,
numSeeds: 0,
downloadSpeed: lastProgressEvent.rate,
downloadSpeed: Number(status.downloadSpeed),
timeRemaining: calculateETA(
lastProgressEvent.total ?? 0,
lastProgressEvent.loaded,
lastProgressEvent.rate ?? 0
Number(status.totalLength),
Number(status.completedLength),
Number(status.downloadSpeed)
),
isDownloadingMetadata: false,
isCheckingFiles: false,
progress: lastProgressEvent.progress,
progress,
gameId: this.downloadingGame!.id,
} as DownloadProgress;
if (lastProgressEvent.progress === 1) {
this.pauseDownload();
}
return progress;
}
if (this.realDebridTorrentId && this.downloadingGame) {
const torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
const { status } = torrentInfo;
if (status === "downloaded") {
this.startDownload(this.downloadingGame);
}
const progress = torrentInfo.progress / 100;
const totalDownloaded = progress * torrentInfo.bytes;
return {
numPeers: 0,
numSeeds: torrentInfo.seeders,
downloadSpeed: torrentInfo.speed,
timeRemaining: calculateETA(
torrentInfo.bytes,
totalDownloaded,
torrentInfo.speed
),
isDownloadingMetadata: status === "magnet_conversion",
} as DownloadProgress;
}
return null;
}
static async pauseDownload() {
this.httpDownload?.pauseDownload();
HttpDownload.pauseDownload();
this.realDebridTorrentId = null;
this.downloadingGame = null;
}
@ -114,12 +84,20 @@ export class RealDebridDownloader {
if (downloadUrl) {
this.realDebridTorrentId = null;
this.httpDownload = new HttpDownload(downloadUrl, game!.downloadPath!);
this.httpDownload.startDownload();
const gid = await HttpDownload.startDownload(
game.downloadPath!,
downloadUrl
);
this.downloads.set(game.id!, gid);
}
}
static cancelDownload() {
return this.httpDownload?.cancelDownload();
static async cancelDownload(gameId: number) {
const gid = this.downloads.get(gameId);
if (gid) {
await HttpDownload.cancelDownload(gid);
this.downloads.delete(gameId);
}
}
}