mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
Merge pull request #755 from hydralauncher/feature/aria2-for-http-downloads
Feature/aria2 for http downloads
This commit is contained in:
commit
be1d9825d3
10 changed files with 292 additions and 143 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,6 +1,8 @@
|
|||
.vscode
|
||||
node_modules
|
||||
hydra-download-manager/
|
||||
aria2/
|
||||
fastlist.exe
|
||||
__pycache__
|
||||
dist
|
||||
out
|
||||
|
|
|
@ -3,6 +3,7 @@ productName: Hydra
|
|||
directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- aria2
|
||||
- hydra-download-manager
|
||||
- seeds
|
||||
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"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",
|
||||
|
@ -41,6 +41,7 @@
|
|||
"@sentry/electron": "^5.1.0",
|
||||
"@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",
|
||||
|
|
50
postinstall.cjs
Normal file
50
postinstall.cjs
Normal file
|
@ -0,0 +1,50 @@
|
|||
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();
|
80
src/main/declaration.d.ts
vendored
Normal file
80
src/main/declaration.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
20
src/main/services/aria2c.ts
Normal file
20
src/main/services/aria2c.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
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 }
|
||||
);
|
||||
};
|
|
@ -63,7 +63,7 @@ export class DownloadManager {
|
|||
|
||||
static async pauseDownload() {
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
RealDebridDownloader.pauseDownload();
|
||||
await RealDebridDownloader.pauseDownload();
|
||||
} else {
|
||||
await PythonInstance.pauseDownload();
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ export class DownloadManager {
|
|||
|
||||
static async cancelDownload(gameId: number) {
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
RealDebridDownloader.cancelDownload();
|
||||
RealDebridDownloader.cancelDownload(gameId);
|
||||
} else {
|
||||
PythonInstance.cancelDownload(gameId);
|
||||
}
|
||||
|
|
|
@ -1,123 +1,68 @@
|
|||
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 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(gid: string) {
|
||||
if (this.connected) {
|
||||
return this.aria2.call("tellStatus", 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);
|
||||
}
|
||||
|
||||
fs.rm(this.downloadPath, (err) => {
|
||||
if (err) logger.error(err);
|
||||
});
|
||||
fs.rm(this.trackerFilePath, (err) => {
|
||||
if (err) logger.error(err);
|
||||
});
|
||||
static async pauseDownload(gid: string) {
|
||||
await this.aria2.call("forcePause", gid);
|
||||
}
|
||||
|
||||
static async resumeDownload(gid: string) {
|
||||
await this.aria2.call("unpause", gid);
|
||||
}
|
||||
|
||||
static async startDownload(downloadPath: string, downloadUrl: string) {
|
||||
if (!this.connected) await this.connect();
|
||||
|
||||
const options = {
|
||||
dir: downloadPath,
|
||||
};
|
||||
|
||||
return this.aria2.call("addUri", [downloadUrl], options);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,39 +35,47 @@ export class RealDebridDownloader {
|
|||
}
|
||||
|
||||
public static async getStatus() {
|
||||
const lastProgressEvent = this.httpDownload?.lastProgressEvent;
|
||||
if (this.downloadingGame) {
|
||||
const gid = this.downloads.get(this.downloadingGame.id)!;
|
||||
const status = await HttpDownload.getStatus(gid);
|
||||
|
||||
if (lastProgressEvent) {
|
||||
await gameRepository.update(
|
||||
{ id: this.downloadingGame!.id },
|
||||
{
|
||||
bytesDownloaded: lastProgressEvent.loaded,
|
||||
fileSize: lastProgressEvent.total,
|
||||
progress: lastProgressEvent.progress,
|
||||
status: "active",
|
||||
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",
|
||||
}
|
||||
);
|
||||
|
||||
const result = {
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
downloadSpeed: Number(status.downloadSpeed),
|
||||
timeRemaining: calculateETA(
|
||||
Number(status.totalLength),
|
||||
Number(status.completedLength),
|
||||
Number(status.downloadSpeed)
|
||||
),
|
||||
isDownloadingMetadata: false,
|
||||
isCheckingFiles: false,
|
||||
progress,
|
||||
gameId: this.downloadingGame!.id,
|
||||
} as DownloadProgress;
|
||||
|
||||
if (progress === 1) {
|
||||
this.downloads.delete(this.downloadingGame.id);
|
||||
this.realDebridTorrentId = null;
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
);
|
||||
|
||||
const progress = {
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
downloadSpeed: lastProgressEvent.rate,
|
||||
timeRemaining: calculateETA(
|
||||
lastProgressEvent.total ?? 0,
|
||||
lastProgressEvent.loaded,
|
||||
lastProgressEvent.rate ?? 0
|
||||
),
|
||||
isDownloadingMetadata: false,
|
||||
isCheckingFiles: false,
|
||||
progress: lastProgressEvent.progress,
|
||||
gameId: this.downloadingGame!.id,
|
||||
} as DownloadProgress;
|
||||
|
||||
if (lastProgressEvent.progress === 1) {
|
||||
this.pauseDownload();
|
||||
return result;
|
||||
}
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
if (this.realDebridTorrentId && this.downloadingGame) {
|
||||
|
@ -101,25 +109,54 @@ export class RealDebridDownloader {
|
|||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
this.httpDownload?.pauseDownload();
|
||||
const gid = this.downloads.get(this.downloadingGame!.id!);
|
||||
if (gid) {
|
||||
await HttpDownload.pauseDownload(gid);
|
||||
}
|
||||
|
||||
this.realDebridTorrentId = null;
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(game!.uri!);
|
||||
this.downloadingGame = game;
|
||||
|
||||
if (this.downloads.has(game.id)) {
|
||||
await this.resumeDownload(game.id!);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(game!.uri!);
|
||||
|
||||
const downloadUrl = await this.getRealDebridDownloadUrl();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
static async resumeDownload(gameId: number) {
|
||||
const gid = this.downloads.get(gameId);
|
||||
|
||||
if (gid) {
|
||||
await HttpDownload.resumeDownload(gid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
15
yarn.lock
15
yarn.lock
|
@ -2665,6 +2665,14 @@ aria-query@^5.3.0:
|
|||
dependencies:
|
||||
dequal "^2.0.3"
|
||||
|
||||
aria2@^4.1.2:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/aria2/-/aria2-4.1.2.tgz#0ecbc50beea82856c88b4de71dac336154f67362"
|
||||
integrity sha512-qTBr2RY8RZQmiUmbj2KXFvkErNxU4aTHZszszzwhE8svy2PEVX+IYR/c4Rp9Tuw4QkeU8cylGy6McV6Yl8i7Qw==
|
||||
dependencies:
|
||||
node-fetch "^2.6.1"
|
||||
ws "^7.4.0"
|
||||
|
||||
array-buffer-byte-length@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz"
|
||||
|
@ -5813,7 +5821,7 @@ node-domexception@^1.0.0:
|
|||
resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz"
|
||||
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
|
||||
|
||||
node-fetch@^2.6.7:
|
||||
node-fetch@^2.6.1, node-fetch@^2.6.7:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
||||
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
||||
|
@ -7609,6 +7617,11 @@ wrappy@1:
|
|||
resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
|
||||
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
||||
|
||||
ws@^7.4.0:
|
||||
version "7.5.10"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
|
||||
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
|
||||
|
||||
ws@^8.16.0:
|
||||
version "8.17.0"
|
||||
resolved "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue