diff --git a/.gitignore b/.gitignore index 017a9141..7a6496a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .vscode node_modules hydra-download-manager/ +aria2/ +fastlist.exe __pycache__ dist out diff --git a/electron-builder.yml b/electron-builder.yml index cfdafe7d..be300d36 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,6 +3,7 @@ productName: Hydra directories: buildResources: build extraResources: + - aria2 - hydra-download-manager - seeds - from: node_modules/create-desktop-shortcuts/src/windows.vbs diff --git a/package.json b/package.json index 549f7a1b..daaef390 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/postinstall.cjs b/postinstall.cjs new file mode 100644 index 00000000..547af988 --- /dev/null +++ b/postinstall.cjs @@ -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(); diff --git a/src/main/declaration.d.ts b/src/main/declaration.d.ts new file mode 100644 index 00000000..ac2675a3 --- /dev/null +++ b/src/main/declaration.d.ts @@ -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; + call( + method: "addUri", + uris: string[], + options: { dir: string } + ): Promise; + call( + method: "tellStatus", + gid: string, + keys?: string[] + ): Promise; + call(method: "pause", gid: string): Promise; + call(method: "forcePause", gid: string): Promise; + call(method: "unpause", gid: string): Promise; + call(method: "remove", gid: string): Promise; + call(method: "forceRemove", gid: string): Promise; + call(method: "pauseAll"): Promise; + call(method: "forcePauseAll"): Promise; + listNotifications: () => [ + "onDownloadStart", + "onDownloadPause", + "onDownloadStop", + "onDownloadComplete", + "onDownloadError", + "onBtDownloadComplete", + ]; + on: (event: string, callback: (params: any) => void) => void; + } +} diff --git a/src/main/services/aria2c.ts b/src/main/services/aria2c.ts new file mode 100644 index 00000000..b1b1da76 --- /dev/null +++ b/src/main/services/aria2c.ts @@ -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 } + ); +}; diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 1a1b4ca8..31f28992 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -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); } diff --git a/src/main/services/download/http-download.ts b/src/main/services/download/http-download.ts index 1ce99825..4553a6cb 100644 --- a/src/main/services/download/http-download.ts +++ b/src/main/services/download/http-download.ts @@ -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); } } diff --git a/src/main/services/download/real-debrid-downloader.ts b/src/main/services/download/real-debrid-downloader.ts index 476d8f3e..8ead0067 100644 --- a/src/main/services/download/real-debrid-downloader.ts +++ b/src/main/services/download/real-debrid-downloader.ts @@ -6,10 +6,10 @@ import { DownloadProgress } from "@types"; import { HttpDownload } from "./http-download"; export class RealDebridDownloader { + private static downloads = new Map(); 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); + } } } diff --git a/yarn.lock b/yarn.lock index 30dfe715..00172038 100644 --- a/yarn.lock +++ b/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"