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", "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"
}, },

View File

@ -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;
}; };

View File

@ -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
} }
} }

691
yarn.lock

File diff suppressed because it is too large Load Diff