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
46 changed files with 462 additions and 328 deletions

View File

@@ -1,4 +1,4 @@
MAIN_VITE_API_URL=API_URL
MAIN_VITE_AUTH_URL=AUTH_URL
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
RENDERER_VITE_INTERCOM_APP_ID=YOUR_APP_ID

View File

@@ -44,7 +44,6 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }}
RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Windows
@@ -55,7 +54,6 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }}
RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create artifact

View File

@@ -46,8 +46,8 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }}
RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Windows
if: matrix.os == 'windows-latest'
run: yarn build:win
@@ -56,8 +56,8 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }}
RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create artifact
uses: actions/upload-artifact@v4
with:

View File

@@ -36,7 +36,6 @@
"@electron-toolkit/utils": "^3.0.0",
"@fontsource/noto-sans": "^5.0.22",
"@hookform/resolvers": "^3.9.0",
"@intercom/messenger-js-sdk": "^0.0.14",
"@primer/octicons-react": "^19.9.0",
"@reduxjs/toolkit": "^2.2.3",
"@vanilla-extract/css": "^1.14.2",

View File

@@ -25,8 +25,7 @@
"queued": "{{title}} (Queued)",
"game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in",
"friends": "Friends",
"need_help": "Need help?"
"friends": "Friends"
},
"header": {
"search": "Search games",
@@ -199,7 +198,10 @@
"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_seed": "Stop seed",
"resume_seed": "Resume seed"
},
"settings": {
"downloads_path": "Downloads path",
@@ -254,8 +256,9 @@
"must_be_valid_url": "The source must be a valid URL",
"blocked_users": "Blocked users",
"user_unblocked": "User has been unblocked",
"enable_achievement_notifications": "When an achievement is unlocked",
"launch_minimized": "Launch Hydra minimized"
"enable_achievement_notifications": "When an achievement in unlocked",
"launch_minimized": "Launch Hydra minimized",
"seed_after_download_completes": "Seed after download completes"
},
"notifications": {
"download_complete": "Download complete",

View File

@@ -25,8 +25,7 @@
"queued": "{{title}} (En cola)",
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
"sign_in": "Iniciar sesión",
"friends": "Amigos",
"need_help": "¿Necesitas ayuda?"
"friends": "Amigos"
},
"header": {
"search": "Buscar juegos",

View File

@@ -25,8 +25,7 @@
"queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login",
"friends": "Amigos",
"need_help": "Precisa de ajuda?"
"friends": "Amigos"
},
"header": {
"search": "Buscar jogos",
@@ -195,7 +194,10 @@
"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_seed": "Parar seed",
"resume_seed": "Retomar seed"
},
"settings": {
"downloads_path": "Diretório dos downloads",

View File

@@ -24,8 +24,7 @@
"queued": "{{title}} (В очереди)",
"game_has_no_executable": "Файл запуска игры не выбран",
"sign_in": "Войти",
"friends": "Друзья",
"need_help": "Нужна помощь?"
"friends": "Друзья"
},
"header": {
"search": "Поиск",

View File

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

View File

@@ -54,6 +54,9 @@ export class Game {
@Column("int", { default: Downloader.Torrent })
downloader: Downloader;
@Column("boolean", { default: false })
shouldSeed: boolean;
/**
* 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 "./download-source.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 })
startMinimized: boolean;
@Column("boolean", { default: true })
seedAfterDownloadCompletes: boolean;
@CreateDateColumn()
createdAt: Date;

View File

@@ -91,15 +91,9 @@ const startGameDownload = async (
logger.error("Failed to create game download", err);
});
if (uri.startsWith("magnet:")) {
try {
const { infoHash } = await parseTorrent(payload.uri);
if (infoHash) {
HydraAnalytics.postDownload(infoHash).catch(() => {});
}
} catch (err) {
logger.error("Failed to parse torrent", err);
}
const { infoHash } = await parseTorrent(payload.uri);
if (infoHash) {
HydraAnalytics.postDownload(infoHash).catch(() => {});
}
await DownloadManager.cancelDownload(updatedGame!.id);

View File

@@ -12,6 +12,9 @@ import { CreateUserSubscription } from "./migrations/20241015235142_create_user_
import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_background_image_url";
import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game";
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 };
class MigrationSource implements Knex.MigrationSource<HydraMigration> {
@@ -28,6 +31,9 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
AddBackgroundImageUrl,
AddWinePrefixToGame,
AddStartMinimizedColumn,
AddSeedAfterDownloadCompletesColumn,
AddSeedListTable,
AddShouldSeedColumn,
]);
}
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,
GameAchievement,
UserSubscription,
SeedList,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
@@ -27,6 +28,8 @@ export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
export const userAuthRepository = dataSource.getRepository(UserAuth);
export const seedListRepository = dataSource.getRepository(SeedList);
export const userSubscriptionRepository =
dataSource.getRepository(UserSubscription);

View File

@@ -102,7 +102,7 @@ export const mergeAchievements = async (
);
});
})
.filter((achievement) => Boolean(achievement))
.filter((achievement) => achievement)
.map((achievement) => {
return {
displayName: achievement!.displayName,

View File

@@ -2,7 +2,11 @@ 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";
@@ -50,6 +54,19 @@ export class DownloadManager {
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({
order: {
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() {
if (this.currentDownloader === Downloader.Torrent) {
await PythonInstance.pauseDownload();

View File

@@ -16,8 +16,9 @@ import {
StartDownloadPayload,
PauseDownloadPayload,
LibtorrentStatus,
LibtorrentPayload,
ProcessPayload,
LibtorrentSeedingPayload,
LibtorrentDownloadingPayload,
} from "./types";
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() {
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 === 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> = {
if (response.data) {
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 &&
status !== LibtorrentStatus.Seeding
) {
this.downloadingGameId = -1;
}
return {
numPeers,
numSeeds,
downloadSpeed,
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
isDownloadingMetadata,
isCheckingFiles,
progress,
status: "active",
};
await gameRepository.update(
{ id: gameId },
{
...update,
folderName,
}
);
gameId,
} as DownloadProgress;
} catch (err) {
return null;
}
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() {

View File

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

View File

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

View File

@@ -56,7 +56,6 @@ export const getUserData = () => {
id: loggedUser.userId,
username: "",
bio: "",
email: null,
profileVisibility: "PUBLIC" as ProfileVisibility,
subscription: loggedUser.subscription
? {

View File

@@ -85,10 +85,6 @@ export class WindowManager {
return callback(details);
}
if (details.url.includes("intercom.io")) {
return callback(details);
}
const headers = {
"access-control-allow-origin": ["*"],
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],

View File

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

View File

@@ -6,7 +6,7 @@
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src *; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *; connect-src *; font-src *;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *;"
/>
</head>
<body>

View File

@@ -126,9 +126,3 @@ export const titleBar = style({
zIndex: "4",
borderBottom: `1px solid ${vars.color.border}`,
} as ComplexStyleRule);
export const cloudText = style({
background: "linear-gradient(270deg, #16B195 50%, #3E62C0 100%)",
backgroundClip: "text",
color: "transparent",
});

View File

@@ -2,8 +2,6 @@ import { useCallback, useContext, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import Intercom from "@intercom/messenger-js-sdk";
import {
useAppDispatch,
useAppSelector,
@@ -36,12 +34,6 @@ export interface AppProps {
children: React.ReactNode;
}
console.log(import.meta.env);
Intercom({
app_id: import.meta.env.RENDERER_VITE_INTERCOM_APP_ID,
});
export function App() {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary, library } = useLibrary();
@@ -62,13 +54,8 @@ export function App() {
hideFriendsModal,
} = useUserDetails();
const {
userDetails,
hasActiveSubscription,
fetchUserDetails,
updateUserDetails,
clearUserDetails,
} = useUserDetails();
const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails();
const dispatch = useAppDispatch();
@@ -217,9 +204,7 @@ export function App() {
useEffect(() => {
new MutationObserver(() => {
const modal = document.body.querySelector(
"[role=dialog]:not([data-intercom-frame='true'])"
);
const modal = document.body.querySelector("[role=dialog]");
dispatch(toggleDraggingDisabled(Boolean(modal)));
}).observe(document.body, {
@@ -285,12 +270,7 @@ export function App() {
<>
{window.electron.platform === "win32" && (
<div className={styles.titleBar}>
<h4>
Hydra
{hasActiveSubscription && (
<span className={styles.cloudText}> Cloud</span>
)}
</h4>
<h4>Hydra</h4>
</div>
)}

View File

@@ -4,7 +4,7 @@
color: globals.$muted-color;
font-size: 10px;
padding: calc(globals.$spacing-unit / 2) globals.$spacing-unit;
border: solid 1px globals.$muted-color;
border: solid 1px globals.$border-color;
border-radius: 4px;
display: flex;
align-items: center;

View File

@@ -13,7 +13,6 @@ export const sidebar = recipe({
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
overflow: "hidden",
justifyContent: "space-between",
},
variants: {
resizing: {
@@ -125,28 +124,3 @@ export const section = style({
flexDirection: "column",
paddingBottom: `${SPACING_UNIT}px`,
});
export const helpButton = style({
color: vars.color.muted,
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
gap: "9px",
display: "flex",
alignItems: "center",
cursor: "pointer",
borderTop: `solid 1px ${vars.color.border}`,
transition: "background-color ease 0.1s",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const helpButtonIcon = style({
background: "linear-gradient(0deg, #16B195 50%, #3E62C0 100%)",
width: "24px",
height: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
borderRadius: "50%",
});

View File

@@ -5,12 +5,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import type { LibraryGame } from "@types";
import { TextField } from "@renderer/components";
import {
useDownload,
useLibrary,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import { routes } from "./routes";
@@ -20,9 +15,6 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import { CommentDiscussionIcon } from "@primer/octicons-react";
import { show, update } from "@intercom/messenger-js-sdk";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@@ -50,20 +42,6 @@ export function Sidebar() {
return sortBy(library, (game) => game.title);
}, [library]);
const { userDetails, hasActiveSubscription } = useUserDetails();
useEffect(() => {
if (userDetails) {
update({
name: userDetails.displayName,
Username: userDetails.username,
Email: userDetails.email,
"Subscription expiration date": userDetails?.subscription?.expiresAt,
"Payment status": userDetails?.subscription?.status,
});
}
}, [userDetails, hasActiveSubscription]);
const { lastPacket, progress } = useDownload();
const { showWarningToast } = useToast();
@@ -188,91 +166,77 @@ export function Sidebar() {
maxWidth: sidebarWidth,
}}
>
<div
style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}
>
<SidebarProfile />
<SidebarProfile />
<div className={styles.content}>
<section className={styles.section}>
<ul className={styles.menu}>
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={styles.menuItem({
active: location.pathname === path,
})}
<div className={styles.content}>
<section className={styles.section}>
<ul className={styles.menu}>
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={styles.menuItem({
active: location.pathname === path,
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() => handleSidebarItemClick(path)}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() => handleSidebarItemClick(path)}
>
{render()}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
{render()}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
<section className={styles.section}>
<small className={styles.sectionTitle}>{t("my_library")}</small>
<section className={styles.section}>
<small className={styles.sectionTitle}>{t("my_library")}</small>
<TextField
ref={filterRef}
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<TextField
ref={filterRef}
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<ul className={styles.menu}>
{filteredLibrary.map((game) => (
<li
key={game.id}
className={styles.menuItem({
active:
location.pathname ===
`/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed",
})}
<ul className={styles.menu}>
{filteredLibrary.map((game) => (
<li
key={game.id}
className={styles.menuItem({
active:
location.pathname === `/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed",
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={(event) => handleSidebarGameClick(event, game)}
>
<button
type="button"
className={styles.menuItemButton}
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
<img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className={styles.gameIcon} />
)}
{game.iconUrl ? (
<img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className={styles.gameIcon} />
)}
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
</div>
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
</div>
{hasActiveSubscription && (
<button type="button" className={styles.helpButton} onClick={show}>
<div className={styles.helpButtonIcon}>
<CommentDiscussionIcon size={14} />
</div>
<span>{t("need_help")}</span>
</button>
)}
<button
type="button"
className={styles.handle}

View File

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

View File

@@ -10,22 +10,12 @@ export interface HowLongToBeatEntry {
updatedAt: Date;
}
export interface CatalogueCache {
id?: number;
category: string;
games: { objectId: string; shop: GameShop }[];
createdAt: Date;
updatedAt: Date;
expiresAt: Date;
}
export const db = new Dexie("Hydra");
db.version(5).stores({
db.version(4).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
catalogueCache: `++id, category, games, createdAt, updatedAt, expiresAt`,
});
export const downloadSourcesTable = db.table("downloadSources");
@@ -34,6 +24,4 @@ export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
"howLongToBeatEntries"
);
export const catalogueCacheTable = db.table<CatalogueCache>("catalogueCache");
db.open();

View File

View File

@@ -1,6 +1,7 @@
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 {
@@ -21,6 +22,7 @@ export interface DownloadGroupProps {
title: string;
openDeleteGameModal: (gameId: number) => void;
openGameInstaller: (gameId: number) => void;
seedingList: SeedingList[];
}
export function DownloadGroup({
@@ -28,6 +30,7 @@ export function DownloadGroup({
title,
openDeleteGameModal,
openGameInstaller,
seedingList = [],
}: DownloadGroupProps) {
const navigate = useNavigate();
@@ -46,6 +49,17 @@ export function DownloadGroup({
isGameDeleting,
} = 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 isGameDownloading = lastPacket?.game.id === game.id;
@@ -60,6 +74,7 @@ export function DownloadGroup({
const getGameInfo = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
const seed = seedingMap.get(game.id);
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
@@ -98,7 +113,18 @@ export function DownloadGroup({
}
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") {
@@ -127,8 +153,8 @@ export function DownloadGroup({
const getGameActions = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const deleting = isGameDeleting(game.id);
const seed = seedingMap.get(game.id);
if (game.progress === 1) {
return (
@@ -144,6 +170,14 @@ export function DownloadGroup({
<Button onClick={() => openDeleteGameModal(game.id)} theme="outline">
{t("delete")}
</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 { 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, SeedingList } from "@types";
import { orderBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react";
@@ -30,6 +30,12 @@ export default function Downloads() {
const { lastPacket } = useDownload();
const [seedingList, setSeedingList] = useState<SeedingList[]>([]);
useEffect(() => {
window.electron.onSeedingList((value) => setSeedingList(value));
}, []);
const handleOpenGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
@@ -122,6 +128,7 @@ export default function Downloads() {
library={group.library}
openDeleteGameModal={handleOpenDeleteGameModal}
openGameInstaller={handleOpenGameInstaller}
seedingList={seedingList}
/>
))}
</div>

View File

@@ -15,14 +15,6 @@ import * as styles from "./home.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { buildGameDetailsPath } from "@renderer/helpers";
import { CatalogueCategory } from "@shared";
import { catalogueCacheTable, db } from "@renderer/dexie";
import { add } from "date-fns";
const categoryCacheDurationInSeconds = {
[CatalogueCategory.Hot]: 60 * 60 * 2,
[CatalogueCategory.Weekly]: 60 * 60 * 24,
[CatalogueCategory.Achievements]: 60 * 60 * 24,
};
export default function Home() {
const { t } = useTranslation("home");
@@ -44,43 +36,19 @@ export default function Home() {
[CatalogueCategory.Achievements]: [],
});
const getCatalogue = useCallback(async (category: CatalogueCategory) => {
try {
const catalogueCache = await catalogueCacheTable
.where("expiresAt")
.above(new Date())
.and((cache) => cache.category === category)
.first();
const getCatalogue = useCallback((category: CatalogueCategory) => {
setCurrentCatalogueCategory(category);
setIsLoading(true);
setCurrentCatalogueCategory(category);
setIsLoading(true);
if (catalogueCache)
return setCatalogue((prev) => ({
...prev,
[category]: catalogueCache.games,
}));
const catalogue = await window.electron.getCatalogue(category);
db.transaction("rw", catalogueCacheTable, async () => {
await catalogueCacheTable.where("category").equals(category).delete();
await catalogueCacheTable.add({
category,
games: catalogue,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: add(new Date(), {
seconds: categoryCacheDurationInSeconds[category],
}),
});
window.electron
.getCatalogue(category)
.then((catalogue) => {
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
})
.catch(() => {})
.finally(() => {
setIsLoading(false);
});
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
} finally {
setIsLoading(false);
}
}, []);
const getRandomGame = useCallback(() => {

View File

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

View File

@@ -1,10 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
interface ImportMetaEnv {
readonly RENDERER_VITE_INTERCOM_APP_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -7,7 +7,8 @@ export type GameStatus =
| "paused"
| "error"
| "complete"
| "removed";
| "removed"
| "seeding";
export type GameShop = "steam" | "epic";
@@ -124,6 +125,7 @@ export interface Game {
objectID: string;
shop: GameShop;
downloadQueue: DownloadQueue | null;
shouldSeed: boolean;
createdAt: Date;
updatedAt: Date;
}
@@ -151,6 +153,16 @@ export interface DownloadProgress {
game: LibraryGame;
}
export interface SeedingList {
progress: number;
numPeers: number;
numSeeds: number;
uploadSpeed: number;
gameId: number;
folderName: string;
fileSize: number;
}
export interface UserPreferences {
downloadsPath: string | null;
language: string;
@@ -161,6 +173,7 @@ export interface UserPreferences {
preferQuitInsteadOfHiding: boolean;
runAtStartup: boolean;
startMinimized: boolean;
seedAfterDownloadCompletes: boolean;
}
export interface Steam250Game {
@@ -245,7 +258,6 @@ export interface Subscription {
export interface UserDetails {
id: string;
username: string;
email: string | null;
displayName: string;
profileImageUrl: string | null;
backgroundImageUrl: string | null;
@@ -258,7 +270,6 @@ export interface UserProfile {
id: string;
displayName: string;
profileImageUrl: string | null;
email: string | null;
backgroundImageUrl: string | null;
profileVisibility: ProfileVisibility;
libraryGames: UserGame[];
@@ -375,4 +386,4 @@ export interface ComparedAchievements {
export * from "./steam.types";
export * from "./real-debrid.types";
export * from "./ludusavi.types";
export * from "./how-long-to-beat.types";
export * from "./howlongtobeat.types";

View File

@@ -50,6 +50,21 @@ class Handler(BaseHTTPRequestHandler):
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":
self.send_response(200)
self.end_headers()
@@ -107,6 +122,8 @@ class Handler(BaseHTTPRequestHandler):
elif data['action'] == 'kill-torrent':
torrent_downloader.abort_session()
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.end_headers()

View File

@@ -106,7 +106,7 @@ class TorrentDownloader:
params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers}
torrent_handle = self.session.add_torrent(params)
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()
self.downloading_game_id = game_id
@@ -151,6 +151,7 @@ class TorrentDownloader:
'gameId': self.downloading_game_id,
'progress': status.progress,
'downloadSpeed': status.download_rate,
'uploadSpeed': status.upload_rate,
'numPeers': status.num_peers,
'numSeeds': status.num_seeds,
'status': status.state,
@@ -158,8 +159,34 @@ class TorrentDownloader:
}
if status.progress == 1:
torrent_handle.pause()
self.session.remove_torrent(torrent_handle)
self.downloading_game_id = -1
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

View File

@@ -1066,11 +1066,6 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
"@intercom/messenger-js-sdk@^0.0.14":
version "0.0.14"
resolved "https://registry.yarnpkg.com/@intercom/messenger-js-sdk/-/messenger-js-sdk-0.0.14.tgz#a27999370cc0a82a2a57a779426df25a57891863"
integrity sha512-2dH4BDAh9EI90K7hUkAdZ76W79LM45Sd1OBX7t6Vzy8twpNiQ5X+7sH9G5hlJlkSGnf+vFWlFcy9TOYAyEs1hA==
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"