mirror of
				https://github.com/hydralauncher/hydra.git
				synced 2025-03-09 15:40:26 +00:00 
			
		
		
		
	Merge branch 'main' into hyd-228-investigate-why-users-are-being-logged-out-when-updating
This commit is contained in:
		
						commit
						be3c78f584
					
				
					 10 changed files with 292 additions and 143 deletions
				
			
		
							
								
								
									
										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);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue