Compare commits

...

27 Commits

Author SHA1 Message Date
Hachi-R
f22896303d lint 2024-11-07 20:35:33 -03:00
Hachi-R
a9085ec2ed feat: pass seeding list from downloader.py to download page 2024-11-07 20:35:17 -03:00
Hachi-R
452532e18b chore: removed remove_torrent() 2024-11-06 22:41:59 -03:00
Hachi-R
b55bc935f7 lint 2024-11-06 22:23:36 -03:00
Hachi-R
e8fc71b36b revert: partially revert changes in getStatus 2024-11-06 22:23:26 -03:00
Hachi-R
6295637b48 feat: add get-sedding endpoint 2024-11-06 21:58:55 -03:00
Hachi-R
e1a5a95ceb chore: removed resume torrent after download 2024-11-06 20:28:55 -03:00
Hachi-R
e5dff9babe lint 2024-11-06 19:30:06 -03:00
Hachi-R
5d59a15bbf feat: check if downloading data exists 2024-11-06 19:29:51 -03:00
Hachi-R
f555890b4c chore: remove possibility of null returning 2024-11-06 19:18:36 -03:00
Hachi-R
d4b1569892 lint 2024-11-06 19:11:38 -03:00
Hachi-R
e0f7f34d14 feat: use main-loop to watch seeding list 2024-11-06 19:10:13 -03:00
Zamitto
cd4715e00d feat: refactor get_download_status 2024-11-05 18:31:56 -03:00
Hachi-R
5daad057e7 feat: return seeding status in downloader 2024-11-05 17:10:25 -03:00
Hachi-R
3a3c0a2b35 Merge branch 'main' into feature/seed-completed-downloads 2024-11-05 15:20:03 -03:00
Hachi-R
64b1795ddd lint 2024-11-05 15:16:24 -03:00
Hachi-R
86d3f7ac81 chore: use resumeDownload() 2024-11-05 15:16:10 -03:00
Hachi-R
1458314df6 lint 2024-11-04 14:03:05 -03:00
Hachi-R
1ad501c64e chore: check if uri exists before adding to table 2024-11-04 14:02:48 -03:00
Hachi-R
92ec056ba8 lint 2024-11-04 03:13:50 -03:00
Hachi-R
bd8974c7cb feat: add initial seeding logic and separation between seeding from downloading 2024-11-04 03:13:17 -03:00
Hachi-R
83b7fb83ab feat: add seed-list table 2024-11-03 21:39:05 -03:00
Hachi-R
dc4dda7e17 Merge branch 'feature/seed-completed-downloads' of https://github.com/hydralauncher/hydra into feature/seed-completed-downloads 2024-10-31 23:06:50 -03:00
Hachi-R
1ebf8acb9b lint 2024-10-31 23:05:59 -03:00
Hachi-R
0955af1e69 feat: add option in user preferences to seed after download completes 2024-10-31 23:05:59 -03:00
Hachi-R
fd7f2403da lint 2024-10-31 23:02:44 -03:00
Hachi-R
e331b9b246 feat: add option in user preferences to seed after download completes 2024-10-31 23:02:23 -03:00
24 changed files with 369 additions and 70 deletions

View File

@@ -198,7 +198,10 @@
"queued": "Queued", "queued": "Queued",
"no_downloads_title": "Such empty", "no_downloads_title": "Such empty",
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start.", "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_seed": "Stop seed",
"resume_seed": "Resume seed"
}, },
"settings": { "settings": {
"downloads_path": "Downloads path", "downloads_path": "Downloads path",
@@ -253,8 +256,9 @@
"must_be_valid_url": "The source must be a valid URL", "must_be_valid_url": "The source must be a valid URL",
"blocked_users": "Blocked users", "blocked_users": "Blocked users",
"user_unblocked": "User has been unblocked", "user_unblocked": "User has been unblocked",
"enable_achievement_notifications": "When an achievement is unlocked", "enable_achievement_notifications": "When an achievement in unlocked",
"launch_minimized": "Launch Hydra minimized" "launch_minimized": "Launch Hydra minimized",
"seed_after_download_completes": "Seed after download completes"
}, },
"notifications": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",

View File

@@ -194,7 +194,10 @@
"queued": "Na fila", "queued": "Na fila",
"no_downloads_title": "Nada por aqui…", "no_downloads_title": "Nada por aqui…",
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar.", "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_seed": "Parar seed",
"resume_seed": "Retomar seed"
}, },
"settings": { "settings": {
"downloads_path": "Diretório dos downloads", "downloads_path": "Diretório dos downloads",

View File

@@ -9,6 +9,7 @@ import {
UserAuth, UserAuth,
GameAchievement, GameAchievement,
UserSubscription, UserSubscription,
SeedList,
} from "@main/entity"; } from "@main/entity";
import { databasePath } from "./constants"; import { databasePath } from "./constants";
@@ -25,6 +26,7 @@ export const dataSource = new DataSource({
DownloadSource, DownloadSource,
DownloadQueue, DownloadQueue,
GameAchievement, GameAchievement,
SeedList,
], ],
synchronize: false, synchronize: false,
database: databasePath, database: databasePath,

View File

@@ -54,6 +54,9 @@ export class Game {
@Column("int", { default: Downloader.Torrent }) @Column("int", { default: Downloader.Torrent })
downloader: Downloader; downloader: Downloader;
@Column("boolean", { default: false })
shouldSeed: boolean;
/** /**
* Progress is a float between 0 and 1 * Progress is a float between 0 and 1
*/ */

View File

@@ -8,3 +8,4 @@ export * from "./game.entity";
export * from "./game-achievements.entity"; export * from "./game-achievements.entity";
export * from "./download-source.entity"; export * from "./download-source.entity";
export * from "./download-queue.entity"; export * from "./download-queue.entity";
export * from "./seed-list.entity";

View File

@@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Column,
} from "typeorm";
@Entity("seed_list")
export class SeedList {
@PrimaryGeneratedColumn()
id: number;
@Column("text")
downloadUri: string;
@Column("boolean", { default: false })
shouldSeed: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -38,6 +38,9 @@ export class UserPreferences {
@Column("boolean", { default: false }) @Column("boolean", { default: false })
startMinimized: boolean; startMinimized: boolean;
@Column("boolean", { default: true })
seedAfterDownloadCompletes: boolean;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View File

@@ -12,6 +12,9 @@ import { CreateUserSubscription } from "./migrations/20241015235142_create_user_
import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_background_image_url"; import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_background_image_url";
import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game"; import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game";
import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_column"; import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_column";
import { AddSeedAfterDownloadCompletesColumn } from "./migrations/20241101012727_add_seed_after_download_completes_column";
import { AddSeedListTable } from "./migrations/20241103231555_add_seed_list_table";
import { AddShouldSeedColumn } from "./migrations/20241107211345_add_should_seed_colum";
export type HydraMigration = Knex.Migration & { name: string }; export type HydraMigration = Knex.Migration & { name: string };
class MigrationSource implements Knex.MigrationSource<HydraMigration> { class MigrationSource implements Knex.MigrationSource<HydraMigration> {
@@ -28,6 +31,9 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
AddBackgroundImageUrl, AddBackgroundImageUrl,
AddWinePrefixToGame, AddWinePrefixToGame,
AddStartMinimizedColumn, AddStartMinimizedColumn,
AddSeedAfterDownloadCompletesColumn,
AddSeedListTable,
AddShouldSeedColumn,
]); ]);
} }
getMigrationName(migration: HydraMigration): string { getMigrationName(migration: HydraMigration): string {

View File

@@ -0,0 +1,20 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddSeedAfterDownloadCompletesColumn: HydraMigration = {
name: "AddSeedAfterDownloadCompletesColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table
.boolean("seedAfterDownloadCompletes")
.notNullable()
.defaultTo(1);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("seedAfterDownloadCompletes");
});
},
};

View File

@@ -0,0 +1,19 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddSeedListTable: HydraMigration = {
name: "AddSeedListTable",
up: (knex: Knex) => {
return knex.schema.createTable("seed_list", async (table) => {
table.increments("id").primary();
table.text("downloadUri").notNullable();
table.boolean("shouldSeed").defaultTo(false);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
},
down: async (knex: Knex) => {
return knex.schema.dropTable("seed_list");
},
};

View 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(false);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.dropColumn("shouldSeed");
});
},
};

View File

@@ -9,6 +9,7 @@ import {
UserAuth, UserAuth,
GameAchievement, GameAchievement,
UserSubscription, UserSubscription,
SeedList,
} from "@main/entity"; } from "@main/entity";
export const gameRepository = dataSource.getRepository(Game); export const gameRepository = dataSource.getRepository(Game);
@@ -27,6 +28,8 @@ export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
export const userAuthRepository = dataSource.getRepository(UserAuth); export const userAuthRepository = dataSource.getRepository(UserAuth);
export const seedListRepository = dataSource.getRepository(SeedList);
export const userSubscriptionRepository = export const userSubscriptionRepository =
dataSource.getRepository(UserSubscription); dataSource.getRepository(UserSubscription);

View File

@@ -2,7 +2,11 @@ import { Game } from "@main/entity";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { PythonInstance } from "./python-instance"; import { PythonInstance } from "./python-instance";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import { downloadQueueRepository, gameRepository } from "@main/repository"; import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications"; import { publishDownloadCompleteNotification } from "../notifications";
import { RealDebridDownloader } from "./real-debrid-downloader"; import { RealDebridDownloader } from "./real-debrid-downloader";
import type { DownloadProgress } from "@types"; import type { DownloadProgress } from "@types";
@@ -50,6 +54,19 @@ export class DownloadManager {
await downloadQueueRepository.delete({ game }); await downloadQueueRepository.delete({ game });
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (
userPreferences?.seedAfterDownloadCompletes &&
this.currentDownloader === Downloader.Torrent
) {
if (!game.shouldSeed) {
await gameRepository.update(game.id, { shouldSeed: true });
}
}
const [nextQueueItem] = await downloadQueueRepository.find({ const [nextQueueItem] = await downloadQueueRepository.find({
order: { order: {
id: "DESC", id: "DESC",
@@ -66,6 +83,21 @@ export class DownloadManager {
} }
} }
public static async watchSeedingList() {
const shouldSeedGames = await gameRepository.findOne({
where: { shouldSeed: true },
});
if (shouldSeedGames) {
const seedingList = await PythonInstance.getSeedingList();
WindowManager.mainWindow?.webContents.send(
"on-seeding-list",
JSON.parse(JSON.stringify(seedingList))
);
}
}
static async pauseDownload() { static async pauseDownload() {
if (this.currentDownloader === Downloader.Torrent) { if (this.currentDownloader === Downloader.Torrent) {
await PythonInstance.pauseDownload(); await PythonInstance.pauseDownload();

View File

@@ -16,8 +16,9 @@ import {
StartDownloadPayload, StartDownloadPayload,
PauseDownloadPayload, PauseDownloadPayload,
LibtorrentStatus, LibtorrentStatus,
LibtorrentPayload,
ProcessPayload, ProcessPayload,
LibtorrentSeedingPayload,
LibtorrentDownloadingPayload,
} from "./types"; } from "./types";
import { pythonInstanceLogger as logger } from "../logger"; import { pythonInstanceLogger as logger } from "../logger";
@@ -60,67 +61,87 @@ export class PythonInstance {
); );
} }
public static async getSeedingList() {
const response = await this.rpc.get<LibtorrentSeedingPayload[] | null>(
"/seed-list"
);
if (response.data && response.data.length > 0) {
for (const seed of response.data) {
await gameRepository.update({ id: seed.gameId }, { status: "seeding" });
}
}
return response.data;
}
public static async getStatus() { public static async getStatus() {
if (this.downloadingGameId === -1) return null; const response = await this.rpc.get<LibtorrentDownloadingPayload | null>(
"/status"
);
const response = await this.rpc.get<LibtorrentPayload | null>("/status"); if (response.data) {
try {
if (response.data === null) return null; const {
progress,
try { numPeers,
const { numSeeds,
progress, downloadSpeed,
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, bytesDownloaded,
fileSize, 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 &&
status !== LibtorrentStatus.Seeding
) {
this.downloadingGameId = -1;
}
return {
numPeers,
numSeeds,
downloadSpeed,
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
isDownloadingMetadata,
isCheckingFiles,
progress, progress,
status: "active", gameId,
}; } as DownloadProgress;
} catch (err) {
await gameRepository.update( return null;
{ 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;
} }
return null;
} }
static async pauseDownload() { static async pauseDownload() {

View File

@@ -20,7 +20,7 @@ export enum LibtorrentStatus {
Seeding = 5, Seeding = 5,
} }
export interface LibtorrentPayload { export interface LibtorrentDownloadingPayload {
progress: number; progress: number;
numPeers: number; numPeers: number;
numSeeds: number; numSeeds: number;
@@ -32,7 +32,22 @@ export interface LibtorrentPayload {
gameId: number; gameId: number;
} }
export interface LibtorrentSeedingPayload {
progress: number;
numPeers: number;
numSeeds: number;
uploadSpeed: number;
fileSize: number;
folderName: string;
status: LibtorrentStatus;
gameId: number;
}
export interface ProcessPayload { export interface ProcessPayload {
exe: string; exe: string;
pid: number; pid: number;
} }
export interface SeedPayload {
should_seed: boolean;
}

View File

@@ -10,6 +10,7 @@ export const startMainLoop = async () => {
watchProcesses(), watchProcesses(),
DownloadManager.watchDownloads(), DownloadManager.watchDownloads(),
AchievementWatcherManager.watchAchievements(), AchievementWatcherManager.watchAchievements(),
DownloadManager.watchSeedingList(),
]); ]);
await sleep(1500); await sleep(1500);

View File

@@ -15,6 +15,7 @@ import type {
import type { CatalogueCategory } from "@shared"; import type { CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import { GameAchievement } from "@main/entity"; import { GameAchievement } from "@main/entity";
import { LibtorrentSeedingPayload } from "@main/services/download/types";
contextBridge.exposeInMainWorld("electron", { contextBridge.exposeInMainWorld("electron", {
/* Torrenting */ /* Torrenting */
@@ -26,6 +27,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("pauseGameDownload", gameId), ipcRenderer.invoke("pauseGameDownload", gameId),
resumeGameDownload: (gameId: number) => resumeGameDownload: (gameId: number) =>
ipcRenderer.invoke("resumeGameDownload", gameId), ipcRenderer.invoke("resumeGameDownload", gameId),
startSeeding: (gameId: number, magnet: string, savePath: string) =>
ipcRenderer.invoke("startSeeding", gameId, magnet, savePath),
onDownloadProgress: (cb: (value: DownloadProgress) => void) => { onDownloadProgress: (cb: (value: DownloadProgress) => void) => {
const listener = ( const listener = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
@@ -34,6 +37,14 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("on-download-progress", listener); ipcRenderer.on("on-download-progress", listener);
return () => ipcRenderer.removeListener("on-download-progress", listener); return () => ipcRenderer.removeListener("on-download-progress", listener);
}, },
onSeedingList: (cb: (value: LibtorrentSeedingPayload[]) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
value: LibtorrentSeedingPayload[]
) => cb(value);
ipcRenderer.on("on-seeding-list", listener);
return () => ipcRenderer.removeListener("on-seeding-list", listener);
},
/* Catalogue */ /* Catalogue */
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query), searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),

View File

@@ -49,6 +49,9 @@ declare global {
onDownloadProgress: ( onDownloadProgress: (
cb: (value: DownloadProgress) => void cb: (value: DownloadProgress) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
onSeedingList: (
cb: (value: LibtorrentSeedingPayload[]) => void
) => () => Electron.IpcRenderer;
/* Catalogue */ /* Catalogue */
searchGames: (query: string) => Promise<CatalogueEntry[]>; searchGames: (query: string) => Promise<CatalogueEntry[]>;

View File

@@ -1,6 +1,7 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useMemo } from "react";
import type { LibraryGame } from "@types"; import type { LibraryGame, SeedingList } from "@types";
import { Badge, Button } from "@renderer/components"; import { Badge, Button } from "@renderer/components";
import { import {
@@ -21,6 +22,7 @@ export interface DownloadGroupProps {
title: string; title: string;
openDeleteGameModal: (gameId: number) => void; openDeleteGameModal: (gameId: number) => void;
openGameInstaller: (gameId: number) => void; openGameInstaller: (gameId: number) => void;
seedingList: SeedingList[];
} }
export function DownloadGroup({ export function DownloadGroup({
@@ -28,6 +30,7 @@ export function DownloadGroup({
title, title,
openDeleteGameModal, openDeleteGameModal,
openGameInstaller, openGameInstaller,
seedingList = [],
}: DownloadGroupProps) { }: DownloadGroupProps) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -46,6 +49,17 @@ export function DownloadGroup({
isGameDeleting, isGameDeleting,
} = useDownload(); } = useDownload();
const seedingMap = useMemo(() => {
if (!Array.isArray(seedingList) || seedingList.length === 0) {
return new Map<number, SeedingList>();
}
const map = new Map<number, SeedingList>();
seedingList.forEach((seed) => {
map.set(seed.gameId, seed);
});
return map;
}, [seedingList]);
const getFinalDownloadSize = (game: LibraryGame) => { const getFinalDownloadSize = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id; const isGameDownloading = lastPacket?.game.id === game.id;
@@ -60,6 +74,7 @@ export function DownloadGroup({
const getGameInfo = (game: LibraryGame) => { const getGameInfo = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id; const isGameDownloading = lastPacket?.game.id === game.id;
const finalDownloadSize = getFinalDownloadSize(game); const finalDownloadSize = getFinalDownloadSize(game);
const seed = seedingMap.get(game.id);
if (isGameDeleting(game.id)) { if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>; return <p>{t("deleting")}</p>;
@@ -98,7 +113,18 @@ export function DownloadGroup({
} }
if (game.progress === 1) { if (game.progress === 1) {
return <p>{t("completed")}</p>; return (
<>
{seed ? (
<>
<p>{t("seeding")}</p>
<p>{formatBytes(seed.uploadSpeed ?? 0)}/s</p>
</>
) : (
<p>{t("completed")}</p>
)}
</>
);
} }
if (game.status === "paused") { if (game.status === "paused") {
@@ -127,8 +153,8 @@ export function DownloadGroup({
const getGameActions = (game: LibraryGame) => { const getGameActions = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id; const isGameDownloading = lastPacket?.game.id === game.id;
const deleting = isGameDeleting(game.id); const deleting = isGameDeleting(game.id);
const seed = seedingMap.get(game.id);
if (game.progress === 1) { if (game.progress === 1) {
return ( return (
@@ -144,6 +170,14 @@ export function DownloadGroup({
<Button onClick={() => openDeleteGameModal(game.id)} theme="outline"> <Button onClick={() => openDeleteGameModal(game.id)} theme="outline">
{t("delete")} {t("delete")}
</Button> </Button>
{seed && game.shouldSeed && (
<Button theme="outline">{t("stop_seed")}</Button>
)}
{seed && !game.shouldSeed && (
<Button theme="outline">{t("resume_seed")}</Button>
)}
</> </>
); );
} }

View File

@@ -2,12 +2,12 @@ import { useTranslation } from "react-i18next";
import { useDownload, useLibrary } from "@renderer/hooks"; 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 { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./downloads.css"; import * as styles from "./downloads.css";
import { DeleteGameModal } from "./delete-game-modal"; import { DeleteGameModal } from "./delete-game-modal";
import { DownloadGroup } from "./download-group"; import { DownloadGroup } from "./download-group";
import type { LibraryGame } from "@types"; import type { LibraryGame, SeedingList } from "@types";
import { orderBy } from "lodash-es"; import { orderBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react"; import { ArrowDownIcon } from "@primer/octicons-react";
@@ -30,6 +30,12 @@ export default function Downloads() {
const { lastPacket } = useDownload(); const { lastPacket } = useDownload();
const [seedingList, setSeedingList] = useState<SeedingList[]>([]);
useEffect(() => {
window.electron.onSeedingList((value) => setSeedingList(value));
}, []);
const handleOpenGameInstaller = (gameId: number) => const handleOpenGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => { window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true); if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
@@ -122,6 +128,7 @@ export default function Downloads() {
library={group.library} library={group.library}
openDeleteGameModal={handleOpenDeleteGameModal} openDeleteGameModal={handleOpenDeleteGameModal}
openGameInstaller={handleOpenGameInstaller} openGameInstaller={handleOpenGameInstaller}
seedingList={seedingList}
/> />
))} ))}
</div> </div>

View File

@@ -18,6 +18,7 @@ export function SettingsBehavior() {
preferQuitInsteadOfHiding: false, preferQuitInsteadOfHiding: false,
runAtStartup: false, runAtStartup: false,
startMinimized: false, startMinimized: false,
seedAfterDownloadCompletes: true,
}); });
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
@@ -28,6 +29,7 @@ export function SettingsBehavior() {
preferQuitInsteadOfHiding: userPreferences.preferQuitInsteadOfHiding, preferQuitInsteadOfHiding: userPreferences.preferQuitInsteadOfHiding,
runAtStartup: userPreferences.runAtStartup, runAtStartup: userPreferences.runAtStartup,
startMinimized: userPreferences.startMinimized, startMinimized: userPreferences.startMinimized,
seedAfterDownloadCompletes: userPreferences.seedAfterDownloadCompletes,
}); });
} }
}, [userPreferences]); }, [userPreferences]);
@@ -86,6 +88,16 @@ export function SettingsBehavior() {
/> />
</div> </div>
)} )}
<CheckboxField
label={t("seed_after_download_completes")}
checked={form.seedAfterDownloadCompletes}
onChange={() =>
handleChange({
seedAfterDownloadCompletes: !form.seedAfterDownloadCompletes,
})
}
/>
</> </>
); );
} }

View File

@@ -7,7 +7,8 @@ export type GameStatus =
| "paused" | "paused"
| "error" | "error"
| "complete" | "complete"
| "removed"; | "removed"
| "seeding";
export type GameShop = "steam" | "epic"; export type GameShop = "steam" | "epic";
@@ -124,6 +125,7 @@ export interface Game {
objectID: string; objectID: string;
shop: GameShop; shop: GameShop;
downloadQueue: DownloadQueue | null; downloadQueue: DownloadQueue | null;
shouldSeed: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -151,6 +153,16 @@ export interface DownloadProgress {
game: LibraryGame; game: LibraryGame;
} }
export interface SeedingList {
progress: number;
numPeers: number;
numSeeds: number;
uploadSpeed: number;
gameId: number;
folderName: string;
fileSize: number;
}
export interface UserPreferences { export interface UserPreferences {
downloadsPath: string | null; downloadsPath: string | null;
language: string; language: string;
@@ -161,6 +173,7 @@ export interface UserPreferences {
preferQuitInsteadOfHiding: boolean; preferQuitInsteadOfHiding: boolean;
runAtStartup: boolean; runAtStartup: boolean;
startMinimized: boolean; startMinimized: boolean;
seedAfterDownloadCompletes: boolean;
} }
export interface Steam250Game { export interface Steam250Game {

View File

@@ -50,6 +50,21 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(json.dumps(status).encode('utf-8')) self.wfile.write(json.dumps(status).encode('utf-8'))
elif self.path == "/seed-list":
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
seed_list = torrent_downloader.get_seed_list()
self.wfile.write(json.dumps(seed_list).encode('utf-8'))
elif self.path == "/healthcheck": elif self.path == "/healthcheck":
self.send_response(200) self.send_response(200)
self.end_headers() self.end_headers()
@@ -107,6 +122,8 @@ class Handler(BaseHTTPRequestHandler):
elif data['action'] == 'kill-torrent': elif data['action'] == 'kill-torrent':
torrent_downloader.abort_session() torrent_downloader.abort_session()
torrent_downloader = None torrent_downloader = None
elif data['action'] == 'start-seeding':
torrent_downloader.start_seeding(data['game_id'], data['magnet'], data['save_path'])
self.send_response(200) self.send_response(200)
self.end_headers() self.end_headers()

View File

@@ -106,7 +106,7 @@ class TorrentDownloader:
params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers} params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers}
torrent_handle = self.session.add_torrent(params) torrent_handle = self.session.add_torrent(params)
self.torrent_handles[game_id] = torrent_handle self.torrent_handles[game_id] = torrent_handle
torrent_handle.set_flags(lt.torrent_flags.auto_managed) torrent_handle.set_flags(lt.torrent_flags.auto_managed, lt.torrent_flags.seed_mode)
torrent_handle.resume() torrent_handle.resume()
self.downloading_game_id = game_id self.downloading_game_id = game_id
@@ -151,6 +151,7 @@ class TorrentDownloader:
'gameId': self.downloading_game_id, 'gameId': self.downloading_game_id,
'progress': status.progress, 'progress': status.progress,
'downloadSpeed': status.download_rate, 'downloadSpeed': status.download_rate,
'uploadSpeed': status.upload_rate,
'numPeers': status.num_peers, 'numPeers': status.num_peers,
'numSeeds': status.num_seeds, 'numSeeds': status.num_seeds,
'status': status.state, 'status': status.state,
@@ -158,8 +159,34 @@ class TorrentDownloader:
} }
if status.progress == 1: if status.progress == 1:
torrent_handle.pause()
self.session.remove_torrent(torrent_handle)
self.downloading_game_id = -1 self.downloading_game_id = -1
return response return response
def get_seed_list(self):
response = []
for game_id, torrent_handle in self.torrent_handles.items():
if game_id == self.downloading_game_id:
continue
status = torrent_handle.status()
info = torrent_handle.torrent_file()
torrent_info = {
'folderName': info.name() if info else "",
'fileSize': info.total_size() if info else 0,
'gameId': game_id,
'progress': status.progress,
'downloadSpeed': status.download_rate,
'uploadSpeed': status.upload_rate,
'numPeers': status.num_peers,
'numSeeds': status.num_seeds,
'status': status.state,
'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download,
}
if status.state == 5:
response.append(torrent_info)
return response