mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
Merge branch 'feature/seed-completed-downloads' into feat/achievements-points
This commit is contained in:
commit
58a8f6fd6e
66 changed files with 1757 additions and 838 deletions
|
@ -201,7 +201,11 @@
|
|||
"queued": "Queued",
|
||||
"no_downloads_title": "Such empty",
|
||||
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start.",
|
||||
"checking_files": "Checking files…"
|
||||
"checking_files": "Checking files…",
|
||||
"seeding": "Seeding",
|
||||
"stop_seeding": "Stop seeding",
|
||||
"resume_seeding": "Resume seeding",
|
||||
"options": "Manage"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Downloads path",
|
||||
|
@ -259,6 +263,7 @@
|
|||
"enable_achievement_notifications": "When an achievement is unlocked",
|
||||
"launch_minimized": "Launch Hydra minimized",
|
||||
"disable_nsfw_alert": "Disable NSFW alert",
|
||||
"seed_after_download_complete": "Seed after download complete",
|
||||
"show_hidden_achievement_description": "Show hidden achievements description before unlocking them"
|
||||
},
|
||||
"notifications": {
|
||||
|
|
|
@ -197,7 +197,11 @@
|
|||
"queued": "Na fila",
|
||||
"no_downloads_title": "Nada por aqui…",
|
||||
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar.",
|
||||
"checking_files": "Verificando arquivos…"
|
||||
"checking_files": "Verificando arquivos…",
|
||||
"seeding": "Semeando",
|
||||
"stop_seeding": "Parar semeio",
|
||||
"resume_seeding": "Retomar semeio",
|
||||
"options": "Gerenciar"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Diretório dos downloads",
|
||||
|
@ -255,6 +259,7 @@
|
|||
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
|
||||
"launch_minimized": "Iniciar o Hydra minimizado",
|
||||
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
|
||||
"seed_after_download_complete": "Semear após a conclusão do download",
|
||||
"show_hidden_achievement_description": "Mostrar descrição de conquistas ocultas antes de debloqueá-las"
|
||||
},
|
||||
"notifications": {
|
||||
|
|
|
@ -256,7 +256,8 @@
|
|||
"public": "Публичный",
|
||||
"required_field": "Это поле обязательно к заполнению",
|
||||
"source_already_exists": "Этот источник уже добавлен",
|
||||
"user_unblocked": "Пользователь разблокирован"
|
||||
"user_unblocked": "Пользователь разблокирован",
|
||||
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Загрузка завершена",
|
||||
|
|
|
@ -85,6 +85,9 @@ export class Game {
|
|||
@Column("boolean", { default: false })
|
||||
isDeleted: boolean;
|
||||
|
||||
@Column("boolean", { default: false })
|
||||
shouldSeed: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
|
|
|
@ -41,6 +41,9 @@ export class UserPreferences {
|
|||
@Column("boolean", { default: false })
|
||||
disableNsfwAlert: boolean;
|
||||
|
||||
@Column("boolean", { default: true })
|
||||
seedAfterDownloadComplete: boolean;
|
||||
|
||||
@Column("boolean", { default: false })
|
||||
showHiddenAchievementsDescription: boolean;
|
||||
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
import { registerEvent } from "../register-event";
|
||||
import {
|
||||
DownloadManager,
|
||||
HydraApi,
|
||||
PythonInstance,
|
||||
gamesPlaytime,
|
||||
} from "@main/services";
|
||||
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import { DownloadQueue, Game, UserAuth, UserSubscription } from "@main/entity";
|
||||
|
||||
|
@ -32,7 +27,8 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
|
|||
DownloadManager.cancelDownload();
|
||||
|
||||
/* Disconnects libtorrent */
|
||||
PythonInstance.killTorrent();
|
||||
// TODO
|
||||
// TorrentDownloader.killTorrent();
|
||||
|
||||
HydraApi.handleSignOut();
|
||||
|
||||
|
|
|
@ -32,6 +32,8 @@ import "./torrenting/cancel-game-download";
|
|||
import "./torrenting/pause-game-download";
|
||||
import "./torrenting/resume-game-download";
|
||||
import "./torrenting/start-game-download";
|
||||
import "./torrenting/pause-game-seed";
|
||||
import "./torrenting/resume-game-seed";
|
||||
import "./user-preferences/get-user-preferences";
|
||||
import "./user-preferences/update-user-preferences";
|
||||
import "./user-preferences/auto-launch";
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { gameRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { PythonInstance, logger } from "@main/services";
|
||||
import { logger } from "@main/services";
|
||||
import sudo from "sudo-prompt";
|
||||
import { app } from "electron";
|
||||
import { PythonRPC } from "@main/services/python-rpc";
|
||||
import { ProcessPayload } from "@main/services/download/types";
|
||||
|
||||
const getKillCommand = (pid: number) => {
|
||||
if (process.platform == "win32") {
|
||||
|
@ -16,7 +18,10 @@ const closeGame = async (
|
|||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number
|
||||
) => {
|
||||
const processes = await PythonInstance.getProcessList();
|
||||
const processes =
|
||||
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
|
||||
[];
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
});
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { registerEvent } from "../register-event";
|
||||
import { PythonInstance } from "@main/services";
|
||||
|
||||
const processProfileImage = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
path: string
|
||||
) => {
|
||||
return PythonInstance.processProfileImage(path);
|
||||
return path;
|
||||
// return PythonInstance.processProfileImage(path);
|
||||
};
|
||||
|
||||
registerEvent("processProfileImage", processProfileImage);
|
||||
|
|
20
src/main/events/torrenting/pause-game-seed.ts
Normal file
20
src/main/events/torrenting/pause-game-seed.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { registerEvent } from "../register-event";
|
||||
|
||||
import { DownloadManager } from "@main/services";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import { Game } from "@main/entity";
|
||||
|
||||
const pauseGameSeed = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number
|
||||
) => {
|
||||
await dataSource.transaction(async (transactionalEntityManager) => {
|
||||
await transactionalEntityManager
|
||||
.getRepository(Game)
|
||||
.update({ id: gameId }, { status: "complete", shouldSeed: false });
|
||||
});
|
||||
|
||||
await DownloadManager.cancelDownload(gameId);
|
||||
};
|
||||
|
||||
registerEvent("pauseGameSeed", pauseGameSeed);
|
32
src/main/events/torrenting/resume-game-seed.ts
Normal file
32
src/main/events/torrenting/resume-game-seed.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { registerEvent } from "../register-event";
|
||||
import { gameRepository } from "../../repository";
|
||||
|
||||
import { DownloadManager } from "@main/services";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import { Game } from "@main/entity";
|
||||
|
||||
const resumeGameSeed = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number
|
||||
) => {
|
||||
const game = await gameRepository.findOne({
|
||||
where: {
|
||||
id: gameId,
|
||||
isDeleted: false,
|
||||
downloader: 1,
|
||||
progress: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!game) return;
|
||||
|
||||
await dataSource.transaction(async (transactionalEntityManager) => {
|
||||
await transactionalEntityManager
|
||||
.getRepository(Game)
|
||||
.update({ id: gameId }, { status: "seeding", shouldSeed: true });
|
||||
});
|
||||
|
||||
await DownloadManager.startDownload(game);
|
||||
};
|
||||
|
||||
registerEvent("resumeGameSeed", resumeGameSeed);
|
|
@ -1,4 +1,4 @@
|
|||
import { RealDebridClient } from "@main/services/real-debrid";
|
||||
import { RealDebridClient } from "@main/services/download/real-debrid";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const authenticateRealDebrid = async (
|
||||
|
|
|
@ -5,12 +5,14 @@ import path from "node:path";
|
|||
import url from "node:url";
|
||||
import fs from "node:fs";
|
||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
||||
import { logger, PythonInstance, WindowManager } from "@main/services";
|
||||
import { logger, WindowManager } from "@main/services";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import resources from "@locales";
|
||||
import { userPreferencesRepository } from "@main/repository";
|
||||
import { knexClient, migrationConfig } from "./knex-client";
|
||||
import { databaseDirectory } from "./constants";
|
||||
import { PythonRPC } from "./services/python-rpc";
|
||||
import { Aria2 } from "./services/aria2";
|
||||
|
||||
const { autoUpdater } = updater;
|
||||
|
||||
|
@ -146,7 +148,8 @@ app.on("window-all-closed", () => {
|
|||
|
||||
app.on("before-quit", () => {
|
||||
/* Disconnects libtorrent */
|
||||
PythonInstance.kill();
|
||||
PythonRPC.kill();
|
||||
Aria2.kill();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
|
|
|
@ -13,6 +13,8 @@ import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_backgroun
|
|||
import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game";
|
||||
import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_column";
|
||||
import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disable_nsfw_alert_column";
|
||||
import { AddShouldSeedColumn } from "./migrations/20241108200154_add_should_seed_colum";
|
||||
import { AddSeedAfterDownloadColumn } from "./migrations/20241108201806_add_seed_after_download";
|
||||
import { AddHiddenAchievementDescriptionColumn } from "./migrations/20241216140633_add_hidden_achievement_description_column ";
|
||||
|
||||
export type HydraMigration = Knex.Migration & { name: string };
|
||||
|
@ -32,6 +34,8 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
|||
AddWinePrefixToGame,
|
||||
AddStartMinimizedColumn,
|
||||
AddDisableNsfwAlertColumn,
|
||||
AddShouldSeedColumn,
|
||||
AddSeedAfterDownloadColumn,
|
||||
AddHiddenAchievementDescriptionColumn,
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -1,21 +1,20 @@
|
|||
import { Ludusavi, startMainLoop } from "./services";
|
||||
import {
|
||||
DownloadManager,
|
||||
Ludusavi,
|
||||
PythonInstance,
|
||||
startMainLoop,
|
||||
} from "./services";
|
||||
import {
|
||||
downloadQueueRepository,
|
||||
// downloadQueueRepository,
|
||||
userPreferencesRepository,
|
||||
} from "./repository";
|
||||
import { UserPreferences } from "./entity";
|
||||
import { RealDebridClient } from "./services/real-debrid";
|
||||
import { RealDebridClient } from "./services/download/real-debrid";
|
||||
import { HydraApi } from "./services/hydra-api";
|
||||
import { uploadGamesBatch } from "./services/library-sync";
|
||||
import { PythonRPC } from "./services/python-rpc";
|
||||
import { Aria2 } from "./services/aria2";
|
||||
|
||||
const loadState = async (userPreferences: UserPreferences | null) => {
|
||||
import("./events");
|
||||
|
||||
Aria2.spawn();
|
||||
|
||||
if (userPreferences?.realDebridApiToken) {
|
||||
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
||||
}
|
||||
|
@ -26,20 +25,23 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
|||
uploadGamesBatch();
|
||||
});
|
||||
|
||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||
order: {
|
||||
id: "DESC",
|
||||
},
|
||||
relations: {
|
||||
game: true,
|
||||
},
|
||||
});
|
||||
// const [nextQueueItem] = await downloadQueueRepository.find({
|
||||
// order: {
|
||||
// id: "DESC",
|
||||
// },
|
||||
// relations: {
|
||||
// game: true,
|
||||
// },
|
||||
// });
|
||||
|
||||
if (nextQueueItem?.game.status === "active") {
|
||||
DownloadManager.startDownload(nextQueueItem.game);
|
||||
} else {
|
||||
PythonInstance.spawn();
|
||||
}
|
||||
PythonRPC.spawn();
|
||||
// start download
|
||||
|
||||
// if (nextQueueItem?.game.status === "active") {
|
||||
// DownloadManager.startDownload(nextQueueItem.game);
|
||||
// } else {
|
||||
// PythonInstance.spawn();
|
||||
// }
|
||||
|
||||
startMainLoop();
|
||||
};
|
||||
|
|
17
src/main/migrations/20241108200154_add_should_seed_colum.ts
Normal file
17
src/main/migrations/20241108200154_add_should_seed_colum.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import type { HydraMigration } from "@main/knex-client";
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export const AddShouldSeedColumn: HydraMigration = {
|
||||
name: "AddShouldSeedColumn",
|
||||
up: (knex: Knex) => {
|
||||
return knex.schema.alterTable("game", (table) => {
|
||||
return table.boolean("shouldSeed").notNullable().defaultTo(true);
|
||||
});
|
||||
},
|
||||
|
||||
down: async (knex: Knex) => {
|
||||
return knex.schema.alterTable("game", (table) => {
|
||||
return table.dropColumn("shouldSeed");
|
||||
});
|
||||
},
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
import type { HydraMigration } from "@main/knex-client";
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export const AddSeedAfterDownloadColumn: HydraMigration = {
|
||||
name: "AddSeedAfterDownloadColumn",
|
||||
up: (knex: Knex) => {
|
||||
return knex.schema.alterTable("user_preferences", (table) => {
|
||||
return table
|
||||
.boolean("seedAfterDownloadComplete")
|
||||
.notNullable()
|
||||
.defaultTo(true);
|
||||
});
|
||||
},
|
||||
|
||||
down: async (knex: Knex) => {
|
||||
return knex.schema.alterTable("user_preferences", (table) => {
|
||||
return table.dropColumn("seedAfterDownloadComplete");
|
||||
});
|
||||
},
|
||||
};
|
32
src/main/services/aria2.ts
Normal file
32
src/main/services/aria2.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import { app } from "electron";
|
||||
|
||||
export const startAria2 = () => {};
|
||||
|
||||
export class Aria2 {
|
||||
private static process: cp.ChildProcess | null = null;
|
||||
|
||||
public static spawn() {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2", "aria2c")
|
||||
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
||||
|
||||
this.process = cp.spawn(
|
||||
binaryPath,
|
||||
[
|
||||
"--enable-rpc",
|
||||
"--rpc-listen-all",
|
||||
"--file-allocation=none",
|
||||
"--allow-overwrite=true",
|
||||
],
|
||||
{ stdio: "inherit", windowsHide: true }
|
||||
);
|
||||
|
||||
console.log(this.process);
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
this.process?.kill();
|
||||
}
|
||||
}
|
|
@ -1,39 +1,102 @@
|
|||
import { Game } from "@main/entity";
|
||||
import { Downloader } from "@shared";
|
||||
import { PythonInstance } from "./python-instance";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
||||
import {
|
||||
downloadQueueRepository,
|
||||
gameRepository,
|
||||
userPreferencesRepository,
|
||||
} from "@main/repository";
|
||||
import { publishDownloadCompleteNotification } from "../notifications";
|
||||
import { RealDebridDownloader } from "./real-debrid-downloader";
|
||||
import type { DownloadProgress } from "@types";
|
||||
import { GofileApi, QiwiApi } from "../hosters";
|
||||
import { GenericHttpDownloader } from "./generic-http-downloader";
|
||||
import { PythonRPC } from "../python-rpc";
|
||||
import {
|
||||
LibtorrentPayload,
|
||||
LibtorrentStatus,
|
||||
PauseDownloadPayload,
|
||||
} from "./types";
|
||||
import { calculateETA, getDirSize } from "./helpers";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { RealDebridClient } from "./real-debrid";
|
||||
import path from "path";
|
||||
|
||||
export class DownloadManager {
|
||||
private static currentDownloader: Downloader | null = null;
|
||||
private static downloadingGameId: number | null = null;
|
||||
|
||||
public static async watchDownloads() {
|
||||
let status: DownloadProgress | null = null;
|
||||
private static async getDownloadStatus() {
|
||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
||||
"/status"
|
||||
);
|
||||
|
||||
if (this.currentDownloader === Downloader.Torrent) {
|
||||
status = await PythonInstance.getStatus();
|
||||
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
status = await RealDebridDownloader.getStatus();
|
||||
} else {
|
||||
status = await GenericHttpDownloader.getStatus();
|
||||
if (response.data === null || !this.downloadingGameId) return null;
|
||||
|
||||
const gameId = this.downloadingGameId;
|
||||
|
||||
try {
|
||||
const {
|
||||
progress,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
folderName,
|
||||
status,
|
||||
} = response.data;
|
||||
|
||||
const isDownloadingMetadata =
|
||||
status === LibtorrentStatus.DownloadingMetadata;
|
||||
|
||||
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
||||
|
||||
if (!isDownloadingMetadata && !isCheckingFiles) {
|
||||
const update: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
progress,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: gameId },
|
||||
{
|
||||
...update,
|
||||
folderName,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
|
||||
isDownloadingMetadata,
|
||||
isCheckingFiles,
|
||||
progress,
|
||||
gameId,
|
||||
} as DownloadProgress;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static async watchDownloads() {
|
||||
const status = await this.getDownloadStatus();
|
||||
|
||||
// // status = await RealDebridDownloader.getStatus();
|
||||
|
||||
if (status) {
|
||||
const { gameId, progress } = status;
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
});
|
||||
const userPreferences = await userPreferencesRepository.findOneBy({
|
||||
id: 1,
|
||||
});
|
||||
|
||||
if (WindowManager.mainWindow && game) {
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(
|
||||
|
@ -44,12 +107,27 @@ export class DownloadManager {
|
|||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (progress === 1 && game) {
|
||||
publishDownloadCompleteNotification(game);
|
||||
|
||||
await downloadQueueRepository.delete({ game });
|
||||
if (
|
||||
userPreferences?.seedAfterDownloadComplete &&
|
||||
game.downloader === Downloader.Torrent
|
||||
) {
|
||||
gameRepository.update(
|
||||
{ id: gameId },
|
||||
{ status: "seeding", shouldSeed: true }
|
||||
);
|
||||
} else {
|
||||
gameRepository.update(
|
||||
{ id: gameId },
|
||||
{ status: "complete", shouldSeed: false }
|
||||
);
|
||||
|
||||
this.cancelDownload(gameId);
|
||||
}
|
||||
|
||||
await downloadQueueRepository.delete({ game });
|
||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||
order: {
|
||||
id: "DESC",
|
||||
|
@ -58,25 +136,117 @@ export class DownloadManager {
|
|||
game: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (nextQueueItem) {
|
||||
this.resumeDownload(nextQueueItem.game);
|
||||
} else {
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async getSeedStatus() {
|
||||
const seedStatus = await PythonRPC.rpc.get<LibtorrentPayload[] | []>(
|
||||
"/seed-status"
|
||||
);
|
||||
|
||||
if (!seedStatus.data.length) return;
|
||||
|
||||
seedStatus.data.forEach(async (status) => {
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: status.gameId },
|
||||
});
|
||||
|
||||
if (!game) return;
|
||||
|
||||
const totalSize = await getDirSize(
|
||||
path.join(game.downloadPath!, status.folderName!)
|
||||
);
|
||||
|
||||
if (totalSize < status.fileSize) {
|
||||
await this.cancelDownload(game.id);
|
||||
|
||||
await gameRepository.update(game.id, {
|
||||
status: "paused",
|
||||
shouldSeed: false,
|
||||
progress: totalSize / status.fileSize,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send("on-hard-delete");
|
||||
}
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-seeding-status",
|
||||
JSON.parse(JSON.stringify(seedStatus.data))
|
||||
);
|
||||
|
||||
// const gamesToSeed = await gameRepository.find({
|
||||
// where: { shouldSeed: true, isDeleted: false },
|
||||
// });
|
||||
// if (gamesToSeed.length === 0) return;
|
||||
// const seedStatus = await PythonRPC.rpc
|
||||
// .get<LibtorrentPayload[] | null>("/seed-status")
|
||||
// .then((results) => {
|
||||
// if (results === null) return [];
|
||||
// return results.data;
|
||||
// });
|
||||
// if (!seedStatus.length === 0) {
|
||||
// for (const game of gamesToSeed) {
|
||||
// if (game.uri && game.downloadPath) {
|
||||
// await this.resumeSeeding(game.id, game.uri, game.downloadPath);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// const gameIds = seedStatus.map((status) => status.gameId);
|
||||
// for (const gameId of gameIds) {
|
||||
// const game = await gameRepository.findOne({
|
||||
// where: { id: gameId },
|
||||
// });
|
||||
// if (game) {
|
||||
// const isNotDeleted = fs.existsSync(
|
||||
// path.join(game.downloadPath!, game.folderName!)
|
||||
// );
|
||||
// if (!isNotDeleted) {
|
||||
// await this.pauseSeeding(game.id);
|
||||
// await gameRepository.update(game.id, {
|
||||
// status: "complete",
|
||||
// shouldSeed: false,
|
||||
// });
|
||||
// WindowManager.mainWindow?.webContents.send("on-hard-delete");
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// const updateList = await gameRepository.find({
|
||||
// where: {
|
||||
// id: In(gameIds),
|
||||
// status: Not(In(["complete", "seeding"])),
|
||||
// shouldSeed: true,
|
||||
// isDeleted: false,
|
||||
// },
|
||||
// });
|
||||
// if (updateList.length > 0) {
|
||||
// await gameRepository.update(
|
||||
// { id: In(updateList.map((game) => game.id)) },
|
||||
// { status: "seeding" }
|
||||
// );
|
||||
// }
|
||||
// WindowManager.mainWindow?.webContents.send(
|
||||
// "on-seeding-status",
|
||||
// JSON.parse(JSON.stringify(seedStatus))
|
||||
// );
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
if (this.currentDownloader === Downloader.Torrent) {
|
||||
await PythonInstance.pauseDownload();
|
||||
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
await RealDebridDownloader.pauseDownload();
|
||||
} else {
|
||||
await GenericHttpDownloader.pauseDownload();
|
||||
}
|
||||
await PythonRPC.rpc
|
||||
.post("/action", {
|
||||
action: "pause",
|
||||
game_id: this.downloadingGameId,
|
||||
} as PauseDownloadPayload)
|
||||
.catch(() => {});
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
this.currentDownloader = null;
|
||||
|
||||
this.downloadingGameId = null;
|
||||
}
|
||||
|
||||
|
@ -85,16 +255,13 @@ export class DownloadManager {
|
|||
}
|
||||
|
||||
static async cancelDownload(gameId = this.downloadingGameId!) {
|
||||
if (this.currentDownloader === Downloader.Torrent) {
|
||||
PythonInstance.cancelDownload(gameId);
|
||||
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
RealDebridDownloader.cancelDownload(gameId);
|
||||
} else {
|
||||
GenericHttpDownloader.cancelDownload(gameId);
|
||||
}
|
||||
await PythonRPC.rpc.post("/action", {
|
||||
action: "cancel",
|
||||
game_id: gameId,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
this.currentDownloader = null;
|
||||
|
||||
this.downloadingGameId = null;
|
||||
}
|
||||
|
||||
|
@ -106,34 +273,57 @@ export class DownloadManager {
|
|||
const token = await GofileApi.authorize();
|
||||
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||
|
||||
GenericHttpDownloader.startDownload(game, downloadLink, {
|
||||
Cookie: `accountToken=${token}`,
|
||||
await PythonRPC.rpc.post("/action", {
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
url: downloadLink,
|
||||
save_path: game.downloadPath,
|
||||
header: `Cookie: accountToken=${token}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case Downloader.PixelDrain: {
|
||||
const id = game!.uri!.split("/").pop();
|
||||
|
||||
await GenericHttpDownloader.startDownload(
|
||||
game,
|
||||
`https://pixeldrain.com/api/file/${id}?download`
|
||||
);
|
||||
await PythonRPC.rpc.post("/action", {
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
url: `https://pixeldrain.com/api/file/${id}?download`,
|
||||
save_path: game.downloadPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case Downloader.Qiwi: {
|
||||
const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!);
|
||||
|
||||
await GenericHttpDownloader.startDownload(game, downloadUrl);
|
||||
await PythonRPC.rpc.post("/action", {
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
url: downloadUrl,
|
||||
save_path: game.downloadPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case Downloader.Torrent:
|
||||
PythonInstance.startDownload(game);
|
||||
await PythonRPC.rpc.post("/action", {
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
url: game.uri,
|
||||
save_path: game.downloadPath,
|
||||
});
|
||||
break;
|
||||
case Downloader.RealDebrid:
|
||||
RealDebridDownloader.startDownload(game);
|
||||
case Downloader.RealDebrid: {
|
||||
const downloadUrl = await RealDebridClient.getDownloadUrl(game.uri!);
|
||||
|
||||
await PythonRPC.rpc.post("/action", {
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
url: downloadUrl,
|
||||
save_path: game.downloadPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.currentDownloader = game.downloader;
|
||||
this.downloadingGameId = game.id;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
import { Game } from "@main/entity";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { calculateETA } from "./helpers";
|
||||
import { DownloadProgress } from "@types";
|
||||
import { HttpDownload } from "./http-download";
|
||||
|
||||
export class GenericHttpDownloader {
|
||||
public static downloads = new Map<number, HttpDownload>();
|
||||
public static downloadingGame: Game | null = null;
|
||||
|
||||
public static async getStatus() {
|
||||
if (this.downloadingGame) {
|
||||
const download = this.downloads.get(this.downloadingGame.id)!;
|
||||
const status = download.getStatus();
|
||||
|
||||
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",
|
||||
folderName: status.folderName,
|
||||
}
|
||||
);
|
||||
|
||||
const result = {
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
downloadSpeed: status.downloadSpeed,
|
||||
timeRemaining: calculateETA(
|
||||
status.totalLength,
|
||||
status.completedLength,
|
||||
status.downloadSpeed
|
||||
),
|
||||
isDownloadingMetadata: false,
|
||||
isCheckingFiles: false,
|
||||
progress,
|
||||
gameId: this.downloadingGame!.id,
|
||||
} as DownloadProgress;
|
||||
|
||||
if (progress === 1) {
|
||||
this.downloads.delete(this.downloadingGame.id);
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
if (this.downloadingGame) {
|
||||
const httpDownload = this.downloads.get(this.downloadingGame!.id!);
|
||||
|
||||
if (httpDownload) {
|
||||
await httpDownload.pauseDownload();
|
||||
}
|
||||
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
}
|
||||
|
||||
static async startDownload(
|
||||
game: Game,
|
||||
downloadUrl: string,
|
||||
headers?: Record<string, string>
|
||||
) {
|
||||
this.downloadingGame = game;
|
||||
|
||||
if (this.downloads.has(game.id)) {
|
||||
await this.resumeDownload(game.id!);
|
||||
return;
|
||||
}
|
||||
|
||||
const httpDownload = new HttpDownload(
|
||||
game.downloadPath!,
|
||||
downloadUrl,
|
||||
headers
|
||||
);
|
||||
|
||||
httpDownload.startDownload();
|
||||
|
||||
this.downloads.set(game.id!, httpDownload);
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
const httpDownload = this.downloads.get(gameId);
|
||||
|
||||
if (httpDownload) {
|
||||
await httpDownload.cancelDownload();
|
||||
this.downloads.delete(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
static async resumeDownload(gameId: number) {
|
||||
const httpDownload = this.downloads.get(gameId);
|
||||
|
||||
if (httpDownload) {
|
||||
await httpDownload.resumeDownload();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,6 @@
|
|||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
export const calculateETA = (
|
||||
totalLength: number,
|
||||
completedLength: number,
|
||||
|
@ -11,3 +14,26 @@ export const calculateETA = (
|
|||
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const getDirSize = async (dir: string): Promise<number> => {
|
||||
const getItemSize = async (filePath: string): Promise<number> => {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return getDirSize(filePath);
|
||||
}
|
||||
|
||||
return stat.size;
|
||||
};
|
||||
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const filePaths = files.map((file) => path.join(dir, file));
|
||||
const sizes = await Promise.all(filePaths.map(getItemSize));
|
||||
|
||||
return sizes.reduce((total, size) => total + size, 0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
import { WindowManager } from "../window-manager";
|
||||
import path from "node:path";
|
||||
|
||||
export class HttpDownload {
|
||||
private downloadItem: Electron.DownloadItem;
|
||||
|
||||
constructor(
|
||||
private downloadPath: string,
|
||||
private downloadUrl: string,
|
||||
private headers?: Record<string, string>
|
||||
) {}
|
||||
|
||||
public getStatus() {
|
||||
return {
|
||||
completedLength: this.downloadItem.getReceivedBytes(),
|
||||
totalLength: this.downloadItem.getTotalBytes(),
|
||||
downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(),
|
||||
folderName: this.downloadItem.getFilename(),
|
||||
};
|
||||
}
|
||||
|
||||
async cancelDownload() {
|
||||
this.downloadItem.cancel();
|
||||
}
|
||||
|
||||
async pauseDownload() {
|
||||
this.downloadItem.pause();
|
||||
}
|
||||
|
||||
async resumeDownload() {
|
||||
this.downloadItem.resume();
|
||||
}
|
||||
|
||||
async startDownload() {
|
||||
return new Promise((resolve) => {
|
||||
const options = this.headers ? { headers: this.headers } : {};
|
||||
WindowManager.mainWindow?.webContents.downloadURL(
|
||||
this.downloadUrl,
|
||||
options
|
||||
);
|
||||
|
||||
WindowManager.mainWindow?.webContents.session.once(
|
||||
"will-download",
|
||||
(_event, item, _webContents) => {
|
||||
this.downloadItem = item;
|
||||
|
||||
item.setSavePath(path.join(this.downloadPath, item.getFilename()));
|
||||
|
||||
resolve(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,2 +1 @@
|
|||
export * from "./download-manager";
|
||||
export * from "./python-instance";
|
||||
|
|
|
@ -1,188 +0,0 @@
|
|||
import cp from "node:child_process";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
import {
|
||||
RPC_PASSWORD,
|
||||
RPC_PORT,
|
||||
startTorrentClient as startRPCClient,
|
||||
} from "./torrent-client";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import type { DownloadProgress } from "@types";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
import { calculateETA } from "./helpers";
|
||||
import axios from "axios";
|
||||
import {
|
||||
CancelDownloadPayload,
|
||||
StartDownloadPayload,
|
||||
PauseDownloadPayload,
|
||||
LibtorrentStatus,
|
||||
LibtorrentPayload,
|
||||
ProcessPayload,
|
||||
} from "./types";
|
||||
import { pythonInstanceLogger as logger } from "../logger";
|
||||
|
||||
export class PythonInstance {
|
||||
private static pythonProcess: cp.ChildProcess | null = null;
|
||||
private static downloadingGameId = -1;
|
||||
|
||||
private static rpc = axios.create({
|
||||
baseURL: `http://localhost:${RPC_PORT}`,
|
||||
headers: {
|
||||
"x-hydra-rpc-password": RPC_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
public static spawn(args?: StartDownloadPayload) {
|
||||
logger.log("spawning python process with args:", args);
|
||||
this.pythonProcess = startRPCClient(args);
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
if (this.pythonProcess) {
|
||||
logger.log("killing python process");
|
||||
this.pythonProcess.kill();
|
||||
this.pythonProcess = null;
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static killTorrent() {
|
||||
if (this.pythonProcess) {
|
||||
logger.log("killing torrent in python process");
|
||||
this.rpc.post("/action", { action: "kill-torrent" });
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getProcessList() {
|
||||
return (
|
||||
(await this.rpc.get<ProcessPayload[] | null>("/process-list")).data || []
|
||||
);
|
||||
}
|
||||
|
||||
public static async getStatus() {
|
||||
if (this.downloadingGameId === -1) return null;
|
||||
|
||||
const response = await this.rpc.get<LibtorrentPayload | null>("/status");
|
||||
|
||||
if (response.data === null) return null;
|
||||
|
||||
try {
|
||||
const {
|
||||
progress,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
folderName,
|
||||
status,
|
||||
gameId,
|
||||
} = response.data;
|
||||
|
||||
this.downloadingGameId = gameId;
|
||||
|
||||
const isDownloadingMetadata =
|
||||
status === LibtorrentStatus.DownloadingMetadata;
|
||||
|
||||
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
||||
|
||||
if (!isDownloadingMetadata && !isCheckingFiles) {
|
||||
const update: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
progress,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: gameId },
|
||||
{
|
||||
...update,
|
||||
folderName,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (progress === 1 && !isCheckingFiles) {
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
|
||||
return {
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
|
||||
isDownloadingMetadata,
|
||||
isCheckingFiles,
|
||||
progress,
|
||||
gameId,
|
||||
} as DownloadProgress;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
await this.rpc
|
||||
.post("/action", {
|
||||
action: "pause",
|
||||
game_id: this.downloadingGameId,
|
||||
} as PauseDownloadPayload)
|
||||
.catch(() => {});
|
||||
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (!this.pythonProcess) {
|
||||
this.spawn({
|
||||
game_id: game.id,
|
||||
magnet: game.uri!,
|
||||
save_path: game.downloadPath!,
|
||||
});
|
||||
} else {
|
||||
await this.rpc
|
||||
.post("/action", {
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
magnet: game.uri,
|
||||
save_path: game.downloadPath,
|
||||
} as StartDownloadPayload)
|
||||
.catch(this.handleRpcError);
|
||||
}
|
||||
|
||||
this.downloadingGameId = game.id;
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
await this.rpc
|
||||
.post("/action", {
|
||||
action: "cancel",
|
||||
game_id: gameId,
|
||||
} as CancelDownloadPayload)
|
||||
.catch(() => {});
|
||||
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
|
||||
static async processProfileImage(imagePath: string) {
|
||||
return this.rpc
|
||||
.post<{ imagePath: string; mimeType: string }>("/profile-image", {
|
||||
image_path: imagePath,
|
||||
})
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
private static async handleRpcError(error: unknown) {
|
||||
logger.error(error);
|
||||
|
||||
return this.rpc.get("/healthcheck").catch(() => {
|
||||
logger.error(
|
||||
"RPC healthcheck failed. Killing process and starting again"
|
||||
);
|
||||
this.kill();
|
||||
this.spawn();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
import { Game } from "@main/entity";
|
||||
import { RealDebridClient } from "../real-debrid";
|
||||
import { HttpDownload } from "./http-download";
|
||||
import { GenericHttpDownloader } from "./generic-http-downloader";
|
||||
|
||||
export class RealDebridDownloader extends GenericHttpDownloader {
|
||||
private static realDebridTorrentId: string | null = null;
|
||||
|
||||
private static async getRealDebridDownloadUrl() {
|
||||
if (this.realDebridTorrentId) {
|
||||
let torrentInfo = await RealDebridClient.getTorrentInfo(
|
||||
this.realDebridTorrentId
|
||||
);
|
||||
|
||||
if (torrentInfo.status === "waiting_files_selection") {
|
||||
await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
|
||||
|
||||
torrentInfo = await RealDebridClient.getTorrentInfo(
|
||||
this.realDebridTorrentId
|
||||
);
|
||||
}
|
||||
|
||||
const { links, status } = torrentInfo;
|
||||
|
||||
if (status === "downloaded") {
|
||||
const [link] = links;
|
||||
|
||||
const { download } = await RealDebridClient.unrestrictLink(link);
|
||||
return decodeURIComponent(download);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.downloadingGame?.uri) {
|
||||
const { download } = await RealDebridClient.unrestrictLink(
|
||||
this.downloadingGame?.uri
|
||||
);
|
||||
|
||||
return decodeURIComponent(download);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (this.downloads.has(game.id)) {
|
||||
await this.resumeDownload(game.id!);
|
||||
this.downloadingGame = game;
|
||||
return;
|
||||
}
|
||||
|
||||
if (game.uri?.startsWith("magnet:")) {
|
||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
|
||||
game!.uri!
|
||||
);
|
||||
}
|
||||
|
||||
this.downloadingGame = game;
|
||||
|
||||
const downloadUrl = await this.getRealDebridDownloadUrl();
|
||||
|
||||
if (downloadUrl) {
|
||||
this.realDebridTorrentId = null;
|
||||
|
||||
const httpDownload = new HttpDownload(game.downloadPath!, downloadUrl);
|
||||
httpDownload.startDownload();
|
||||
|
||||
this.downloads.set(game.id!, httpDownload);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -83,4 +83,37 @@ export class RealDebridClient {
|
|||
const torrent = await RealDebridClient.addMagnet(magnetUri);
|
||||
return torrent.id;
|
||||
}
|
||||
|
||||
public static async getDownloadUrl(uri: string) {
|
||||
let realDebridTorrentId: string | null = null;
|
||||
|
||||
if (uri.startsWith("magnet:")) {
|
||||
realDebridTorrentId = await this.getTorrentId(uri);
|
||||
}
|
||||
|
||||
if (realDebridTorrentId) {
|
||||
let torrentInfo = await this.getTorrentInfo(realDebridTorrentId);
|
||||
|
||||
if (torrentInfo.status === "waiting_files_selection") {
|
||||
await this.selectAllFiles(realDebridTorrentId);
|
||||
|
||||
torrentInfo = await this.getTorrentInfo(realDebridTorrentId);
|
||||
}
|
||||
|
||||
const { links, status } = torrentInfo;
|
||||
|
||||
if (status === "downloaded") {
|
||||
const [link] = links;
|
||||
|
||||
const { download } = await this.unrestrictLink(link);
|
||||
return decodeURIComponent(download);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const { download } = await this.unrestrictLink(uri);
|
||||
|
||||
return decodeURIComponent(download);
|
||||
}
|
||||
}
|
96
src/main/services/download/torbox.ts
Normal file
96
src/main/services/download/torbox.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
import axios, { AxiosInstance } from "axios";
|
||||
import parseTorrent from "parse-torrent";
|
||||
import type {
|
||||
TorBoxUserRequest,
|
||||
TorBoxTorrentInfoRequest,
|
||||
TorBoxAddTorrentRequest,
|
||||
TorBoxRequestLinkRequest,
|
||||
} from "@types";
|
||||
|
||||
export class TorBoxClient {
|
||||
private static instance: AxiosInstance;
|
||||
private static baseURL = "https://api.torbox.app/v1/api";
|
||||
public static apiToken: string;
|
||||
|
||||
static authorize(apiToken: string) {
|
||||
this.instance = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
this.apiToken = apiToken;
|
||||
}
|
||||
|
||||
static async addMagnet(magnet: string) {
|
||||
const form = new FormData();
|
||||
form.append("magnet", magnet);
|
||||
|
||||
const response = await this.instance.post<TorBoxAddTorrentRequest>(
|
||||
"/torrents/createtorrent",
|
||||
form
|
||||
);
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
static async getTorrentInfo(id: number) {
|
||||
const response =
|
||||
await this.instance.get<TorBoxTorrentInfoRequest>("/torrents/mylist");
|
||||
const data = response.data.data;
|
||||
|
||||
const info = data.find((item) => item.id === id);
|
||||
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
static async getUser() {
|
||||
const response = await this.instance.get<TorBoxUserRequest>(`/user/me`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
static async requestLink(id: number) {
|
||||
const searchParams = new URLSearchParams({});
|
||||
|
||||
searchParams.set("token", this.apiToken);
|
||||
searchParams.set("torrent_id", id.toString());
|
||||
searchParams.set("zip_link", "true");
|
||||
|
||||
const response = await this.instance.get<TorBoxRequestLinkRequest>(
|
||||
"/torrents/requestdl?" + searchParams.toString()
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.error(response.data.error);
|
||||
console.error(response.data.detail);
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
private static async getAllTorrentsFromUser() {
|
||||
const response =
|
||||
await this.instance.get<TorBoxTorrentInfoRequest>("/torrents/mylist");
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
static async getTorrentId(magnetUri: string) {
|
||||
const userTorrents = await this.getAllTorrentsFromUser();
|
||||
|
||||
const { infoHash } = await parseTorrent(magnetUri);
|
||||
const userTorrent = userTorrents.find(
|
||||
(userTorrent) => userTorrent.hash === infoHash
|
||||
);
|
||||
|
||||
if (userTorrent) return userTorrent.id;
|
||||
|
||||
const torrent = await this.addMagnet(magnetUri);
|
||||
return torrent.torrent_id;
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { app, dialog } from "electron";
|
||||
import type { StartDownloadPayload } from "./types";
|
||||
import { Readable } from "node:stream";
|
||||
import { pythonInstanceLogger as logger } from "../logger";
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "hydra-download-manager",
|
||||
linux: "hydra-download-manager",
|
||||
win32: "hydra-download-manager.exe",
|
||||
};
|
||||
|
||||
export const BITTORRENT_PORT = "5881";
|
||||
export const RPC_PORT = "8084";
|
||||
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
const logStderr = (readable: Readable | null) => {
|
||||
if (!readable) return;
|
||||
|
||||
readable.setEncoding("utf-8");
|
||||
readable.on("data", logger.log);
|
||||
};
|
||||
|
||||
export const startTorrentClient = (args?: StartDownloadPayload) => {
|
||||
const commonArgs = [
|
||||
BITTORRENT_PORT,
|
||||
RPC_PORT,
|
||||
RPC_PASSWORD,
|
||||
args ? encodeURIComponent(JSON.stringify(args)) : "",
|
||||
];
|
||||
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
||||
const binaryPath = path.join(
|
||||
process.resourcesPath,
|
||||
"hydra-download-manager",
|
||||
binaryName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
dialog.showErrorBox(
|
||||
"Fatal",
|
||||
"Hydra Download Manager binary not found. Please check if it has been removed by Windows Defender."
|
||||
);
|
||||
|
||||
app.quit();
|
||||
}
|
||||
|
||||
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
||||
windowsHide: true,
|
||||
stdio: ["inherit", "inherit"],
|
||||
});
|
||||
|
||||
logStderr(childProcess.stderr);
|
||||
|
||||
return childProcess;
|
||||
} else {
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"torrent-client",
|
||||
"main.py"
|
||||
);
|
||||
|
||||
const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||
stdio: ["inherit", "inherit"],
|
||||
});
|
||||
|
||||
logStderr(childProcess.stderr);
|
||||
|
||||
return childProcess;
|
||||
}
|
||||
};
|
|
@ -1,9 +1,3 @@
|
|||
export interface StartDownloadPayload {
|
||||
game_id: number;
|
||||
magnet: string;
|
||||
save_path: string;
|
||||
}
|
||||
|
||||
export interface PauseDownloadPayload {
|
||||
game_id: number;
|
||||
}
|
||||
|
@ -25,6 +19,7 @@ export interface LibtorrentPayload {
|
|||
numPeers: number;
|
||||
numSeeds: number;
|
||||
downloadSpeed: number;
|
||||
uploadSpeed: number;
|
||||
bytesDownloaded: number;
|
||||
fileSize: number;
|
||||
folderName: string;
|
||||
|
@ -33,7 +28,15 @@ export interface LibtorrentPayload {
|
|||
}
|
||||
|
||||
export interface ProcessPayload {
|
||||
exe: string;
|
||||
exe: string | null;
|
||||
pid: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PauseSeedingPayload {
|
||||
game_id: number;
|
||||
}
|
||||
|
||||
export interface ResumeSeedingPayload {
|
||||
game_id: number;
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ export class HydraApi {
|
|||
private static instance: AxiosInstance;
|
||||
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||
private static readonly ADD_LOG_INTERCEPTOR = true;
|
||||
private static readonly ADD_LOG_INTERCEPTOR = false;
|
||||
|
||||
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ export const startMainLoop = async () => {
|
|||
watchProcesses(),
|
||||
DownloadManager.watchDownloads(),
|
||||
AchievementWatcherManager.watchAchievements(),
|
||||
DownloadManager.getSeedStatus(),
|
||||
]);
|
||||
|
||||
await sleep(1500);
|
||||
|
|
|
@ -2,10 +2,11 @@ import { gameRepository } from "@main/repository";
|
|||
import { WindowManager } from "./window-manager";
|
||||
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||
import type { GameRunning } from "@types";
|
||||
import { PythonInstance } from "./download";
|
||||
import { PythonRPC } from "./python-rpc";
|
||||
import { Game } from "@main/entity";
|
||||
import axios from "axios";
|
||||
import { exec } from "child_process";
|
||||
import { ProcessPayload } from "./download/types";
|
||||
|
||||
const commands = {
|
||||
findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`,
|
||||
|
@ -88,12 +89,14 @@ const findGamePathByProcess = (
|
|||
};
|
||||
|
||||
const getSystemProcessMap = async () => {
|
||||
const processes = await PythonInstance.getProcessList();
|
||||
const processes =
|
||||
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
|
||||
[];
|
||||
|
||||
const map = new Map<string, Set<string>>();
|
||||
|
||||
processes.forEach((process) => {
|
||||
const key = process.name.toLowerCase();
|
||||
const key = process.name?.toLowerCase();
|
||||
const value = process.exe;
|
||||
|
||||
if (!key || !value) return;
|
||||
|
|
99
src/main/services/python-rpc.ts
Normal file
99
src/main/services/python-rpc.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import axios from "axios";
|
||||
|
||||
import cp from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { logger } from "./logger";
|
||||
import { Readable } from "node:stream";
|
||||
import { app, dialog } from "electron";
|
||||
import { startSeedProcess } from "./seed";
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "hydra-python-rpc",
|
||||
linux: "hydra-python-rpc",
|
||||
win32: "hydra-python-rpc.exe",
|
||||
};
|
||||
|
||||
export class PythonRPC {
|
||||
public static readonly BITTORRENT_PORT = "5881";
|
||||
public static readonly RPC_PORT = "8084";
|
||||
private static readonly RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
private static pythonProcess: cp.ChildProcess | null = null;
|
||||
|
||||
public static rpc = axios.create({
|
||||
baseURL: `http://localhost:${this.RPC_PORT}`,
|
||||
headers: {
|
||||
"x-hydra-rpc-password": this.RPC_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
private static logStderr(readable: Readable | null) {
|
||||
if (!readable) return;
|
||||
|
||||
readable.setEncoding("utf-8");
|
||||
readable.on("data", logger.log);
|
||||
}
|
||||
|
||||
public static spawn() {
|
||||
console.log([this.BITTORRENT_PORT, this.RPC_PORT, this.RPC_PASSWORD]);
|
||||
const commonArgs = [this.BITTORRENT_PORT, this.RPC_PORT, this.RPC_PASSWORD];
|
||||
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
||||
const binaryPath = path.join(
|
||||
process.resourcesPath,
|
||||
"hydra-python-rpc",
|
||||
binaryName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
dialog.showErrorBox(
|
||||
"Fatal",
|
||||
"Hydra Python Instance binary not found. Please check if it has been removed by Windows Defender."
|
||||
);
|
||||
|
||||
app.quit();
|
||||
}
|
||||
|
||||
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
||||
windowsHide: true,
|
||||
stdio: ["inherit", "inherit"],
|
||||
});
|
||||
|
||||
this.logStderr(childProcess.stderr);
|
||||
|
||||
this.pythonProcess = childProcess;
|
||||
} else {
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"python_rpc",
|
||||
"main.py"
|
||||
);
|
||||
|
||||
console.log(scriptPath);
|
||||
|
||||
const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||
stdio: ["inherit", "inherit"],
|
||||
});
|
||||
|
||||
this.logStderr(childProcess.stderr);
|
||||
|
||||
this.pythonProcess = childProcess;
|
||||
|
||||
startSeedProcess();
|
||||
}
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
if (this.pythonProcess) {
|
||||
logger.log("Killing python process");
|
||||
this.pythonProcess.kill();
|
||||
this.pythonProcess = null;
|
||||
}
|
||||
}
|
||||
}
|
23
src/main/services/seed.ts
Normal file
23
src/main/services/seed.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { gameRepository } from "@main/repository";
|
||||
import { DownloadManager } from "./download/download-manager";
|
||||
import { sleep } from "@main/helpers";
|
||||
|
||||
export const startSeedProcess = async () => {
|
||||
const seedList = await gameRepository.find({
|
||||
where: {
|
||||
shouldSeed: true,
|
||||
downloader: 1,
|
||||
progress: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (seedList.length === 0) return;
|
||||
|
||||
await sleep(1000);
|
||||
// wait for python process to start
|
||||
|
||||
seedList.map(async (game) => {
|
||||
await DownloadManager.startDownload(game);
|
||||
await sleep(100);
|
||||
});
|
||||
};
|
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
|
@ -1,7 +1,6 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
|
||||
readonly MAIN_VITE_API_URL: string;
|
||||
readonly MAIN_VITE_ANALYTICS_API_URL: string;
|
||||
readonly MAIN_VITE_AUTH_URL: string;
|
||||
|
|
|
@ -11,6 +11,7 @@ import type {
|
|||
GameRunning,
|
||||
FriendRequestAction,
|
||||
UpdateProfileRequest,
|
||||
SeedingStatus,
|
||||
GameAchievement,
|
||||
} from "@types";
|
||||
import type { CatalogueCategory } from "@shared";
|
||||
|
@ -26,6 +27,10 @@ contextBridge.exposeInMainWorld("electron", {
|
|||
ipcRenderer.invoke("pauseGameDownload", gameId),
|
||||
resumeGameDownload: (gameId: number) =>
|
||||
ipcRenderer.invoke("resumeGameDownload", gameId),
|
||||
pauseGameSeed: (gameId: number) =>
|
||||
ipcRenderer.invoke("pauseGameSeed", gameId),
|
||||
resumeGameSeed: (gameId: number) =>
|
||||
ipcRenderer.invoke("resumeGameSeed", gameId),
|
||||
onDownloadProgress: (cb: (value: DownloadProgress) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
|
@ -34,6 +39,19 @@ contextBridge.exposeInMainWorld("electron", {
|
|||
ipcRenderer.on("on-download-progress", listener);
|
||||
return () => ipcRenderer.removeListener("on-download-progress", listener);
|
||||
},
|
||||
onHardDelete: (cb: () => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||
ipcRenderer.on("on-hard-delete", listener);
|
||||
return () => ipcRenderer.removeListener("on-hard-delete", listener);
|
||||
},
|
||||
onSeedingStatus: (cb: (value: SeedingStatus[]) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
value: SeedingStatus[]
|
||||
) => cb(value);
|
||||
ipcRenderer.on("on-seeding-status", listener);
|
||||
return () => ipcRenderer.removeListener("on-seeding-status", listener);
|
||||
},
|
||||
|
||||
/* Catalogue */
|
||||
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
||||
|
|
|
@ -103,6 +103,14 @@ export function App() {
|
|||
};
|
||||
}, [clearDownload, setLastPacket, updateLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onHardDelete(() => {
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [updateLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
const cachedUserDetails = window.localStorage.getItem("userDetails");
|
||||
|
||||
|
|
68
src/renderer/src/components/dropdown-menu/dropdown-menu.scss
Normal file
68
src/renderer/src/components/dropdown-menu/dropdown-menu.scss
Normal file
|
@ -0,0 +1,68 @@
|
|||
@use "../../scss/globals.scss";
|
||||
|
||||
.dropdown-menu {
|
||||
&__content {
|
||||
background-color: globals.$dark-background-color;
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 4px;
|
||||
min-width: 200px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__group {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__title-bar {
|
||||
width: 100%;
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__separator {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: globals.$border-color;
|
||||
}
|
||||
|
||||
&__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease-in-out;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__item--disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:not(&__item--disabled) &__item:hover {
|
||||
background-color: globals.$background-color;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__item:focus {
|
||||
background-color: globals.$background-color;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__item-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
81
src/renderer/src/components/dropdown-menu/dropdown-menu.tsx
Normal file
81
src/renderer/src/components/dropdown-menu/dropdown-menu.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import "./dropdown-menu.scss";
|
||||
|
||||
export interface DropdownMenuItem {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
show?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface DropdownMenuProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
loop?: boolean;
|
||||
items: DropdownMenuItem[];
|
||||
sideOffset?: number;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
align?: "start" | "center" | "end";
|
||||
alignOffset?: number;
|
||||
}
|
||||
|
||||
export function DropdownMenu({
|
||||
children,
|
||||
title,
|
||||
items,
|
||||
sideOffset = 5,
|
||||
side = "bottom",
|
||||
loop = true,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
}: DropdownMenuProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<button aria-label={title}>{children}</button>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
side={side}
|
||||
loop={loop}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
className="dropdown-menu__content"
|
||||
>
|
||||
{title && (
|
||||
<DropdownMenuPrimitive.Group className="dropdown-menu__group">
|
||||
<div className="dropdown-menu__title-bar">{title}</div>
|
||||
</DropdownMenuPrimitive.Group>
|
||||
)}
|
||||
|
||||
<DropdownMenuPrimitive.Separator className="dropdown-menu__separator" />
|
||||
|
||||
<DropdownMenuPrimitive.Group className="dropdown-menu__group">
|
||||
{items.map(
|
||||
(item) =>
|
||||
item.show !== false && (
|
||||
<DropdownMenuPrimitive.Item
|
||||
key={item.label}
|
||||
aria-label={item.label}
|
||||
onSelect={item.onClick}
|
||||
className={`dropdown-menu__item ${item.disabled ? "dropdown-menu__item--disabled" : ""}`}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && (
|
||||
<div className="dropdown-menu__item-icon">
|
||||
{item.icon}
|
||||
</div>
|
||||
)}
|
||||
{item.label}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuPrimitive.Group>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
);
|
||||
}
|
7
src/renderer/src/declaration.d.ts
vendored
7
src/renderer/src/declaration.d.ts
vendored
|
@ -10,6 +10,7 @@ import type {
|
|||
ShopDetails,
|
||||
Steam250Game,
|
||||
DownloadProgress,
|
||||
SeedingStatus,
|
||||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
|
@ -46,9 +47,15 @@ declare global {
|
|||
cancelGameDownload: (gameId: number) => Promise<void>;
|
||||
pauseGameDownload: (gameId: number) => Promise<void>;
|
||||
resumeGameDownload: (gameId: number) => Promise<void>;
|
||||
pauseGameSeed: (gameId: number) => Promise<void>;
|
||||
resumeGameSeed: (gameId: number) => Promise<void>;
|
||||
onDownloadProgress: (
|
||||
cb: (value: DownloadProgress) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onSeedingStatus: (
|
||||
cb: (value: SeedingStatus[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onHardDelete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Catalogue */
|
||||
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
||||
|
|
|
@ -66,6 +66,16 @@ export function useDownload() {
|
|||
updateLibrary();
|
||||
});
|
||||
|
||||
const pauseSeeding = async (gameId: number) => {
|
||||
await window.electron.pauseGameSeed(gameId);
|
||||
await updateLibrary();
|
||||
};
|
||||
|
||||
const resumeSeeding = async (gameId: number) => {
|
||||
await window.electron.resumeGameSeed(gameId);
|
||||
await updateLibrary();
|
||||
};
|
||||
|
||||
const calculateETA = () => {
|
||||
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
|
||||
|
||||
|
@ -96,6 +106,8 @@ export function useDownload() {
|
|||
removeGameFromLibrary,
|
||||
removeGameInstaller,
|
||||
isGameDeleting,
|
||||
pauseSeeding,
|
||||
resumeSeeding,
|
||||
clearDownload: () => dispatch(clearDownload()),
|
||||
setLastPacket: (packet: DownloadProgress) =>
|
||||
dispatch(setLastPacket(packet)),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { LibraryGame } from "@types";
|
||||
import type { LibraryGame, SeedingStatus } from "@types";
|
||||
|
||||
import { Badge, Button } from "@renderer/components";
|
||||
import {
|
||||
|
@ -15,12 +15,19 @@ import { useAppSelector, useDownload } from "@renderer/hooks";
|
|||
import * as styles from "./download-group.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
} from "@renderer/components/dropdown-menu/dropdown-menu";
|
||||
import { ThreeBarsIcon } from "@primer/octicons-react";
|
||||
|
||||
export interface DownloadGroupProps {
|
||||
library: LibraryGame[];
|
||||
title: string;
|
||||
openDeleteGameModal: (gameId: number) => void;
|
||||
openGameInstaller: (gameId: number) => void;
|
||||
seedingStatus: SeedingStatus[];
|
||||
}
|
||||
|
||||
export function DownloadGroup({
|
||||
|
@ -28,6 +35,7 @@ export function DownloadGroup({
|
|||
title,
|
||||
openDeleteGameModal,
|
||||
openGameInstaller,
|
||||
seedingStatus,
|
||||
}: DownloadGroupProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
@ -44,6 +52,8 @@ export function DownloadGroup({
|
|||
resumeDownload,
|
||||
cancelDownload,
|
||||
isGameDeleting,
|
||||
pauseSeeding,
|
||||
resumeSeeding,
|
||||
} = useDownload();
|
||||
|
||||
const getFinalDownloadSize = (game: LibraryGame) => {
|
||||
|
@ -57,9 +67,21 @@ export function DownloadGroup({
|
|||
return "N/A";
|
||||
};
|
||||
|
||||
const seedingMap = useMemo(() => {
|
||||
if (!Array.isArray(seedingStatus) || seedingStatus.length === 0) {
|
||||
return new Map<number, SeedingStatus>();
|
||||
}
|
||||
const map = new Map<number, SeedingStatus>();
|
||||
seedingStatus.forEach((seed) => {
|
||||
map.set(seed.gameId, seed);
|
||||
});
|
||||
return map;
|
||||
}, [seedingStatus]);
|
||||
|
||||
const getGameInfo = (game: LibraryGame) => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
const seedingStatus = seedingMap.get(game.id);
|
||||
|
||||
if (isGameDeleting(game.id)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
|
@ -98,7 +120,17 @@ export function DownloadGroup({
|
|||
}
|
||||
|
||||
if (game.progress === 1) {
|
||||
return <p>{t("completed")}</p>;
|
||||
const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0);
|
||||
|
||||
return game.status === "seeding" &&
|
||||
game.downloader === Downloader.Torrent ? (
|
||||
<>
|
||||
<p>{t("seeding")}</p>
|
||||
{uploadSpeed && <p>{uploadSpeed}/s</p>}
|
||||
</>
|
||||
) : (
|
||||
<p>{t("completed")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (game.status === "paused") {
|
||||
|
@ -125,59 +157,66 @@ export function DownloadGroup({
|
|||
return <p>{t(game.status as string)}</p>;
|
||||
};
|
||||
|
||||
const getGameActions = (game: LibraryGame) => {
|
||||
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
|
||||
const isGameDownloading = lastPacket?.game.id === game.id;
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
if (game.progress === 1) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => openGameInstaller(game.id)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => openDeleteGameModal(game.id)} theme="outline">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
return [
|
||||
{
|
||||
label: t("install"),
|
||||
disabled: deleting,
|
||||
onClick: () => openGameInstaller(game.id),
|
||||
},
|
||||
{
|
||||
label: t("stop_seeding"),
|
||||
disabled: deleting,
|
||||
show:
|
||||
game.status === "seeding" && game.downloader === Downloader.Torrent,
|
||||
onClick: () => pauseSeeding(game.id),
|
||||
},
|
||||
{
|
||||
label: t("resume_seeding"),
|
||||
disabled: deleting,
|
||||
show:
|
||||
game.status !== "seeding" && game.downloader === Downloader.Torrent,
|
||||
onClick: () => resumeSeeding(game.id),
|
||||
},
|
||||
{
|
||||
label: t("delete"),
|
||||
disabled: deleting,
|
||||
onClick: () => openDeleteGameModal(game.id),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (isGameDownloading || game.status === "active") {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => pauseDownload(game.id)} theme="outline">
|
||||
{t("pause")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
return [
|
||||
{
|
||||
label: t("pause"),
|
||||
onClick: () => pauseDownload(game.id),
|
||||
},
|
||||
{
|
||||
label: t("cancel"),
|
||||
onClick: () => cancelDownload(game.id),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => resumeDownload(game.id)}
|
||||
theme="outline"
|
||||
disabled={
|
||||
game.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken
|
||||
}
|
||||
>
|
||||
{t("resume")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
return [
|
||||
{
|
||||
label: t("resume"),
|
||||
disabled:
|
||||
game.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken,
|
||||
onClick: () => resumeDownload(game.id),
|
||||
},
|
||||
{
|
||||
label: t("cancel"),
|
||||
onClick: () => cancelDownload(game.id),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
if (!library.length) return null;
|
||||
|
@ -207,7 +246,11 @@ export function DownloadGroup({
|
|||
<ul className={styles.downloads}>
|
||||
{library.map((game) => {
|
||||
return (
|
||||
<li key={game.id} className={styles.download}>
|
||||
<li
|
||||
key={game.id}
|
||||
className={styles.download}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<div className={styles.downloadCover}>
|
||||
<div className={styles.downloadCoverBackdrop}>
|
||||
<img
|
||||
|
@ -243,9 +286,28 @@ export function DownloadGroup({
|
|||
{getGameInfo(game)}
|
||||
</div>
|
||||
|
||||
<div className={styles.downloadActions}>
|
||||
{getGameActions(game)}
|
||||
</div>
|
||||
{getGameActions(game) !== null && (
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
items={getGameActions(game)}
|
||||
sideOffset={-70}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "12px",
|
||||
right: "12px",
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
padding: "8px",
|
||||
minHeight: "unset",
|
||||
}}
|
||||
theme="outline"
|
||||
>
|
||||
<ThreeBarsIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
|
|
@ -2,12 +2,12 @@ import { useTranslation } from "react-i18next";
|
|||
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
import * as styles from "./downloads.css";
|
||||
import { DeleteGameModal } from "./delete-game-modal";
|
||||
import { DownloadGroup } from "./download-group";
|
||||
import type { LibraryGame } from "@types";
|
||||
import type { LibraryGame, SeedingStatus } from "@types";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { ArrowDownIcon } from "@primer/octicons-react";
|
||||
|
||||
|
@ -21,15 +21,23 @@ export default function Downloads() {
|
|||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
const { removeGameInstaller } = useDownload();
|
||||
const { removeGameInstaller, pauseSeeding } = useDownload();
|
||||
|
||||
const handleDeleteGame = async () => {
|
||||
if (gameToBeDeleted.current)
|
||||
if (gameToBeDeleted.current) {
|
||||
await pauseSeeding(gameToBeDeleted.current);
|
||||
await removeGameInstaller(gameToBeDeleted.current);
|
||||
}
|
||||
};
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
const [seedingStatus, setSeedingStatus] = useState<SeedingStatus[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
|
||||
}, []);
|
||||
|
||||
const handleOpenGameInstaller = (gameId: number) =>
|
||||
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
|
@ -119,9 +127,10 @@ export default function Downloads() {
|
|||
<DownloadGroup
|
||||
key={group.title}
|
||||
title={group.title}
|
||||
library={group.library}
|
||||
library={orderBy(group.library, ["updatedAt"], ["desc"])}
|
||||
openDeleteGameModal={handleOpenDeleteGameModal}
|
||||
openGameInstaller={handleOpenGameInstaller}
|
||||
seedingStatus={seedingStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -181,7 +181,7 @@ export function ProfileContent() {
|
|||
game.achievementCount > 0 && (
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
color: "#fff",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
|
@ -237,6 +237,8 @@ export function ProfileContent() {
|
|||
borderRadius: 4,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minWidth: "100%",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
|
|
@ -19,6 +19,7 @@ export function SettingsBehavior() {
|
|||
runAtStartup: false,
|
||||
startMinimized: false,
|
||||
disableNsfwAlert: false,
|
||||
seedAfterDownloadComplete: false,
|
||||
showHiddenAchievementsDescription: false,
|
||||
});
|
||||
|
||||
|
@ -31,6 +32,7 @@ export function SettingsBehavior() {
|
|||
runAtStartup: userPreferences.runAtStartup,
|
||||
startMinimized: userPreferences.startMinimized,
|
||||
disableNsfwAlert: userPreferences.disableNsfwAlert,
|
||||
seedAfterDownloadComplete: userPreferences.seedAfterDownloadComplete,
|
||||
showHiddenAchievementsDescription:
|
||||
userPreferences.showHiddenAchievementsDescription,
|
||||
});
|
||||
|
@ -100,6 +102,16 @@ export function SettingsBehavior() {
|
|||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("seed_after_download_complete")}
|
||||
checked={form.seedAfterDownloadComplete}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
seedAfterDownloadComplete: !form.seedAfterDownloadComplete,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("show_hidden_achievement_description")}
|
||||
checked={form.showHiddenAchievementsDescription}
|
||||
|
|
|
@ -3,12 +3,3 @@ export interface HowLongToBeatCategory {
|
|||
duration: string;
|
||||
accuracy: string;
|
||||
}
|
||||
|
||||
export interface HowLongToBeatResult {
|
||||
game_id: number;
|
||||
game_name: string;
|
||||
}
|
||||
|
||||
export interface HowLongToBeatSearchResponse {
|
||||
data: HowLongToBeatResult[];
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ export type GameStatus =
|
|||
| "paused"
|
||||
| "error"
|
||||
| "complete"
|
||||
| "seeding"
|
||||
| "removed";
|
||||
|
||||
export type GameShop = "steam" | "epic";
|
||||
|
@ -126,6 +127,7 @@ export interface Game {
|
|||
objectID: string;
|
||||
shop: GameShop;
|
||||
downloadQueue: DownloadQueue | null;
|
||||
shouldSeed: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
@ -153,6 +155,12 @@ export interface DownloadProgress {
|
|||
game: LibraryGame;
|
||||
}
|
||||
|
||||
export interface SeedingStatus {
|
||||
gameId: number;
|
||||
status: GameStatus;
|
||||
uploadSpeed: number;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
downloadsPath: string | null;
|
||||
language: string;
|
||||
|
@ -164,6 +172,7 @@ export interface UserPreferences {
|
|||
runAtStartup: boolean;
|
||||
startMinimized: boolean;
|
||||
disableNsfwAlert: boolean;
|
||||
seedAfterDownloadComplete: boolean;
|
||||
showHiddenAchievementsDescription: boolean;
|
||||
}
|
||||
|
||||
|
@ -391,3 +400,4 @@ export * from "./steam.types";
|
|||
export * from "./real-debrid.types";
|
||||
export * from "./ludusavi.types";
|
||||
export * from "./how-long-to-beat.types";
|
||||
export * from "./torbox.types";
|
||||
|
|
77
src/types/torbox.types.ts
Normal file
77
src/types/torbox.types.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
export interface TorBoxUser {
|
||||
id: number;
|
||||
email: string;
|
||||
plan: string;
|
||||
expiration: string;
|
||||
}
|
||||
|
||||
export interface TorBoxUserRequest {
|
||||
success: boolean;
|
||||
detail: string;
|
||||
error: string;
|
||||
data: TorBoxUser;
|
||||
}
|
||||
|
||||
export interface TorBoxFile {
|
||||
id: number;
|
||||
md5: string;
|
||||
s3_path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimetype: string;
|
||||
short_name: string;
|
||||
}
|
||||
|
||||
export interface TorBoxTorrentInfo {
|
||||
id: number;
|
||||
hash: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
magnet: string;
|
||||
size: number;
|
||||
active: boolean;
|
||||
cached: boolean;
|
||||
auth_id: string;
|
||||
download_state:
|
||||
| "downloading"
|
||||
| "uploading"
|
||||
| "stalled (no seeds)"
|
||||
| "paused"
|
||||
| "completed"
|
||||
| "cached"
|
||||
| "metaDL"
|
||||
| "checkingResumeData";
|
||||
seeds: number;
|
||||
ratio: number;
|
||||
progress: number;
|
||||
download_speed: number;
|
||||
upload_speed: number;
|
||||
name: string;
|
||||
eta: number;
|
||||
files: TorBoxFile[];
|
||||
}
|
||||
|
||||
export interface TorBoxTorrentInfoRequest {
|
||||
success: boolean;
|
||||
detail: string;
|
||||
error: string;
|
||||
data: TorBoxTorrentInfo[];
|
||||
}
|
||||
|
||||
export interface TorBoxAddTorrentRequest {
|
||||
success: boolean;
|
||||
detail: string;
|
||||
error: string;
|
||||
data: {
|
||||
torrent_id: number;
|
||||
name: string;
|
||||
hash: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TorBoxRequestLinkRequest {
|
||||
success: boolean;
|
||||
detail: string;
|
||||
error: string;
|
||||
data: string;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue