feat: adding download queue

This commit is contained in:
Chubby Granny Chaser 2024-06-04 15:33:47 +01:00
parent 0b68ddda78
commit 73b4b2c13c
No known key found for this signature in database
27 changed files with 615 additions and 458 deletions

File diff suppressed because one or more lines are too long

View File

@ -14,12 +14,8 @@
"paused": "{{title}} (Paused)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filter library",
"follow_us": "Follow us",
"home": "Home",
"discord": "Join our Discord",
"telegram": "Join our Telegram",
"x": "Follow on X",
"github": "Contribute on GitHub"
"queued": "{{title}} (Queued)"
},
"header": {
"search": "Search games",
@ -133,7 +129,11 @@
"remove_from_list": "Remove",
"delete_modal_title": "Are you sure?",
"delete_modal_description": "This will remove all the installation files from your computer",
"install": "Install"
"install": "Install",
"download_in_progress": "In progress",
"queued_downloads": "Queued downloads",
"downloads_complete": "Completed",
"queued": "Queued"
},
"settings": {
"downloads_path": "Downloads path",

View File

@ -17,10 +17,7 @@
"filter": "Filtrar biblioteca",
"home": "Início",
"follow_us": "Acompanhe-nos",
"discord": "Entre no nosso Discord",
"telegram": "Entre no nosso Telegram",
"x": "Siga-nos no X",
"github": "Contribua no GitHub"
"queued": "{{title}} (Na fila)"
},
"header": {
"search": "Buscar jogos",
@ -130,7 +127,11 @@
"delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador",
"delete_modal_title": "Tem certeza?",
"deleting": "Excluindo instalador…",
"install": "Instalar"
"install": "Instalar",
"download_in_progress": "Baixando agora",
"queued_downloads": "Na fila",
"downloads_complete": "Completo",
"queued": "Na fila"
},
"settings": {
"downloads_path": "Diretório dos downloads",

View File

@ -1,5 +1,6 @@
import { DataSource } from "typeorm";
import {
DownloadQueue,
DownloadSource,
Game,
GameShopCache,
@ -16,7 +17,14 @@ export const createDataSource = (
) =>
new DataSource({
type: "better-sqlite3",
entities: [Game, Repack, UserPreferences, GameShopCache, DownloadSource],
entities: [
Game,
Repack,
UserPreferences,
GameShopCache,
DownloadSource,
DownloadQueue,
],
synchronize: true,
database: databasePath,
...options,

View File

@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import type { Game } from "./game.entity";
@Entity("download_queue")
export class DownloadQueue {
@PrimaryGeneratedColumn()
id: number;
@OneToOne("Game", "downloadQueue")
@JoinColumn()
game: Game;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -6,7 +6,8 @@ import {
UpdateDateColumn,
OneToMany,
} from "typeorm";
import { Repack } from "./repack.entity";
import type { Repack } from "./repack.entity";
import { DownloadSourceStatus } from "@shared";
@Entity("download_source")
@ -26,7 +27,7 @@ export class DownloadSource {
@Column("text", { default: DownloadSourceStatus.UpToDate })
status: DownloadSourceStatus;
@OneToMany(() => Repack, (repack) => repack.downloadSource, { cascade: true })
@OneToMany("Repack", "downloadSource", { cascade: true })
repacks: Repack[];
@CreateDateColumn()

View File

@ -12,6 +12,7 @@ import { Repack } from "./repack.entity";
import type { GameShop } from "@types";
import { Downloader } from "@shared";
import type { Aria2Status } from "aria2";
import type { DownloadQueue } from "./download-queue.entity";
@Entity("game")
export class Game {
@ -66,10 +67,16 @@ export class Game {
@Column("text", { nullable: true })
uri: string | null;
@OneToOne(() => Repack, { nullable: true })
/**
* @deprecated
*/
@OneToOne("Repack", "game", { nullable: true })
@JoinColumn()
repack: Repack;
@OneToOne("DownloadQueue", "game")
downloadQueue: DownloadQueue;
@Column("boolean", { default: false })
isDeleted: boolean;

View File

@ -3,3 +3,4 @@ export * from "./repack.entity";
export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity";
export * from "./download-source.entity";
export * from "./download-queue.entity";

View File

@ -19,6 +19,9 @@ export class Repack {
@Column("text", { unique: true })
magnet: string;
/**
* @deprecated
*/
@Column("int", { nullable: true })
page: number;

View File

@ -4,7 +4,8 @@ import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { stateManager } from "@main/state-manager";
import { steamGamesWorker } from "@main/workers";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
@ -27,9 +28,9 @@ const addGameToLibrary = async (
)
.then(async ({ affected }) => {
if (!affected) {
const steamGame = stateManager
.getValue("steamGames")
.find((game) => game.id === Number(objectID));
const steamGame = await steamGamesWorker.run(Number(objectID), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)

View File

@ -6,8 +6,11 @@ const getLibrary = async () =>
where: {
isDeleted: false,
},
relations: {
downloadQueue: true,
},
order: {
updatedAt: "desc",
createdAt: "desc",
},
});

View File

@ -1,25 +1,31 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
await DownloadManager.cancelDownload(gameId);
await dataSource.transaction(async (transactionalEntityManager) => {
await DownloadManager.cancelDownload(gameId);
await gameRepository.update(
{
id: gameId,
},
{
status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
await transactionalEntityManager.getRepository(DownloadQueue).delete({
game: { id: gameId },
});
await transactionalEntityManager.getRepository(Game).update(
{
id: gameId,
},
{
status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
});
};
registerEvent("cancelGameDownload", cancelGameDownload);

View File

@ -1,13 +1,24 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
await DownloadManager.pauseDownload();
await gameRepository.update({ id: gameId }, { status: "paused" });
await dataSource.transaction(async (transactionalEntityManager) => {
await DownloadManager.pauseDownload();
await transactionalEntityManager.getRepository(DownloadQueue).delete({
game: { id: gameId },
});
await transactionalEntityManager
.getRepository(Game)
.update({ id: gameId }, { status: "paused" });
});
};
registerEvent("pauseGameDownload", pauseGameDownload);

View File

@ -5,7 +5,7 @@ import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { Game } from "@main/entity";
import { DownloadQueue, Game } from "@main/entity";
const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -30,6 +30,14 @@ const resumeGameDownload = async (
await DownloadManager.resumeDownload(game);
await transactionalEntityManager
.getRepository(DownloadQueue)
.delete({ game: { id: gameId } });
await transactionalEntityManager
.getRepository(DownloadQueue)
.insert({ game: { id: gameId } });
await transactionalEntityManager
.getRepository(Game)
.update({ id: gameId }, { status: "active" });

View File

@ -1,12 +1,17 @@
import { gameRepository, repackRepository } from "@main/repository";
import {
downloadQueueRepository,
gameRepository,
repackRepository,
} from "@main/repository";
import { registerEvent } from "../register-event";
import type { StartGameDownloadPayload } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { DownloadManager } from "@main/services";
import { stateManager } from "@main/state-manager";
import { Not } from "typeorm";
import { steamGamesWorker } from "@main/workers";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -53,9 +58,9 @@ const startGameDownload = async (
}
);
} else {
const steamGame = stateManager
.getValue("steamGames")
.find((game) => game.id === Number(objectID));
const steamGame = await steamGamesWorker.run(Number(objectID), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
@ -89,6 +94,9 @@ const startGameDownload = async (
},
});
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
await DownloadManager.startDownload(updatedGame!);
};

View File

@ -1,8 +1,10 @@
import { DownloadManager, RepacksManager, startMainLoop } from "./services";
import { gameRepository, userPreferencesRepository } from "./repository";
import {
downloadQueueRepository,
userPreferencesRepository,
} from "./repository";
import { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/real-debrid";
import { Not } from "typeorm";
import { fetchDownloadSourcesAndUpdate } from "./helpers";
startMainLoop();
@ -15,15 +17,17 @@ const loadState = async (userPreferences: UserPreferences | null) => {
if (userPreferences?.realDebridApiToken)
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
const game = await gameRepository.findOne({
where: {
status: "active",
progress: Not(1),
isDeleted: false,
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (game) DownloadManager.startDownload(game);
if (nextQueueItem?.game.status === "active")
DownloadManager.startDownload(nextQueueItem.game);
fetchDownloadSourcesAndUpdate();
};

View File

@ -1,5 +1,6 @@
import { dataSource } from "./data-source";
import {
DownloadQueue,
DownloadSource,
Game,
GameShopCache,
@ -18,3 +19,5 @@ export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const downloadSourceRepository =
dataSource.getRepository(DownloadSource);
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);

View File

@ -1,6 +1,10 @@
import Aria2, { StatusResponse } from "aria2";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { WindowManager } from "./window-manager";
import { RealDebridClient } from "./real-debrid";
@ -194,19 +198,6 @@ export class DownloadManager {
where: { id: this.game.id, isDeleted: false },
});
if (progress === 1 && this.game && !isDownloadingMetadata) {
await this.publishNotification();
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
}
if (WindowManager.mainWindow && game) {
if (!isNaN(progress))
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
@ -229,6 +220,32 @@ export class DownloadManager {
JSON.parse(JSON.stringify(payload))
);
}
if (progress === 1 && this.game && !isDownloadingMetadata) {
await this.publishNotification();
await downloadQueueRepository.delete({ game: this.game });
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
this.resumeDownload(nextQueueItem!.game);
}
}
private static clearCurrentDownload() {

View File

@ -1,30 +0,0 @@
import type { Repack } from "@main/entity";
import type { SteamGame } from "@types";
interface State {
repacks: Repack[];
steamGames: SteamGame[];
}
const initialState: State = {
repacks: [],
steamGames: [],
};
export class StateManager {
private state = initialState;
public setValue<T extends keyof State>(key: T, value: State[T]) {
this.state = { ...this.state, [key]: value };
}
public getValue<T extends keyof State>(key: T) {
return this.state[key];
}
public clearValue<T extends keyof State>(key: T) {
this.state = { ...this.state, [key]: initialState[key] };
}
}
export const stateManager = new StateManager();

View File

@ -7,7 +7,7 @@ import { formatName } from "@shared";
import { workerData } from "node:worker_threads";
const steamGamesIndex = new flexSearch.Index({
tokenize: "forward",
tokenize: "reverse",
});
const { steamGamesPath } = workerData;

View File

@ -40,6 +40,8 @@ export function Sidebar() {
updateLibrary();
}, [lastPacket?.game.id, updateLibrary]);
console.log(library);
const isDownloading = library.some(
(game) => game.status === "active" && game.progress !== 1
);
@ -100,8 +102,6 @@ export function Sidebar() {
}, [isResizing]);
const getGameTitle = (game: LibraryGame) => {
if (game.status === "paused") return t("paused", { title: game.title });
if (lastPacket?.game.id === game.id) {
return t("downloading", {
title: game.title,
@ -109,6 +109,12 @@ export function Sidebar() {
});
}
if (game.downloadQueue !== null) {
return t("queued", { title: game.title });
}
if (game.status === "paused") return t("paused", { title: game.title });
return game.title;
};

View File

@ -0,0 +1,113 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
export const downloadTitleWrapper = style({
display: "flex",
alignItems: "center",
marginBottom: `${SPACING_UNIT}px`,
gap: `${SPACING_UNIT}px`,
});
export const downloadTitle = style({
fontWeight: "bold",
cursor: "pointer",
color: vars.color.body,
textAlign: "left",
fontSize: "16px",
display: "block",
":hover": {
textDecoration: "underline",
},
});
export const downloads = style({
width: "100%",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
margin: "0",
padding: "0",
});
export const downloadCover = style({
width: "280px",
minWidth: "280px",
height: "auto",
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
zIndex: "1",
});
export const downloadCoverContent = style({
width: "100%",
height: "100%",
padding: `${SPACING_UNIT}px`,
display: "flex",
alignItems: "flex-end",
justifyContent: "flex-end",
});
export const downloadCoverBackdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)",
display: "flex",
overflow: "hidden",
zIndex: "1",
});
export const downloadCoverImage = style({
width: "100%",
height: "100%",
position: "absolute",
zIndex: "-1",
});
export const download = recipe({
base: {
width: "100%",
backgroundColor: vars.color.background,
display: "flex",
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
transition: "all ease 0.2s",
height: "140px",
minHeight: "140px",
maxHeight: "140px",
},
variants: {
cancelled: {
true: {
opacity: vars.opacity.disabled,
":hover": {
opacity: "1",
},
},
},
},
});
export const downloadDetails = style({
display: "flex",
flexDirection: "column",
flex: "1",
justifyContent: "center",
gap: `${SPACING_UNIT / 2}px`,
fontSize: "14px",
});
export const downloadRightContent = style({
display: "flex",
padding: `${SPACING_UNIT * 2}px`,
flex: "1",
gap: `${SPACING_UNIT}px`,
});
export const downloadActions = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});

View File

@ -0,0 +1,244 @@
import { useNavigate } from "react-router-dom";
import type { LibraryGame } from "@types";
import { Badge, Button } from "@renderer/components";
import {
buildGameDetailsPath,
formatDownloadProgress,
steamUrlBuilder,
} from "@renderer/helpers";
import { Downloader, formatBytes } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload } from "@renderer/hooks";
import * as styles from "./download-list.css";
import { useTranslation } from "react-i18next";
export interface DownloadListProps {
library: LibraryGame[];
}
export function DownloadList({ library }: DownloadListProps) {
const navigate = useNavigate();
const { t } = useTranslation("downloads");
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const {
lastPacket,
progress,
pauseDownload,
resumeDownload,
removeGameFromLibrary,
cancelDownload,
removeGameInstaller,
isGameDeleting,
} = useDownload();
const openGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
// if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
// updateLibrary();
});
const getFinalDownloadSize = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
if (game.fileSize) return formatBytes(game.fileSize);
if (lastPacket?.game.fileSize && isGameDownloading)
return formatBytes(lastPacket?.game.fileSize);
return "N/A";
};
const getGameInfo = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
}
if (isGameDownloading) {
return (
<>
<p>{progress}</p>
<p>
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
{game.downloader === Downloader.Torrent && (
<small>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
</small>
)}
</>
);
}
if (game.progress === 1) {
return <p>{t("completed")}</p>;
}
if (game.status === "paused") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>{t(game.downloadQueue ? "queued" : "paused")}</p>
</>
);
}
if (game.status === "active") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
return <p>{t(game.status)}</p>;
};
const openDeleteModal = (gameId: number) => {
// gameToBeDeleted.current = gameId;
// setShowDeleteModal(true);
};
const getGameActions = (game: LibraryGame) => {
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={() => openDeleteModal(game.id)} theme="outline">
{t("delete")}
</Button>
</>
);
}
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>
</>
);
}
if (game.status === "paused") {
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 (
<>
<Button
onClick={() => navigate(buildGameDetailsPath(game))}
theme="outline"
disabled={deleting}
>
{t("download_again")}
</Button>
<Button
onClick={() => removeGameFromLibrary(game.id)}
theme="outline"
disabled={deleting}
>
{t("remove_from_list")}
</Button>
</>
);
};
return (
<ul className={styles.downloads}>
{library.map((game) => {
return (
<li
key={game.id}
className={styles.download({
cancelled: game.status === "removed",
})}
>
<div className={styles.downloadCover}>
<div className={styles.downloadCoverBackdrop}>
<img
src={steamUrlBuilder.library(game.objectID)}
className={styles.downloadCoverImage}
alt={game.title}
/>
<div className={styles.downloadCoverContent}>
<Badge>{DOWNLOADER_NAME[game.downloader]}</Badge>
</div>
</div>
</div>
<div className={styles.downloadRightContent}>
<div className={styles.downloadDetails}>
<div className={styles.downloadTitleWrapper}>
<button
type="button"
className={styles.downloadTitle}
onClick={() => navigate(buildGameDetailsPath(game))}
>
{game.title}
</button>
</div>
{getGameInfo(game)}
</div>
<div className={styles.downloadActions}>
{getGameActions(game)}
</div>
</div>
</li>
);
})}
</ul>
);
}

View File

@ -1,117 +1,5 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
export const downloadTitleWrapper = style({
display: "flex",
alignItems: "center",
marginBottom: `${SPACING_UNIT}px`,
gap: `${SPACING_UNIT}px`,
});
export const downloadTitle = style({
fontWeight: "bold",
cursor: "pointer",
color: vars.color.body,
textAlign: "left",
fontSize: "16px",
display: "block",
":hover": {
textDecoration: "underline",
},
});
export const downloads = style({
width: "100%",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
margin: "0",
padding: "0",
marginTop: `${SPACING_UNIT * 3}px`,
});
export const downloadCover = style({
width: "280px",
minWidth: "280px",
height: "auto",
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
zIndex: "1",
});
export const downloadCoverContent = style({
width: "100%",
height: "100%",
padding: `${SPACING_UNIT}px`,
display: "flex",
alignItems: "flex-end",
justifyContent: "flex-end",
});
export const downloadCoverBackdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)",
display: "flex",
overflow: "hidden",
zIndex: "1",
});
export const downloadCoverImage = style({
width: "100%",
height: "100%",
position: "absolute",
zIndex: "-1",
});
export const download = recipe({
base: {
width: "100%",
backgroundColor: vars.color.background,
display: "flex",
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
transition: "all ease 0.2s",
height: "140px",
minHeight: "140px",
maxHeight: "140px",
},
variants: {
cancelled: {
true: {
opacity: vars.opacity.disabled,
":hover": {
opacity: "1",
},
},
},
},
});
export const downloadDetails = style({
display: "flex",
flexDirection: "column",
flex: "1",
justifyContent: "center",
gap: `${SPACING_UNIT / 2}px`,
fontSize: "14px",
});
export const downloadRightContent = style({
display: "flex",
padding: `${SPACING_UNIT * 2}px`,
flex: "1",
gap: `${SPACING_UNIT}px`,
});
export const downloadActions = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});
export const downloadsContainer = style({
display: "flex",
@ -119,3 +7,15 @@ export const downloadsContainer = style({
flexDirection: "column",
width: "100%",
});
export const downloadGroups = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
flexDirection: "column",
});
export const downloadGroup = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});

View File

@ -1,227 +1,63 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Badge, Button, TextField } from "@renderer/components";
import {
buildGameDetailsPath,
formatDownloadProgress,
steamUrlBuilder,
} from "@renderer/helpers";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import type { LibraryGame } from "@types";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
import { 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 { Downloader, formatBytes } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { DownloadList } from "./download-list";
import { LibraryGame } from "@types";
import { orderBy } from "lodash-es";
export function Downloads() {
const { library, updateLibrary } = useLibrary();
const { t } = useTranslation("downloads");
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const navigate = useNavigate();
const gameToBeDeleted = useRef<number | null>(null);
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const {
lastPacket,
progress,
pauseDownload,
resumeDownload,
removeGameFromLibrary,
cancelDownload,
removeGameInstaller,
isGameDeleting,
} = useDownload();
const libraryWithDownloadedGamesOnly = useMemo(() => {
return library.filter((game) => game.status);
}, [library]);
useEffect(() => {
setFilteredLibrary(libraryWithDownloadedGamesOnly);
}, [libraryWithDownloadedGamesOnly]);
const openGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
const getFinalDownloadSize = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
if (game.fileSize) return formatBytes(game.fileSize);
if (lastPacket?.game.fileSize && isGameDownloading)
return formatBytes(lastPacket?.game.fileSize);
return "N/A";
};
const getGameInfo = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
}
if (isGameDownloading) {
return (
<>
<p>{progress}</p>
<p>
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
{game.downloader === Downloader.Torrent && (
<small>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
</small>
)}
</>
);
}
if (game.progress === 1) {
return <p>{t("completed")}</p>;
}
if (game.status === "paused") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>{t("paused")}</p>
</>
);
}
if (game.status === "active") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
return <p>{t(game.status)}</p>;
};
const openDeleteModal = (gameId: number) => {
gameToBeDeleted.current = gameId;
setShowDeleteModal(true);
};
const getGameActions = (game: LibraryGame) => {
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={() => openDeleteModal(game.id)} theme="outline">
{t("delete")}
</Button>
</>
);
}
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>
</>
);
}
if (game.status === "paused") {
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 (
<>
<Button
onClick={() => navigate(buildGameDetailsPath(game))}
theme="outline"
disabled={deleting}
>
{t("download_again")}
</Button>
<Button
onClick={() => removeGameFromLibrary(game.id)}
theme="outline"
disabled={deleting}
>
{t("remove_from_list")}
</Button>
</>
);
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setFilteredLibrary(
libraryWithDownloadedGamesOnly.filter((game) =>
game.title
.toLowerCase()
.includes(event.target.value.toLocaleLowerCase())
)
);
};
const { removeGameInstaller } = useDownload();
const handleDeleteGame = async () => {
if (gameToBeDeleted.current)
await removeGameInstaller(gameToBeDeleted.current);
};
const { lastPacket } = useDownload();
const libraryGroup: Record<string, LibraryGame[]> = useMemo(() => {
const initialValue: Record<string, LibraryGame[]> = {
downloading: [],
queued: [],
complete: [],
};
const result = library.reduce((prev, next) => {
if (lastPacket?.game.id === next.id) {
return { ...prev, downloading: [...prev.downloading, next] };
}
if (next.downloadQueue) {
return { ...prev, queued: [...prev.queued, next] };
}
return { ...prev, complete: [...prev.complete, next] };
}, initialValue);
return {
...result,
queued: orderBy(result.queued, (game) => game.downloadQueue?.id, [
"desc",
]),
};
}, [library, lastPacket?.game.id]);
console.log(libraryGroup);
return (
<section className={styles.downloadsContainer}>
<BinaryNotFoundModal
@ -235,53 +71,28 @@ export function Downloads() {
deleteGame={handleDeleteGame}
/>
<TextField placeholder={t("filter")} onChange={handleFilter} />
<div className={styles.downloadGroups}>
{libraryGroup.downloading.length > 0 && (
<div className={styles.downloadGroup}>
<h2>{t("download_in_progress")}</h2>
<DownloadList library={libraryGroup.downloading} />
</div>
)}
<ul className={styles.downloads}>
{filteredLibrary.map((game) => {
return (
<li
key={game.id}
className={styles.download({
cancelled: game.status === "removed",
})}
>
<div className={styles.downloadCover}>
<div className={styles.downloadCoverBackdrop}>
<img
src={steamUrlBuilder.library(game.objectID)}
className={styles.downloadCoverImage}
alt={game.title}
/>
{libraryGroup.queued.length > 0 && (
<div className={styles.downloadGroup}>
<h2>{t("queued_downloads")}</h2>
<DownloadList library={libraryGroup.queued} />
</div>
)}
<div className={styles.downloadCoverContent}>
<Badge>{DOWNLOADER_NAME[game.downloader]}</Badge>
</div>
</div>
</div>
<div className={styles.downloadRightContent}>
<div className={styles.downloadDetails}>
<div className={styles.downloadTitleWrapper}>
<button
type="button"
className={styles.downloadTitle}
onClick={() => navigate(buildGameDetailsPath(game))}
>
{game.title}
</button>
</div>
{getGameInfo(game)}
</div>
<div className={styles.downloadActions}>
{getGameActions(game)}
</div>
</div>
</li>
);
})}
</ul>
{libraryGroup.complete.length > 0 && (
<div className={styles.downloadGroup}>
<h2>{t("downloads_complete")}</h2>
<DownloadList library={libraryGroup.complete} />
</div>
)}
</div>
</section>
);
}

View File

@ -37,7 +37,6 @@ export function AddDownloadSourceModal({
try {
const result = await window.electron.validateDownloadSource(value);
console.log(result);
setValidationResult(result);
} finally {
setIsLoading(false);

View File

@ -86,6 +86,12 @@ export interface CatalogueEntry {
repacks: GameRepack[];
}
export interface DownloadQueue {
id: number;
createdAt: Date;
updatedAt: Date;
}
/* Used by the library */
export interface Game {
id: number;
@ -104,6 +110,7 @@ export interface Game {
fileSize: number;
objectID: string;
shop: GameShop;
downloadQueue: DownloadQueue | null;
createdAt: Date;
updatedAt: Date;
}