Fixing downloads by replacing the torrent client

This commit is contained in:
tuesday 2024-06-26 17:57:19 +02:00
parent ccef34c15c
commit 2aeeeaf15b
4 changed files with 920 additions and 184 deletions

View File

@ -63,6 +63,7 @@
"jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.0",
"node-torrent": "^0.2.2",
"parse-torrent": "^11.0.16",
"piscina": "^4.5.1",
"ps-list": "^8.1.1",
@ -70,9 +71,9 @@
"react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1",
"react-router-dom": "^6.22.3",
"torrent-stream": "^1.2.1",
"typeorm": "^0.3.20",
"user-agents": "^1.1.193",
"webtorrent": "^2.4.2",
"yaml": "^2.4.1",
"zod": "^3.23.8"
},

View File

@ -7,14 +7,31 @@ export const startAria2 = () => {
? path.join(process.resourcesPath, "aria2", "aria2c")
: path.join(__dirname, "..", "..", "aria2", "aria2c");
return spawn(
const aria2Process = spawn(
binaryPath,
[
"--enable-rpc",
"--rpc-listen-all",
"--file-allocation=none",
"--allow-overwrite=true",
"--log-level=debug",
"--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;
};

View File

@ -1,22 +1,58 @@
import WebTorrent, { Torrent } from "webtorrent";
import Aria2, { StatusResponse } from "aria2";
import path from "node:path";
import { downloadQueueRepository, gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager";
import { RealDebridClient } from "./real-debrid";
import { Downloader } from "@shared";
import { DownloadProgress } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { Game } from "@main/entity";
import { startAria2 } from "./aria2c";
import { sleep } from "@main/helpers";
import { logger } from "./logger";
import type { ChildProcess } from "node:child_process";
import { publishDownloadCompleteNotification } from "./notifications";
export class DownloadManager {
private static downloads = new Map<number, Torrent>();
private static client = new WebTorrent();
private static downloads = new Map<number, string>();
private static connected = false;
private static gid: string | null = null;
private static game: Game | 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(
totalLength: number,
@ -24,22 +60,29 @@ export class DownloadManager {
speed: number
) {
const remainingBytes = totalLength - completedLength;
if (remainingBytes >= 0 && speed > 0) {
return (remainingBytes / speed) * 1000;
}
return -1;
}
private static getFolderName(torrent: Torrent) {
return torrent.name;
private static getFolderName(status: StatusResponse) {
if (status.bittorrent?.info) return status.bittorrent.info.name;
const [file] = status.files;
if (file) return path.win32.basename(file.path);
return null;
}
private static async getRealDebridDownloadUrl() {
try {
if (this.realDebridTorrentId) {
const torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
const { status, links } = torrentInfo;
if (status === "waiting_files_selection") {
@ -57,9 +100,7 @@ export class DownloadManager {
const progress = torrentInfo.progress / 100;
const totalDownloaded = progress * torrentInfo.bytes;
WindowManager.mainWindow.setProgressBar(
progress === 1 ? -1 : progress
);
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
const payload = {
numPeers: 0,
@ -84,58 +125,76 @@ export class DownloadManager {
);
}
}
} catch (error) {
logger.error("Error getting RealDebrid download URL:", error);
}
return null;
}
private static async updateDownloadStatus() {
public static async watchDownloads() {
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();
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;
}
}
if (!this.downloads.has(this.game.id)) return;
if (!this.gid) return;
const torrent = this.downloads.get(this.game.id)!;
const progress = torrent.progress;
const status = torrent.done ? "complete" : "downloading";
const status = await this.aria2.call("tellStatus", this.gid);
const isDownloadingMetadata = status.bittorrent && !status.bittorrent?.info;
if (status.followedBy?.length) {
this.gid = status.followedBy[0];
this.downloads.set(this.game.id, this.gid);
return;
}
const progress =
Number(status.completedLength) / Number(status.totalLength);
if (!isDownloadingMetadata) {
const update: QueryDeepPartialEntity<Game> = {
bytesDownloaded: torrent.downloaded,
fileSize: torrent.length,
status: status,
progress: progress,
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, folderName: this.getFolderName(torrent) }
{
...update,
status: status.status,
folderName: this.getFolderName(status),
}
);
}
const game = await gameRepository.findOne({
where: { id: this.game.id, isDeleted: false },
});
if (WindowManager.mainWindow && game) {
if (!isNaN(progress))
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
const payload = {
numPeers: torrent.numPeers,
numSeeds: torrent.numPeers, // WebTorrent doesn't differentiate between seeds and peers
downloadSpeed: torrent.downloadSpeed,
numPeers: Number(status.connections),
numSeeds: Number(status.numSeeders ?? 0),
downloadSpeed: Number(status.downloadSpeed),
timeRemaining: this.getETA(
torrent.length,
torrent.downloaded,
torrent.downloadSpeed
Number(status.totalLength),
Number(status.completedLength),
Number(status.downloadSpeed)
),
isDownloadingMetadata: false,
isDownloadingMetadata: !!isDownloadingMetadata,
game,
} as DownloadProgress;
@ -145,14 +204,27 @@ export class DownloadManager {
);
}
if (progress === 1 && this.game) {
if (progress === 1 && this.game && !isDownloadingMetadata) {
publishDownloadCompleteNotification(this.game);
await downloadQueueRepository.delete({ game: this.game });
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
const [nextQueueItem] = await downloadQueueRepository.find({
order: { id: "DESC" },
relations: { game: true },
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (nextQueueItem) {
@ -164,98 +236,69 @@ export class DownloadManager {
private static clearCurrentDownload() {
if (this.game) {
this.downloads.delete(this.game.id);
this.gid = null;
this.game = null;
this.realDebridTorrentId = null;
}
}
static async cancelDownload(gameId: number) {
try {
const torrent = this.downloads.get(gameId);
if (torrent) {
torrent.destroy();
const gid = this.downloads.get(gameId);
if (gid) {
await this.aria2.call("forceRemove", gid);
if (this.gid === gid) {
this.clearCurrentDownload();
WindowManager.mainWindow?.setProgressBar(-1);
} else {
this.downloads.delete(gameId);
}
} catch (error) {
logger.error("Error canceling download:", error);
}
}
static async pauseDownload() {
if (this.game) {
const torrent = this.downloads.get(this.game.id);
if (torrent) {
torrent.pause();
if (this.gid) {
await this.aria2.call("forcePause", this.gid);
this.gid = null;
}
this.game = null;
this.realDebridTorrentId = null;
WindowManager.mainWindow?.setProgressBar(-1);
if (this.statusUpdateInterval) {
clearInterval(this.statusUpdateInterval);
this.statusUpdateInterval = null;
}
}
}
static async resumeDownload(game: Game) {
try {
if (this.downloads.has(game.id)) {
const torrent = this.downloads.get(game.id)!;
torrent.resume();
const gid = this.downloads.get(game.id)!;
await this.aria2.call("unpause", gid);
this.gid = gid;
this.game = game;
this.realDebridTorrentId = null;
this.startStatusUpdateInterval();
} else {
return this.startDownload(game);
}
} catch (error) {
logger.error("Error resuming download:", error);
}
}
static async startDownload(game: Game) {
try {
const options = { path: game.downloadPath! };
if (!this.connected) await this.connect();
const options = {
dir: game.downloadPath!,
};
if (game.downloader === Downloader.RealDebrid) {
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
game.uri!
game!.uri!
);
} else {
this.startDownloadFromUrl(game.uri!, options);
this.gid = await this.aria2.call("addUri", [game.uri!], options);
this.downloads.set(game.id, this.gid);
}
this.game = game;
this.startStatusUpdateInterval();
} catch (error) {
logger.error("Error starting download:", error);
}
}
private static startDownloadFromUrl(url: string, options?: any) {
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
}
}

691
yarn.lock

File diff suppressed because it is too large Load Diff