mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-01-24 05:54:55 +03:00
Fixing downloads by replacing the torrent client
This commit is contained in:
parent
ccef34c15c
commit
2aeeeaf15b
@ -63,6 +63,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lottie-react": "^2.4.0",
|
"lottie-react": "^2.4.0",
|
||||||
|
"node-torrent": "^0.2.2",
|
||||||
"parse-torrent": "^11.0.16",
|
"parse-torrent": "^11.0.16",
|
||||||
"piscina": "^4.5.1",
|
"piscina": "^4.5.1",
|
||||||
"ps-list": "^8.1.1",
|
"ps-list": "^8.1.1",
|
||||||
@ -70,9 +71,9 @@
|
|||||||
"react-loading-skeleton": "^3.4.0",
|
"react-loading-skeleton": "^3.4.0",
|
||||||
"react-redux": "^9.1.1",
|
"react-redux": "^9.1.1",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
|
"torrent-stream": "^1.2.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"user-agents": "^1.1.193",
|
"user-agents": "^1.1.193",
|
||||||
"webtorrent": "^2.4.2",
|
|
||||||
"yaml": "^2.4.1",
|
"yaml": "^2.4.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
@ -3,18 +3,35 @@ import { spawn } from "node:child_process";
|
|||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
|
|
||||||
export const startAria2 = () => {
|
export const startAria2 = () => {
|
||||||
const binaryPath = app.isPackaged
|
const binaryPath = app.isPackaged
|
||||||
? path.join(process.resourcesPath, "aria2", "aria2c")
|
? path.join(process.resourcesPath, "aria2", "aria2c")
|
||||||
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
||||||
|
|
||||||
return spawn(
|
const aria2Process = spawn(
|
||||||
binaryPath,
|
binaryPath,
|
||||||
[
|
[
|
||||||
"--enable-rpc",
|
"--enable-rpc",
|
||||||
"--rpc-listen-all",
|
"--rpc-listen-all",
|
||||||
"--file-allocation=none",
|
"--file-allocation=none",
|
||||||
"--allow-overwrite=true",
|
"--allow-overwrite=true",
|
||||||
],
|
"--log-level=debug",
|
||||||
{ stdio: "inherit", windowsHide: true }
|
"--no-conf",
|
||||||
);
|
"--disk-cache=128M"
|
||||||
|
],
|
||||||
|
{ stdio: "inherit", windowsHide: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
aria2Process.on("error", (err) => {
|
||||||
|
console.error("Aria2 process error:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
aria2Process.on("exit", (code, signal) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.error(`Aria2 process exited with code ${code} and signal ${signal}`);
|
||||||
|
} else {
|
||||||
|
console.log("Aria2 process exited successfully");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return aria2Process;
|
||||||
};
|
};
|
||||||
|
@ -1,158 +1,230 @@
|
|||||||
import WebTorrent, { Torrent } from "webtorrent";
|
import Aria2, { StatusResponse } from "aria2";
|
||||||
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
||||||
|
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import { RealDebridClient } from "./real-debrid";
|
import { RealDebridClient } from "./real-debrid";
|
||||||
|
|
||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
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 { Game } from "@main/entity";
|
||||||
|
import { startAria2 } from "./aria2c";
|
||||||
import { sleep } from "@main/helpers";
|
import { sleep } from "@main/helpers";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
import type { ChildProcess } from "node:child_process";
|
||||||
import { publishDownloadCompleteNotification } from "./notifications";
|
import { publishDownloadCompleteNotification } from "./notifications";
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
private static downloads = new Map<number, Torrent>();
|
private static downloads = new Map<number, string>();
|
||||||
private static client = new WebTorrent();
|
|
||||||
|
private static connected = false;
|
||||||
|
private static gid: string | null = null;
|
||||||
private static game: Game | null = null;
|
private static game: Game | null = null;
|
||||||
private static realDebridTorrentId: string | null = null;
|
private static realDebridTorrentId: string | null = null;
|
||||||
private static statusUpdateInterval: NodeJS.Timeout | 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() {
|
||||||
|
if (this.aria2c) {
|
||||||
|
this.aria2c.kill();
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static getETA(
|
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) {
|
||||||
return (remainingBytes / speed) * 1000;
|
return (remainingBytes / speed) * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getFolderName(torrent: Torrent) {
|
private static getFolderName(status: StatusResponse) {
|
||||||
return torrent.name;
|
if (status.bittorrent?.info) return status.bittorrent.info.name;
|
||||||
}
|
|
||||||
|
|
||||||
private static async getRealDebridDownloadUrl() {
|
const [file] = status.files;
|
||||||
try {
|
if (file) return path.win32.basename(file.path);
|
||||||
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))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error getting RealDebrid download URL:", error);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async updateDownloadStatus() {
|
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() {
|
||||||
if (!this.game) return;
|
if (!this.game) return;
|
||||||
|
|
||||||
if (!this.downloads.has(this.game.id) && this.realDebridTorrentId) {
|
if (!this.gid && this.realDebridTorrentId) {
|
||||||
|
const options = { dir: this.game.downloadPath! };
|
||||||
const downloadUrl = await this.getRealDebridDownloadUrl();
|
const downloadUrl = await this.getRealDebridDownloadUrl();
|
||||||
|
|
||||||
if (downloadUrl) {
|
if (downloadUrl) {
|
||||||
this.startDownloadFromUrl(downloadUrl);
|
this.gid = await this.aria2.call("addUri", [downloadUrl], options);
|
||||||
|
this.downloads.set(this.game.id, this.gid);
|
||||||
this.realDebridTorrentId = null;
|
this.realDebridTorrentId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.downloads.has(this.game.id)) return;
|
if (!this.gid) return;
|
||||||
|
|
||||||
const torrent = this.downloads.get(this.game.id)!;
|
const status = await this.aria2.call("tellStatus", this.gid);
|
||||||
const progress = torrent.progress;
|
|
||||||
const status = torrent.done ? "complete" : "downloading";
|
|
||||||
|
|
||||||
const update: QueryDeepPartialEntity<Game> = {
|
const isDownloadingMetadata = status.bittorrent && !status.bittorrent?.info;
|
||||||
bytesDownloaded: torrent.downloaded,
|
|
||||||
fileSize: torrent.length,
|
|
||||||
status: status,
|
|
||||||
progress: progress,
|
|
||||||
};
|
|
||||||
|
|
||||||
await gameRepository.update(
|
if (status.followedBy?.length) {
|
||||||
{ id: this.game.id },
|
this.gid = status.followedBy[0];
|
||||||
{ ...update, status, folderName: this.getFolderName(torrent) }
|
this.downloads.set(this.game.id, this.gid);
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress =
|
||||||
|
Number(status.completedLength) / Number(status.totalLength);
|
||||||
|
|
||||||
|
if (!isDownloadingMetadata) {
|
||||||
|
const update: QueryDeepPartialEntity<Game> = {
|
||||||
|
bytesDownloaded: Number(status.completedLength),
|
||||||
|
fileSize: Number(status.totalLength),
|
||||||
|
status: status.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isNaN(progress)) update.progress = progress;
|
||||||
|
|
||||||
|
await gameRepository.update(
|
||||||
|
{ id: this.game.id },
|
||||||
|
{
|
||||||
|
...update,
|
||||||
|
status: status.status,
|
||||||
|
folderName: this.getFolderName(status),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const game = await gameRepository.findOne({
|
const game = await gameRepository.findOne({
|
||||||
where: { id: this.game.id, isDeleted: false },
|
where: { id: this.game.id, isDeleted: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (WindowManager.mainWindow && game) {
|
if (WindowManager.mainWindow && game) {
|
||||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
if (!isNaN(progress))
|
||||||
|
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
numPeers: torrent.numPeers,
|
numPeers: Number(status.connections),
|
||||||
numSeeds: torrent.numPeers, // WebTorrent doesn't differentiate between seeds and peers
|
numSeeds: Number(status.numSeeders ?? 0),
|
||||||
downloadSpeed: torrent.downloadSpeed,
|
downloadSpeed: Number(status.downloadSpeed),
|
||||||
timeRemaining: this.getETA(
|
timeRemaining: this.getETA(
|
||||||
torrent.length,
|
Number(status.totalLength),
|
||||||
torrent.downloaded,
|
Number(status.completedLength),
|
||||||
torrent.downloadSpeed
|
Number(status.downloadSpeed)
|
||||||
),
|
),
|
||||||
isDownloadingMetadata: false,
|
isDownloadingMetadata: !!isDownloadingMetadata,
|
||||||
game,
|
game,
|
||||||
} as DownloadProgress;
|
} as DownloadProgress;
|
||||||
|
|
||||||
WindowManager.mainWindow.webContents.send(
|
WindowManager.mainWindow.webContents.send(
|
||||||
"on-download-progress",
|
"on-download-progress",
|
||||||
JSON.parse(JSON.stringify(payload))
|
JSON.parse(JSON.stringify(payload))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progress === 1 && this.game) {
|
if (progress === 1 && this.game && !isDownloadingMetadata) {
|
||||||
publishDownloadCompleteNotification(this.game);
|
publishDownloadCompleteNotification(this.game);
|
||||||
|
|
||||||
await downloadQueueRepository.delete({ game: this.game });
|
await downloadQueueRepository.delete({ game: this.game });
|
||||||
this.clearCurrentDownload();
|
|
||||||
|
/*
|
||||||
|
Only cancel bittorrent downloads to stop seeding
|
||||||
|
*/
|
||||||
|
if (status.bittorrent) {
|
||||||
|
await this.cancelDownload(this.game.id);
|
||||||
|
} else {
|
||||||
|
this.clearCurrentDownload();
|
||||||
|
}
|
||||||
|
|
||||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||||
order: { id: "DESC" },
|
order: {
|
||||||
relations: { game: true },
|
id: "DESC",
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
game: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nextQueueItem) {
|
if (nextQueueItem) {
|
||||||
@ -164,98 +236,69 @@ export class DownloadManager {
|
|||||||
private static clearCurrentDownload() {
|
private static clearCurrentDownload() {
|
||||||
if (this.game) {
|
if (this.game) {
|
||||||
this.downloads.delete(this.game.id);
|
this.downloads.delete(this.game.id);
|
||||||
|
this.gid = null;
|
||||||
this.game = null;
|
this.game = null;
|
||||||
this.realDebridTorrentId = null;
|
this.realDebridTorrentId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async cancelDownload(gameId: number) {
|
static async cancelDownload(gameId: number) {
|
||||||
try {
|
const gid = this.downloads.get(gameId);
|
||||||
const torrent = this.downloads.get(gameId);
|
|
||||||
if (torrent) {
|
if (gid) {
|
||||||
torrent.destroy();
|
await this.aria2.call("forceRemove", gid);
|
||||||
|
|
||||||
|
if (this.gid === gid) {
|
||||||
|
this.clearCurrentDownload();
|
||||||
|
|
||||||
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
|
} else {
|
||||||
this.downloads.delete(gameId);
|
this.downloads.delete(gameId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error canceling download:", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async pauseDownload() {
|
static async pauseDownload() {
|
||||||
if (this.game) {
|
if (this.gid) {
|
||||||
const torrent = this.downloads.get(this.game.id);
|
await this.aria2.call("forcePause", this.gid);
|
||||||
if (torrent) {
|
this.gid = null;
|
||||||
torrent.pause();
|
|
||||||
}
|
|
||||||
this.game = null;
|
|
||||||
this.realDebridTorrentId = null;
|
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
|
||||||
if (this.statusUpdateInterval) {
|
|
||||||
clearInterval(this.statusUpdateInterval);
|
|
||||||
this.statusUpdateInterval = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.game = null;
|
||||||
|
this.realDebridTorrentId = null;
|
||||||
|
|
||||||
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async resumeDownload(game: Game) {
|
static async resumeDownload(game: Game) {
|
||||||
try {
|
if (this.downloads.has(game.id)) {
|
||||||
if (this.downloads.has(game.id)) {
|
const gid = this.downloads.get(game.id)!;
|
||||||
const torrent = this.downloads.get(game.id)!;
|
await this.aria2.call("unpause", gid);
|
||||||
torrent.resume();
|
|
||||||
this.game = game;
|
this.gid = gid;
|
||||||
this.realDebridTorrentId = null;
|
this.game = game;
|
||||||
this.startStatusUpdateInterval();
|
this.realDebridTorrentId = null;
|
||||||
} else {
|
} else {
|
||||||
return this.startDownload(game);
|
return this.startDownload(game);
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error resuming download:", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async startDownload(game: Game) {
|
static async startDownload(game: Game) {
|
||||||
try {
|
if (!this.connected) await this.connect();
|
||||||
const options = { path: game.downloadPath! };
|
|
||||||
|
|
||||||
if (game.downloader === Downloader.RealDebrid) {
|
const options = {
|
||||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
|
dir: game.downloadPath!,
|
||||||
game.uri!
|
};
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.startDownloadFromUrl(game.uri!, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.game = game;
|
if (game.downloader === Downloader.RealDebrid) {
|
||||||
this.startStatusUpdateInterval();
|
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
|
||||||
} catch (error) {
|
game!.uri!
|
||||||
logger.error("Error starting download:", error);
|
);
|
||||||
|
} else {
|
||||||
|
this.gid = await this.aria2.call("addUri", [game.uri!], options);
|
||||||
|
this.downloads.set(game.id, this.gid);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static startDownloadFromUrl(url: string, options?: any) {
|
this.game = game;
|
||||||
this.client.add(url, options, (torrent) => {
|
|
||||||
this.downloads.set(this.game!.id, torrent);
|
|
||||||
|
|
||||||
torrent.on("download", () => {
|
|
||||||
// We handle status updates with a setInterval now
|
|
||||||
});
|
|
||||||
|
|
||||||
torrent.on("done", () => {
|
|
||||||
this.updateDownloadStatus();
|
|
||||||
});
|
|
||||||
|
|
||||||
torrent.on("error", (err) => {
|
|
||||||
logger.error("Torrent error:", err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static startStatusUpdateInterval() {
|
|
||||||
if (this.statusUpdateInterval) {
|
|
||||||
clearInterval(this.statusUpdateInterval);
|
|
||||||
}
|
|
||||||
this.statusUpdateInterval = setInterval(() => {
|
|
||||||
this.updateDownloadStatus();
|
|
||||||
}, 5000); // Update every 5 seconds
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user