This commit is contained in:
Andy 2024-06-26 16:33:19 +02:00 committed by GitHub
parent 94284a427f
commit df839eddaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,58 +1,22 @@
import Aria2, { StatusResponse } from "aria2"; import WebTorrent, { Torrent } from "webtorrent";
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, string>(); private static downloads = new Map<number, Torrent>();
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 aria2c: ChildProcess | null = null; private static statusUpdateInterval: NodeJS.Timeout | 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,
@ -60,141 +24,118 @@ export class DownloadManager {
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(status: StatusResponse) { private static getFolderName(torrent: Torrent) {
if (status.bittorrent?.info) return status.bittorrent.info.name; return torrent.name;
const [file] = status.files;
if (file) return path.win32.basename(file.path);
return null;
} }
private static async getRealDebridDownloadUrl() { private static async getRealDebridDownloadUrl() {
if (this.realDebridTorrentId) { try {
const torrentInfo = await RealDebridClient.getTorrentInfo( if (this.realDebridTorrentId) {
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))
); );
} 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;
} }
public static async watchDownloads() { private static async updateDownloadStatus() {
if (!this.game) return; if (!this.game) return;
if (!this.gid && this.realDebridTorrentId) { if (!this.downloads.has(this.game.id) && this.realDebridTorrentId) {
const options = { dir: this.game.downloadPath! };
const downloadUrl = await this.getRealDebridDownloadUrl(); const downloadUrl = await this.getRealDebridDownloadUrl();
if (downloadUrl) { if (downloadUrl) {
this.gid = await this.aria2.call("addUri", [downloadUrl], options); this.startDownloadFromUrl(downloadUrl);
this.downloads.set(this.game.id, this.gid);
this.realDebridTorrentId = null; this.realDebridTorrentId = null;
} }
} }
if (!this.gid) return; if (!this.downloads.has(this.game.id)) return;
const status = await this.aria2.call("tellStatus", this.gid); const torrent = this.downloads.get(this.game.id)!;
const progress = torrent.progress;
const status = torrent.done ? "complete" : "downloading";
const isDownloadingMetadata = status.bittorrent && !status.bittorrent?.info; const update: QueryDeepPartialEntity<Game> = {
bytesDownloaded: torrent.downloaded,
fileSize: torrent.length,
status: status,
progress: progress,
};
if (status.followedBy?.length) { await gameRepository.update(
this.gid = status.followedBy[0]; { id: this.game.id },
this.downloads.set(this.game.id, this.gid); { ...update, status, folderName: this.getFolderName(torrent) }
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) {
if (!isNaN(progress)) WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
const payload = { const payload = {
numPeers: Number(status.connections), numPeers: torrent.numPeers,
numSeeds: Number(status.numSeeders ?? 0), numSeeds: torrent.numPeers, // WebTorrent doesn't differentiate between seeds and peers
downloadSpeed: Number(status.downloadSpeed), downloadSpeed: torrent.downloadSpeed,
timeRemaining: this.getETA( timeRemaining: this.getETA(
Number(status.totalLength), torrent.length,
Number(status.completedLength), torrent.downloaded,
Number(status.downloadSpeed) torrent.downloadSpeed
), ),
isDownloadingMetadata: !!isDownloadingMetadata, isDownloadingMetadata: false,
game, game,
} as DownloadProgress; } as DownloadProgress;
@ -204,27 +145,14 @@ export class DownloadManager {
); );
} }
if (progress === 1 && this.game && !isDownloadingMetadata) { if (progress === 1 && this.game) {
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: { order: { id: "DESC" },
id: "DESC", relations: { game: true },
},
relations: {
game: true,
},
}); });
if (nextQueueItem) { if (nextQueueItem) {
@ -236,69 +164,98 @@ 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) {
const gid = this.downloads.get(gameId); try {
const torrent = this.downloads.get(gameId);
if (gid) { if (torrent) {
await this.aria2.call("forceRemove", gid); torrent.destroy();
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.gid) { if (this.game) {
await this.aria2.call("forcePause", this.gid); const torrent = this.downloads.get(this.game.id);
this.gid = null; if (torrent) {
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) {
if (this.downloads.has(game.id)) { try {
const gid = this.downloads.get(game.id)!; if (this.downloads.has(game.id)) {
await this.aria2.call("unpause", gid); const torrent = this.downloads.get(game.id)!;
torrent.resume();
this.gid = gid; this.game = game;
this.game = game; this.realDebridTorrentId = null;
this.realDebridTorrentId = null; this.startStatusUpdateInterval();
} 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) {
if (!this.connected) await this.connect(); try {
const options = { path: game.downloadPath! };
const options = { if (game.downloader === Downloader.RealDebrid) {
dir: game.downloadPath!, this.realDebridTorrentId = await RealDebridClient.getTorrentId(
}; game.uri!
);
} else {
this.startDownloadFromUrl(game.uri!, options);
}
if (game.downloader === Downloader.RealDebrid) { this.game = game;
this.realDebridTorrentId = await RealDebridClient.getTorrentId( this.startStatusUpdateInterval();
game!.uri! } catch (error) {
); logger.error("Error starting download:", error);
} else {
this.gid = await this.aria2.call("addUri", [game.uri!], options);
this.downloads.set(game.id, this.gid);
} }
}
this.game = game; 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
} }
} }