Merge branch 'main' into main

This commit is contained in:
Zamitto 2024-08-25 20:37:49 -03:00 committed by GitHub
commit c6e313f5ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 1304 additions and 851 deletions

View File

@ -1,3 +1,4 @@
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
MAIN_VITE_API_URL=API_URL MAIN_VITE_API_URL=API_URL
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN
SENTRY_AUTH_TOKEN=

View File

@ -37,8 +37,6 @@ jobs:
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: yarn build:linux run: yarn build:linux
env: env:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
@ -48,8 +46,6 @@ jobs:
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
run: yarn build:win run: yarn build:win
env: env:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}

View File

@ -39,8 +39,6 @@ jobs:
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: yarn build:linux run: yarn build:linux
env: env:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
@ -50,8 +48,6 @@ jobs:
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
run: yarn build:win run: yarn build:win
env: env:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }} MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}

View File

@ -5,3 +5,4 @@ pnpm-lock.yaml
LICENSE.md LICENSE.md
tsconfig.json tsconfig.json
tsconfig.*.json tsconfig.*.json
src/main/migrations

View File

@ -174,12 +174,9 @@
"validate_download_source": "Validate", "validate_download_source": "Validate",
"remove_download_source": "Remove", "remove_download_source": "Remove",
"add_download_source": "Add source", "add_download_source": "Add source",
"download_count_zero": "No downloads in list", "download_count_zero": "No download options",
"download_count_one": "{{countFormatted}} download in list", "download_count_one": "{{countFormatted}} download option",
"download_count_other": "{{countFormatted}} downloads in list", "download_count_other": "{{countFormatted}} download options",
"download_options_zero": "No download available",
"download_options_one": "{{countFormatted}} download available",
"download_options_other": "{{countFormatted}} downloads available",
"download_source_url": "Download source URL", "download_source_url": "Download source URL",
"add_download_source_description": "Insert the URL containing the .json file", "add_download_source_description": "Insert the URL containing the .json file",
"download_source_up_to_date": "Up-to-date", "download_source_up_to_date": "Up-to-date",
@ -261,6 +258,18 @@
"undo_friendship": "Undo friendship", "undo_friendship": "Undo friendship",
"request_accepted": "Request accepted", "request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully", "user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}" "user_block_modal_text": "This will block {{displayName}}",
"settings": "Settings",
"public": "Public",
"private": "Private",
"friends_only": "Friends only",
"privacy": "Privacy",
"blocked_users": "Blocked users",
"unblock": "Unblock",
"no_friends_added": "You still don't have added friends",
"pending": "Pending",
"no_pending_invites": "You have no pending invites",
"no_blocked_users": "You have no blocked users",
"friend_code_copied": "Friend code copied"
} }
} }

View File

@ -261,6 +261,18 @@
"undo_friendship": "Desfazer amizade", "undo_friendship": "Desfazer amizade",
"request_accepted": "Pedido de amizade aceito", "request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso", "user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}" "user_block_modal_text": "Bloquear {{displayName}}",
"settings": "Configurações",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
"public": "Público",
"blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "Você ainda não possui amigos adicionados",
"pending": "Pendentes",
"no_pending_invites": "Você não possui convites de amizade pendentes",
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
"friend_code_copied": "Código de amigo copiado"
} }
} }

View File

@ -6,32 +6,24 @@ import {
GameShopCache, GameShopCache,
Repack, Repack,
UserPreferences, UserPreferences,
UserAuth,
} from "@main/entity"; } from "@main/entity";
import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions";
import { databasePath } from "./constants"; import { databasePath } from "./constants";
import migrations from "./migrations"; import * as migrations from "./migrations";
import { UserAuth } from "./entity/user-auth";
export const createDataSource = ( export const dataSource = new DataSource({
options: Partial<BetterSqlite3ConnectionOptions> type: "better-sqlite3",
) => entities: [
new DataSource({ Game,
type: "better-sqlite3", Repack,
entities: [ UserPreferences,
Game, GameShopCache,
Repack, DownloadSource,
UserPreferences, DownloadQueue,
GameShopCache, UserAuth,
DownloadSource, ],
DownloadQueue, synchronize: true,
UserAuth, database: databasePath,
],
synchronize: true,
database: databasePath,
...options,
});
export const dataSource = createDataSource({
migrations, migrations,
}); });

View File

@ -16,11 +16,14 @@ export class Repack {
@Column("text", { unique: true }) @Column("text", { unique: true })
title: string; title: string;
/**
* @deprecated Use uris instead
*/
@Column("text", { unique: true }) @Column("text", { unique: true })
magnet: string; magnet: string;
/** /**
* @deprecated * @deprecated Direct scraping capability has been removed
*/ */
@Column("int", { nullable: true }) @Column("int", { nullable: true })
page: number; page: number;
@ -37,6 +40,9 @@ export class Repack {
@ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" }) @ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" })
downloadSource: DownloadSource; downloadSource: DownloadSource;
@Column("text", { default: "[]" })
uris: string;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View File

@ -1,16 +1,11 @@
import { downloadSourceRepository } from "@main/repository"; import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
return downloadSourceRepository downloadSourceRepository.find({
.createQueryBuilder("downloadSource") order: {
.leftJoin("downloadSource.repacks", "repacks") createdAt: "DESC",
.orderBy("downloadSource.createdAt", "DESC") },
.loadRelationCountAndMap( });
"downloadSource.repackCount",
"downloadSource.repacks"
)
.getMany();
};
registerEvent("getDownloadSources", getDownloadSources); registerEvent("getDownloadSources", getDownloadSources);

View File

@ -43,6 +43,7 @@ import "./auth/sign-out";
import "./auth/open-auth-window"; import "./auth/open-auth-window";
import "./auth/get-session-hash"; import "./auth/get-session-hash";
import "./user/get-user"; import "./user/get-user";
import "./user/get-user-blocks";
import "./user/block-user"; import "./user/block-user";
import "./user/unblock-user"; import "./user/unblock-user";
import "./user/get-user-friends"; import "./user/get-user-friends";
@ -52,11 +53,9 @@ import "./profile/undo-friendship";
import "./profile/update-friend-request"; import "./profile/update-friend-request";
import "./profile/update-profile"; import "./profile/update-profile";
import "./profile/send-friend-request"; import "./profile/send-friend-request";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion()); ipcMain.handle("getVersion", () => app.getVersion());
ipcMain.handle( ipcMain.handle("isPortableVersion", () => isPortableVersion());
"isPortableVersion",
() => process.env.PORTABLE_EXECUTABLE_FILE != null
);
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath); ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);

View File

@ -20,7 +20,7 @@ const removeRemoveGameFromLibrary = async (gameId: number) => {
const game = await gameRepository.findOne({ where: { id: gameId } }); const game = await gameRepository.findOne({ where: { id: gameId } });
if (game?.remoteId) { if (game?.remoteId) {
HydraApi.delete(`/games/${game.remoteId}`).catch(() => {}); HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
} }
}; };

View File

@ -4,33 +4,22 @@ import axios from "axios";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileTypeFromFile } from "file-type"; import { fileTypeFromFile } from "file-type";
import { UserProfile } from "@types"; import { UpdateProfileProps, UserProfile } from "@types";
const patchUserProfile = async ( const patchUserProfile = async (updateProfile: UpdateProfileProps) => {
displayName: string, return HydraApi.patch("/profile", updateProfile);
profileImageUrl?: string
) => {
if (profileImageUrl) {
return HydraApi.patch("/profile", {
displayName,
profileImageUrl,
});
} else {
return HydraApi.patch("/profile", {
displayName,
});
}
}; };
const updateProfile = async ( const updateProfile = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
displayName: string, updateProfile: UpdateProfileProps
newProfileImagePath: string | null
): Promise<UserProfile> => { ): Promise<UserProfile> => {
if (!newProfileImagePath) { if (!updateProfile.profileImageUrl) {
return patchUserProfile(displayName); return patchUserProfile(updateProfile);
} }
const newProfileImagePath = updateProfile.profileImageUrl;
const stats = fs.statSync(newProfileImagePath); const stats = fs.statSync(newProfileImagePath);
const fileBuffer = fs.readFileSync(newProfileImagePath); const fileBuffer = fs.readFileSync(newProfileImagePath);
const fileSizeInBytes = stats.size; const fileSizeInBytes = stats.size;
@ -53,7 +42,7 @@ const updateProfile = async (
}) })
.catch(() => undefined); .catch(() => undefined);
return patchUserProfile(displayName, profileImageUrl); return patchUserProfile({ ...updateProfile, profileImageUrl });
}; };
registerEvent("updateProfile", updateProfile); registerEvent("updateProfile", updateProfile);

View File

@ -18,7 +18,8 @@ const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
payload: StartGameDownloadPayload payload: StartGameDownloadPayload
) => { ) => {
const { repackId, objectID, title, shop, downloadPath, downloader } = payload; const { repackId, objectID, title, shop, downloadPath, downloader, uri } =
payload;
const [game, repack] = await Promise.all([ const [game, repack] = await Promise.all([
gameRepository.findOne({ gameRepository.findOne({
@ -54,7 +55,7 @@ const startGameDownload = async (
bytesDownloaded: 0, bytesDownloaded: 0,
downloadPath, downloadPath,
downloader, downloader,
uri: repack.magnet, uri,
isDeleted: false, isDeleted: false,
} }
); );
@ -76,7 +77,7 @@ const startGameDownload = async (
shop, shop,
status: "active", status: "active",
downloadPath, downloadPath,
uri: repack.magnet, uri,
}) })
.then((result) => { .then((result) => {
if (iconUrl) { if (iconUrl) {
@ -100,6 +101,7 @@ const startGameDownload = async (
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
await DownloadManager.cancelDownload(updatedGame!.id);
await DownloadManager.startDownload(updatedGame!); await DownloadManager.startDownload(updatedGame!);
}; };

View File

@ -5,7 +5,7 @@ const blockUser = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
userId: string userId: string
) => { ) => {
await HydraApi.post(`/user/${userId}/block`); await HydraApi.post(`/users/${userId}/block`);
}; };
registerEvent("blockUser", blockUser); registerEvent("blockUser", blockUser);

View File

@ -0,0 +1,13 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserBlocks } from "@types";
export const getUserBlocks = async (
_event: Electron.IpcMainInvokeEvent,
take: number,
skip: number
): Promise<UserBlocks> => {
return HydraApi.get(`/profile/blocks`, { take, skip });
};
registerEvent("getUserBlocks", getUserBlocks);

View File

@ -14,7 +14,7 @@ export const getUserFriends = async (
return HydraApi.get(`/profile/friends`, { take, skip }); return HydraApi.get(`/profile/friends`, { take, skip });
} }
return HydraApi.get(`/user/${userId}/friends`, { take, skip }); return HydraApi.get(`/users/${userId}/friends`, { take, skip });
}; };
const getUserFriendsEvent = async ( const getUserFriendsEvent = async (

View File

@ -1,7 +1,7 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { UserProfile } from "@types"; import { GameRunning, UserGame, UserProfile } from "@types";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { getSteamAppAsset } from "@main/helpers"; import { getSteamAppAsset } from "@main/helpers";
import { getUserFriends } from "./get-user-friends"; import { getUserFriends } from "./get-user-friends";
@ -12,7 +12,7 @@ const getUser = async (
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
try { try {
const [profile, friends] = await Promise.all([ const [profile, friends] = await Promise.all([
HydraApi.get(`/user/${userId}`), HydraApi.get(`/users/${userId}`),
getUserFriends(userId, 12, 0).catch(() => { getUserFriends(userId, 12, 0).catch(() => {
return { totalFriends: 0, friends: [] }; return { totalFriends: 0, friends: [] };
}), }),
@ -20,48 +20,57 @@ const getUser = async (
const recentGames = await Promise.all( const recentGames = await Promise.all(
profile.recentGames.map(async (game) => { profile.recentGames.map(async (game) => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), { return getSteamUserGame(game);
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
return {
...game,
...convertSteamGameToCatalogueEntry(steamGame),
iconUrl,
};
}) })
); );
const libraryGames = await Promise.all( const libraryGames = await Promise.all(
profile.libraryGames.map(async (game) => { profile.libraryGames.map(async (game) => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), { return getSteamUserGame(game);
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
return {
...game,
...convertSteamGameToCatalogueEntry(steamGame),
iconUrl,
};
}) })
); );
const currentGame = await getGameRunning(profile.currentGame);
return { return {
...profile, ...profile,
libraryGames, libraryGames,
recentGames, recentGames,
friends: friends.friends, friends: friends.friends,
totalFriends: friends.totalFriends, totalFriends: friends.totalFriends,
currentGame,
}; };
} catch (err) { } catch (err) {
return null; return null;
} }
}; };
const getGameRunning = async (currentGame): Promise<GameRunning | null> => {
if (!currentGame) {
return null;
}
const gameRunning = await getSteamUserGame(currentGame);
return {
...gameRunning,
sessionDurationInMillis: currentGame.sessionDurationInSeconds * 1000,
};
};
const getSteamUserGame = async (game): Promise<UserGame> => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
return {
...game,
...convertSteamGameToCatalogueEntry(steamGame),
iconUrl,
};
};
registerEvent("getUser", getUser); registerEvent("getUser", getUser);

View File

@ -5,7 +5,7 @@ const unblockUser = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
userId: string userId: string
) => { ) => {
await HydraApi.post(`/user/${userId}/unblock`); await HydraApi.post(`/users/${userId}/unblock`);
}; };
registerEvent("unblockUser", unblockUser); registerEvent("unblockUser", unblockUser);

View File

@ -17,7 +17,8 @@ export const insertDownloadsFromSource = async (
const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map( const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map(
(download) => ({ (download) => ({
title: download.title, title: download.title,
magnet: download.uris[0], uris: JSON.stringify(download.uris),
magnet: download.uris[0]!,
fileSize: download.fileSize, fileSize: download.fileSize,
repacker: downloadSource.name, repacker: downloadSource.name,
uploadDate: download.uploadDate, uploadDate: download.uploadDate,

View File

@ -1,4 +1,5 @@
import axios from "axios"; import axios from "axios";
import { JSDOM } from "jsdom";
import UserAgent from "user-agents"; import UserAgent from "user-agents";
export const getSteamAppAsset = ( export const getSteamAppAsset = (
@ -48,13 +49,19 @@ export const sleep = (ms: number) =>
export const requestWebPage = async (url: string) => { export const requestWebPage = async (url: string) => {
const userAgent = new UserAgent(); const userAgent = new UserAgent();
return axios const data = await axios
.get(url, { .get(url, {
headers: { headers: {
"User-Agent": userAgent.toString(), "User-Agent": userAgent.toString(),
}, },
}) })
.then((response) => response.data); .then((response) => response.data);
const { window } = new JSDOM(data);
return window.document;
}; };
export const isPortableVersion = () =>
process.env.PORTABLE_EXECUTABLE_FILE != null;
export * from "./download-source"; export * from "./download-source";

View File

@ -20,8 +20,6 @@ autoUpdater.setFeedURL({
autoUpdater.logger = logger; autoUpdater.logger = logger;
logger.log("Init Hydra");
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit(); if (!gotTheLock) app.quit();
@ -123,7 +121,6 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => { app.on("before-quit", () => {
/* Disconnects libtorrent */ /* Disconnects libtorrent */
PythonInstance.kill(); PythonInstance.kill();
logger.log("Quit Hydra");
}); });
app.on("activate", () => { app.on("activate", () => {

View File

@ -1,11 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class FixRepackUploadDate1715900413313 implements MigrationInterface {
public async up(_: QueryRunner): Promise<void> {
return;
}
public async down(_: QueryRunner): Promise<void> {
return;
}
}

View File

@ -1,49 +0,0 @@
import { Game } from "@main/entity";
import { MigrationInterface, QueryRunner } from "typeorm";
export class AlterLastTimePlayedToDatime1716776027208
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
// 2024-05-27 02:08:17
// Mon, 27 May 2024 02:08:17 GMT
const updateLastTimePlayedValues = `
UPDATE game SET lastTimePlayed = (SELECT
SUBSTR(lastTimePlayed, 13, 4) || '-' || -- Year
CASE SUBSTR(lastTimePlayed, 9, 3)
WHEN 'Jan' THEN '01'
WHEN 'Feb' THEN '02'
WHEN 'Mar' THEN '03'
WHEN 'Apr' THEN '04'
WHEN 'May' THEN '05'
WHEN 'Jun' THEN '06'
WHEN 'Jul' THEN '07'
WHEN 'Aug' THEN '08'
WHEN 'Sep' THEN '09'
WHEN 'Oct' THEN '10'
WHEN 'Nov' THEN '11'
WHEN 'Dec' THEN '12'
END || '-' || -- Month
SUBSTR(lastTimePlayed, 6, 2) || ' ' || -- Day
SUBSTR(lastTimePlayed, 18, 8) -- hh:mm:ss;
FROM game)
WHERE lastTimePlayed IS NOT NULL;
`;
await queryRunner.query(updateLastTimePlayedValues);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const queryBuilder = queryRunner.manager.createQueryBuilder(Game, "game");
const result = await queryBuilder.getMany();
for (const game of result) {
if (!game.lastTimePlayed) continue;
await queryRunner.query(
`UPDATE game set lastTimePlayed = ? WHERE id = ?;`,
[game.lastTimePlayed.toUTCString(), game.id]
);
}
}
}

View File

@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Hydra2031724081695967 implements MigrationInterface {
name = 'Hydra2031724081695967'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "game" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "objectID" text NOT NULL, "remoteId" text, "title" text NOT NULL, "iconUrl" text, "folderName" text, "downloadPath" text, "executablePath" text, "playTimeInMilliseconds" integer NOT NULL DEFAULT (0), "shop" text NOT NULL, "status" text, "downloader" integer NOT NULL DEFAULT (1), "progress" float NOT NULL DEFAULT (0), "bytesDownloaded" integer NOT NULL DEFAULT (0), "lastTimePlayed" datetime, "fileSize" float NOT NULL DEFAULT (0), "uri" text, "isDeleted" boolean NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "repackId" integer, CONSTRAINT "UQ_04293f46e8db3deaec8dfb69264" UNIQUE ("objectID"), CONSTRAINT "UQ_6dac8c3148e141251a4864e94d4" UNIQUE ("remoteId"), CONSTRAINT "REL_0c1d6445ad047d9bbd256f961f" UNIQUE ("repackId"))`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "download_source" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "url" text, "name" text NOT NULL, "etag" text, "downloadCount" integer NOT NULL DEFAULT (0), "status" text NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_aec2879321a87e9bb2ed477981a" UNIQUE ("url"))`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "repack" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "magnet" text NOT NULL, "page" integer, "repacker" text NOT NULL, "fileSize" text NOT NULL, "uploadDate" datetime NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "downloadSourceId" integer, CONSTRAINT "UQ_a46a68496754a4d429ddf9d48ec" UNIQUE ("title"), CONSTRAINT "UQ_5e8d57798643e693bced32095d2" UNIQUE ("magnet"))`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "user_preferences" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "downloadsPath" text, "language" text NOT NULL DEFAULT ('en'), "realDebridApiToken" text, "downloadNotificationsEnabled" boolean NOT NULL DEFAULT (0), "repackUpdatesNotificationsEnabled" boolean NOT NULL DEFAULT (0), "preferQuitInsteadOfHiding" boolean NOT NULL DEFAULT (0), "runAtStartup" boolean NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "game_shop_cache" ("objectID" text PRIMARY KEY NOT NULL, "shop" text NOT NULL, "serializedData" text, "howLongToBeatSerializedData" text, "language" text, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "download_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "gameId" integer, CONSTRAINT "REL_aed852c94d9ded617a7a07f541" UNIQUE ("gameId"))`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "user_auth" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "userId" text NOT NULL DEFAULT (''), "displayName" text NOT NULL DEFAULT (''), "profileImageUrl" text, "accessToken" text NOT NULL DEFAULT (''), "refreshToken" text NOT NULL DEFAULT (''), "tokenExpirationTimestamp" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "temporary_game" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "objectID" text NOT NULL, "remoteId" text, "title" text NOT NULL, "iconUrl" text, "folderName" text, "downloadPath" text, "executablePath" text, "playTimeInMilliseconds" integer NOT NULL DEFAULT (0), "shop" text NOT NULL, "status" text, "downloader" integer NOT NULL DEFAULT (1), "progress" float NOT NULL DEFAULT (0), "bytesDownloaded" integer NOT NULL DEFAULT (0), "lastTimePlayed" datetime, "fileSize" float NOT NULL DEFAULT (0), "uri" text, "isDeleted" boolean NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "repackId" integer, CONSTRAINT "UQ_04293f46e8db3deaec8dfb69264" UNIQUE ("objectID"), CONSTRAINT "UQ_6dac8c3148e141251a4864e94d4" UNIQUE ("remoteId"), CONSTRAINT "REL_0c1d6445ad047d9bbd256f961f" UNIQUE ("repackId"), CONSTRAINT "FK_0c1d6445ad047d9bbd256f961f6" FOREIGN KEY ("repackId") REFERENCES "repack" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_game"("id", "objectID", "remoteId", "title", "iconUrl", "folderName", "downloadPath", "executablePath", "playTimeInMilliseconds", "shop", "status", "downloader", "progress", "bytesDownloaded", "lastTimePlayed", "fileSize", "uri", "isDeleted", "createdAt", "updatedAt", "repackId") SELECT "id", "objectID", "remoteId", "title", "iconUrl", "folderName", "downloadPath", "executablePath", "playTimeInMilliseconds", "shop", "status", "downloader", "progress", "bytesDownloaded", "lastTimePlayed", "fileSize", "uri", "isDeleted", "createdAt", "updatedAt", "repackId" FROM "game"`);
await queryRunner.query(`DROP TABLE "game"`);
await queryRunner.query(`ALTER TABLE "temporary_game" RENAME TO "game"`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "temporary_repack" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "magnet" text NOT NULL, "page" integer, "repacker" text NOT NULL, "fileSize" text NOT NULL, "uploadDate" datetime NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "downloadSourceId" integer, CONSTRAINT "UQ_a46a68496754a4d429ddf9d48ec" UNIQUE ("title"), CONSTRAINT "UQ_5e8d57798643e693bced32095d2" UNIQUE ("magnet"), CONSTRAINT "FK_13f131029be1dd361fd3cd9c2a6" FOREIGN KEY ("downloadSourceId") REFERENCES "download_source" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_repack"("id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId") SELECT "id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId" FROM "repack"`);
await queryRunner.query(`DROP TABLE "repack"`);
await queryRunner.query(`ALTER TABLE "temporary_repack" RENAME TO "repack"`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "temporary_download_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "gameId" integer, CONSTRAINT "REL_aed852c94d9ded617a7a07f541" UNIQUE ("gameId"), CONSTRAINT "FK_aed852c94d9ded617a7a07f5415" FOREIGN KEY ("gameId") REFERENCES "game" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_download_queue"("id", "createdAt", "updatedAt", "gameId") SELECT "id", "createdAt", "updatedAt", "gameId" FROM "download_queue"`);
await queryRunner.query(`DROP TABLE "download_queue"`);
await queryRunner.query(`ALTER TABLE "temporary_download_queue" RENAME TO "download_queue"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "download_queue" RENAME TO "temporary_download_queue"`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "download_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "gameId" integer, CONSTRAINT "REL_aed852c94d9ded617a7a07f541" UNIQUE ("gameId"))`);
await queryRunner.query(`INSERT INTO "download_queue"("id", "createdAt", "updatedAt", "gameId") SELECT "id", "createdAt", "updatedAt", "gameId" FROM "temporary_download_queue"`);
await queryRunner.query(`DROP TABLE "temporary_download_queue"`);
await queryRunner.query(`ALTER TABLE "repack" RENAME TO "temporary_repack"`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "repack" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "magnet" text NOT NULL, "page" integer, "repacker" text NOT NULL, "fileSize" text NOT NULL, "uploadDate" datetime NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "downloadSourceId" integer, CONSTRAINT "UQ_a46a68496754a4d429ddf9d48ec" UNIQUE ("title"), CONSTRAINT "UQ_5e8d57798643e693bced32095d2" UNIQUE ("magnet"))`);
await queryRunner.query(`INSERT INTO "repack"("id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId") SELECT "id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId" FROM "temporary_repack"`);
await queryRunner.query(`DROP TABLE "temporary_repack"`);
await queryRunner.query(`ALTER TABLE "game" RENAME TO "temporary_game"`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "game" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "objectID" text NOT NULL, "remoteId" text, "title" text NOT NULL, "iconUrl" text, "folderName" text, "downloadPath" text, "executablePath" text, "playTimeInMilliseconds" integer NOT NULL DEFAULT (0), "shop" text NOT NULL, "status" text, "downloader" integer NOT NULL DEFAULT (1), "progress" float NOT NULL DEFAULT (0), "bytesDownloaded" integer NOT NULL DEFAULT (0), "lastTimePlayed" datetime, "fileSize" float NOT NULL DEFAULT (0), "uri" text, "isDeleted" boolean NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "repackId" integer, CONSTRAINT "UQ_04293f46e8db3deaec8dfb69264" UNIQUE ("objectID"), CONSTRAINT "UQ_6dac8c3148e141251a4864e94d4" UNIQUE ("remoteId"), CONSTRAINT "REL_0c1d6445ad047d9bbd256f961f" UNIQUE ("repackId"))`);
await queryRunner.query(`INSERT INTO "game"("id", "objectID", "remoteId", "title", "iconUrl", "folderName", "downloadPath", "executablePath", "playTimeInMilliseconds", "shop", "status", "downloader", "progress", "bytesDownloaded", "lastTimePlayed", "fileSize", "uri", "isDeleted", "createdAt", "updatedAt", "repackId") SELECT "id", "objectID", "remoteId", "title", "iconUrl", "folderName", "downloadPath", "executablePath", "playTimeInMilliseconds", "shop", "status", "downloader", "progress", "bytesDownloaded", "lastTimePlayed", "fileSize", "uri", "isDeleted", "createdAt", "updatedAt", "repackId" FROM "temporary_game"`);
await queryRunner.query(`DROP TABLE "temporary_game"`);
await queryRunner.query(`DROP TABLE "user_auth"`);
await queryRunner.query(`DROP TABLE "download_queue"`);
await queryRunner.query(`DROP TABLE "game_shop_cache"`);
await queryRunner.query(`DROP TABLE "user_preferences"`);
await queryRunner.query(`DROP TABLE "repack"`);
await queryRunner.query(`DROP TABLE "download_source"`);
await queryRunner.query(`DROP TABLE "game"`);
}
}

View File

@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class DowloadsRefactor1724081984535 implements MigrationInterface {
name = 'DowloadsRefactor1724081984535'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_repack" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "magnet" text NOT NULL, "page" integer, "repacker" text NOT NULL, "fileSize" text NOT NULL, "uploadDate" datetime NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "downloadSourceId" integer, "uris" text NOT NULL DEFAULT ('[]'), CONSTRAINT "UQ_5e8d57798643e693bced32095d2" UNIQUE ("magnet"), CONSTRAINT "UQ_a46a68496754a4d429ddf9d48ec" UNIQUE ("title"), CONSTRAINT "FK_13f131029be1dd361fd3cd9c2a6" FOREIGN KEY ("downloadSourceId") REFERENCES "download_source" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_repack"("id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId") SELECT "id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId" FROM "repack"`);
await queryRunner.query(`DROP TABLE "repack"`);
await queryRunner.query(`ALTER TABLE "temporary_repack" RENAME TO "repack"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "repack" RENAME TO "temporary_repack"`);
await queryRunner.query(`CREATE TABLE "repack" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "magnet" text NOT NULL, "page" integer, "repacker" text NOT NULL, "fileSize" text NOT NULL, "uploadDate" datetime NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "downloadSourceId" integer, CONSTRAINT "UQ_5e8d57798643e693bced32095d2" UNIQUE ("magnet"), CONSTRAINT "UQ_a46a68496754a4d429ddf9d48ec" UNIQUE ("title"), CONSTRAINT "FK_13f131029be1dd361fd3cd9c2a6" FOREIGN KEY ("downloadSourceId") REFERENCES "download_source" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "repack"("id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId") SELECT "id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId" FROM "temporary_repack"`);
await queryRunner.query(`DROP TABLE "temporary_repack"`);
}
}

View File

@ -1,7 +1,2 @@
import { FixRepackUploadDate1715900413313 } from "./1715900413313-fix_repack_uploadDate"; export * from "./1724081695967-Hydra_2_0_3";
import { AlterLastTimePlayedToDatime1716776027208 } from "./1716776027208-alter_lastTimePlayed_to_datime"; export * from "./1724081984535-DowloadsRefactor";
export default [
FixRepackUploadDate1715900413313,
AlterLastTimePlayedToDatime1716776027208,
];

View File

@ -6,8 +6,8 @@ import { downloadQueueRepository, gameRepository } 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";
import { GofileApi } from "../hosters"; import { GofileApi, QiwiApi } from "../hosters";
import { GenericHTTPDownloader } from "./generic-http-downloader"; import { GenericHttpDownloader } from "./generic-http-downloader";
export class DownloadManager { export class DownloadManager {
private static currentDownloader: Downloader | null = null; private static currentDownloader: Downloader | null = null;
@ -20,7 +20,7 @@ export class DownloadManager {
} else if (this.currentDownloader === Downloader.RealDebrid) { } else if (this.currentDownloader === Downloader.RealDebrid) {
status = await RealDebridDownloader.getStatus(); status = await RealDebridDownloader.getStatus();
} else { } else {
status = await GenericHTTPDownloader.getStatus(); status = await GenericHttpDownloader.getStatus();
} }
if (status) { if (status) {
@ -71,7 +71,7 @@ export class DownloadManager {
} else if (this.currentDownloader === Downloader.RealDebrid) { } else if (this.currentDownloader === Downloader.RealDebrid) {
await RealDebridDownloader.pauseDownload(); await RealDebridDownloader.pauseDownload();
} else { } else {
await GenericHTTPDownloader.pauseDownload(); await GenericHttpDownloader.pauseDownload();
} }
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@ -88,7 +88,7 @@ export class DownloadManager {
} else if (this.currentDownloader === Downloader.RealDebrid) { } else if (this.currentDownloader === Downloader.RealDebrid) {
RealDebridDownloader.cancelDownload(gameId); RealDebridDownloader.cancelDownload(gameId);
} else { } else {
GenericHTTPDownloader.cancelDownload(gameId); GenericHttpDownloader.cancelDownload(gameId);
} }
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@ -96,26 +96,38 @@ export class DownloadManager {
} }
static async startDownload(game: Game) { static async startDownload(game: Game) {
if (game.downloader === Downloader.Gofile) { switch (game.downloader) {
const id = game!.uri!.split("/").pop(); case Downloader.Gofile: {
const id = game!.uri!.split("/").pop();
const token = await GofileApi.authorize(); const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!); const downloadLink = await GofileApi.getDownloadLink(id!);
GenericHTTPDownloader.startDownload(game, downloadLink, { GenericHttpDownloader.startDownload(game, downloadLink, {
Cookie: `accountToken=${token}`, Cookie: `accountToken=${token}`,
}); });
} else if (game.downloader === Downloader.PixelDrain) { break;
const id = game!.uri!.split("/").pop(); }
case Downloader.PixelDrain: {
const id = game!.uri!.split("/").pop();
await GenericHTTPDownloader.startDownload( await GenericHttpDownloader.startDownload(
game, game,
`https://pixeldrain.com/api/file/${id}?download` `https://pixeldrain.com/api/file/${id}?download`
); );
} else if (game.downloader === Downloader.Torrent) { break;
PythonInstance.startDownload(game); }
} else if (game.downloader === Downloader.RealDebrid) { case Downloader.Qiwi: {
RealDebridDownloader.startDownload(game); const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!);
await GenericHttpDownloader.startDownload(game, downloadUrl);
break;
}
case Downloader.Torrent:
PythonInstance.startDownload(game);
break;
case Downloader.RealDebrid:
RealDebridDownloader.startDownload(game);
} }
this.currentDownloader = game.downloader; this.currentDownloader = game.downloader;

View File

@ -4,14 +4,14 @@ import { calculateETA } from "./helpers";
import { DownloadProgress } from "@types"; import { DownloadProgress } from "@types";
import { HttpDownload } from "./http-download"; import { HttpDownload } from "./http-download";
export class GenericHTTPDownloader { export class GenericHttpDownloader {
private static downloads = new Map<number, string>(); public static downloads = new Map<number, HttpDownload>();
private static downloadingGame: Game | null = null; public static downloadingGame: Game | null = null;
public static async getStatus() { public static async getStatus() {
if (this.downloadingGame) { if (this.downloadingGame) {
const gid = this.downloads.get(this.downloadingGame.id)!; const download = this.downloads.get(this.downloadingGame.id)!;
const status = HttpDownload.getStatus(gid); const status = download.getStatus();
if (status) { if (status) {
const progress = const progress =
@ -57,10 +57,10 @@ export class GenericHTTPDownloader {
static async pauseDownload() { static async pauseDownload() {
if (this.downloadingGame) { if (this.downloadingGame) {
const gid = this.downloads.get(this.downloadingGame!.id!); const httpDownload = this.downloads.get(this.downloadingGame!.id!);
if (gid) { if (httpDownload) {
await HttpDownload.pauseDownload(gid); await httpDownload.pauseDownload();
} }
this.downloadingGame = null; this.downloadingGame = null;
@ -79,29 +79,31 @@ export class GenericHTTPDownloader {
return; return;
} }
const gid = await HttpDownload.startDownload( const httpDownload = new HttpDownload(
game.downloadPath!, game.downloadPath!,
downloadUrl, downloadUrl,
headers headers
); );
this.downloads.set(game.id!, gid); httpDownload.startDownload();
this.downloads.set(game.id!, httpDownload);
} }
static async cancelDownload(gameId: number) { static async cancelDownload(gameId: number) {
const gid = this.downloads.get(gameId); const httpDownload = this.downloads.get(gameId);
if (gid) { if (httpDownload) {
await HttpDownload.cancelDownload(gid); await httpDownload.cancelDownload();
this.downloads.delete(gameId); this.downloads.delete(gameId);
} }
} }
static async resumeDownload(gameId: number) { static async resumeDownload(gameId: number) {
const gid = this.downloads.get(gameId); const httpDownload = this.downloads.get(gameId);
if (gid) { if (httpDownload) {
await HttpDownload.resumeDownload(gid); await httpDownload.resumeDownload();
} }
} }
} }

View File

@ -1,67 +1,52 @@
import { DownloadItem } from "electron";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import path from "node:path"; import path from "node:path";
export class HttpDownload { export class HttpDownload {
private static id = 0; private downloadItem: Electron.DownloadItem;
private static downloads: Record<string, DownloadItem> = {}; constructor(
private downloadPath: string,
private downloadUrl: string,
private headers?: Record<string, string>
) {}
public static getStatus(gid: string): { public getStatus() {
completedLength: number; return {
totalLength: number; completedLength: this.downloadItem.getReceivedBytes(),
downloadSpeed: number; totalLength: this.downloadItem.getTotalBytes(),
folderName: string; downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(),
} | null { folderName: this.downloadItem.getFilename(),
const downloadItem = this.downloads[gid]; };
if (downloadItem) {
return {
completedLength: downloadItem.getReceivedBytes(),
totalLength: downloadItem.getTotalBytes(),
downloadSpeed: downloadItem.getCurrentBytesPerSecond(),
folderName: downloadItem.getFilename(),
};
}
return null;
} }
static async cancelDownload(gid: string) { async cancelDownload() {
const downloadItem = this.downloads[gid]; this.downloadItem.cancel();
downloadItem?.cancel();
delete this.downloads[gid];
} }
static async pauseDownload(gid: string) { async pauseDownload() {
const downloadItem = this.downloads[gid]; this.downloadItem.pause();
downloadItem?.pause();
} }
static async resumeDownload(gid: string) { async resumeDownload() {
const downloadItem = this.downloads[gid]; this.downloadItem.resume();
downloadItem?.resume();
} }
static async startDownload( async startDownload() {
downloadPath: string, return new Promise((resolve) => {
downloadUrl: string, const options = this.headers ? { headers: this.headers } : {};
headers?: Record<string, string> WindowManager.mainWindow?.webContents.downloadURL(
) { this.downloadUrl,
return new Promise<string>((resolve) => { options
const options = headers ? { headers } : {}; );
WindowManager.mainWindow?.webContents.downloadURL(downloadUrl, options);
const gid = ++this.id; WindowManager.mainWindow?.webContents.session.once(
WindowManager.mainWindow?.webContents.session.on(
"will-download", "will-download",
(_event, item, _webContents) => { (_event, item, _webContents) => {
this.downloads[gid.toString()] = item; this.downloadItem = item;
// Set the save path, making Electron not to prompt a save dialog. item.setSavePath(path.join(this.downloadPath, item.getFilename()));
item.setSavePath(path.join(downloadPath, item.getFilename()));
resolve(gid.toString()); resolve(null);
} }
); );
}); });

View File

@ -19,6 +19,7 @@ import {
LibtorrentPayload, LibtorrentPayload,
ProcessPayload, ProcessPayload,
} from "./types"; } from "./types";
import { pythonInstanceLogger as logger } from "../logger";
export class PythonInstance { export class PythonInstance {
private static pythonProcess: cp.ChildProcess | null = null; private static pythonProcess: cp.ChildProcess | null = null;
@ -32,11 +33,13 @@ export class PythonInstance {
}); });
public static spawn(args?: StartDownloadPayload) { public static spawn(args?: StartDownloadPayload) {
logger.log("spawning python process with args:", args);
this.pythonProcess = startRPCClient(args); this.pythonProcess = startRPCClient(args);
} }
public static kill() { public static kill() {
if (this.pythonProcess) { if (this.pythonProcess) {
logger.log("killing python process");
this.pythonProcess.kill(); this.pythonProcess.kill();
this.pythonProcess = null; this.pythonProcess = null;
this.downloadingGameId = -1; this.downloadingGameId = -1;
@ -45,6 +48,7 @@ export class PythonInstance {
public static killTorrent() { public static killTorrent() {
if (this.pythonProcess) { if (this.pythonProcess) {
logger.log("killing torrent in python process");
this.rpc.post("/action", { action: "kill-torrent" }); this.rpc.post("/action", { action: "kill-torrent" });
this.downloadingGameId = -1; this.downloadingGameId = -1;
} }
@ -138,12 +142,14 @@ export class PythonInstance {
save_path: game.downloadPath!, save_path: game.downloadPath!,
}); });
} else { } else {
await this.rpc.post("/action", { await this.rpc
action: "start", .post("/action", {
game_id: game.id, action: "start",
magnet: game.uri, game_id: game.id,
save_path: game.downloadPath, magnet: game.uri,
} as StartDownloadPayload); save_path: game.downloadPath,
} as StartDownloadPayload)
.catch(this.handleRpcError);
} }
this.downloadingGameId = game.id; this.downloadingGameId = game.id;
@ -159,4 +165,14 @@ export class PythonInstance {
this.downloadingGameId = -1; this.downloadingGameId = -1;
} }
private static async handleRpcError(_error: unknown) {
await this.rpc.get("/healthcheck").catch(() => {
logger.error(
"RPC healthcheck failed. Killing process and starting again"
);
this.kill();
this.spawn();
});
}
} }

View File

@ -1,14 +1,9 @@
import { Game } from "@main/entity"; import { Game } from "@main/entity";
import { RealDebridClient } from "../real-debrid"; import { RealDebridClient } from "../real-debrid";
import { gameRepository } from "@main/repository";
import { calculateETA } from "./helpers";
import { DownloadProgress } from "@types";
import { HttpDownload } from "./http-download"; import { HttpDownload } from "./http-download";
import { GenericHttpDownloader } from "./generic-http-downloader";
export class RealDebridDownloader { export class RealDebridDownloader extends GenericHttpDownloader {
private static downloads = new Map<number, string>();
private static downloadingGame: Game | null = null;
private static realDebridTorrentId: string | null = null; private static realDebridTorrentId: string | null = null;
private static async getRealDebridDownloadUrl() { private static async getRealDebridDownloadUrl() {
@ -48,66 +43,6 @@ export class RealDebridDownloader {
return null; return null;
} }
public static async getStatus() {
if (this.downloadingGame) {
const gid = this.downloads.get(this.downloadingGame.id)!;
const status = HttpDownload.getStatus(gid);
if (status) {
const progress =
Number(status.completedLength) / Number(status.totalLength);
await gameRepository.update(
{ id: this.downloadingGame!.id },
{
bytesDownloaded: Number(status.completedLength),
fileSize: Number(status.totalLength),
progress,
status: "active",
folderName: status.folderName,
}
);
const result = {
numPeers: 0,
numSeeds: 0,
downloadSpeed: Number(status.downloadSpeed),
timeRemaining: calculateETA(
Number(status.totalLength),
Number(status.completedLength),
Number(status.downloadSpeed)
),
isDownloadingMetadata: false,
isCheckingFiles: false,
progress,
gameId: this.downloadingGame!.id,
} as DownloadProgress;
if (progress === 1) {
this.downloads.delete(this.downloadingGame.id);
this.realDebridTorrentId = null;
this.downloadingGame = null;
}
return result;
}
}
return null;
}
static async pauseDownload() {
if (this.downloadingGame) {
const gid = this.downloads.get(this.downloadingGame.id);
if (gid) {
await HttpDownload.pauseDownload(gid);
}
}
this.realDebridTorrentId = null;
this.downloadingGame = null;
}
static async startDownload(game: Game) { static async startDownload(game: Game) {
if (this.downloads.has(game.id)) { if (this.downloads.has(game.id)) {
await this.resumeDownload(game.id!); await this.resumeDownload(game.id!);
@ -128,32 +63,10 @@ export class RealDebridDownloader {
if (downloadUrl) { if (downloadUrl) {
this.realDebridTorrentId = null; this.realDebridTorrentId = null;
const gid = await HttpDownload.startDownload( const httpDownload = new HttpDownload(game.downloadPath!, downloadUrl);
game.downloadPath!, httpDownload.startDownload();
downloadUrl
);
this.downloads.set(game.id!, gid); this.downloads.set(game.id!, httpDownload);
}
}
static async cancelDownload(gameId: number) {
const gid = this.downloads.get(gameId);
if (gid) {
await HttpDownload.cancelDownload(gid);
this.downloads.delete(gameId);
}
this.realDebridTorrentId = null;
this.downloadingGame = null;
}
static async resumeDownload(gameId: number) {
const gid = this.downloads.get(gameId);
if (gid) {
await HttpDownload.resumeDownload(gid);
} }
} }
} }

View File

@ -4,6 +4,8 @@ import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { app, dialog } from "electron"; import { app, dialog } from "electron";
import type { StartDownloadPayload } from "./types"; import type { StartDownloadPayload } from "./types";
import { Readable } from "node:stream";
import { pythonInstanceLogger as logger } from "../logger";
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = { const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: "hydra-download-manager", darwin: "hydra-download-manager",
@ -15,6 +17,13 @@ export const BITTORRENT_PORT = "5881";
export const RPC_PORT = "8084"; export const RPC_PORT = "8084";
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex"); export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
const logStderr = (readable: Readable | null) => {
if (!readable) return;
readable.setEncoding("utf-8");
readable.on("data", logger.log);
};
export const startTorrentClient = (args?: StartDownloadPayload) => { export const startTorrentClient = (args?: StartDownloadPayload) => {
const commonArgs = [ const commonArgs = [
BITTORRENT_PORT, BITTORRENT_PORT,
@ -40,10 +49,14 @@ export const startTorrentClient = (args?: StartDownloadPayload) => {
app.quit(); app.quit();
} }
return cp.spawn(binaryPath, commonArgs, { const childProcess = cp.spawn(binaryPath, commonArgs, {
stdio: "inherit",
windowsHide: true, windowsHide: true,
stdio: ["inherit", "inherit"],
}); });
logStderr(childProcess.stderr);
return childProcess;
} else { } else {
const scriptPath = path.join( const scriptPath = path.join(
__dirname, __dirname,
@ -53,8 +66,12 @@ export const startTorrentClient = (args?: StartDownloadPayload) => {
"main.py" "main.py"
); );
return cp.spawn("python3", [scriptPath, ...commonArgs], { const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], {
stdio: "inherit", stdio: ["inherit", "inherit"],
}); });
logStderr(childProcess.stderr);
return childProcess;
} }
}; };

View File

@ -16,6 +16,8 @@ export interface GofileContentsResponse {
children: Record<string, GofileContentChild>; children: Record<string, GofileContentChild>;
} }
export const WT = "4fd6sg89d7s6";
export class GofileApi { export class GofileApi {
private static token: string; private static token: string;
@ -35,7 +37,7 @@ export class GofileApi {
public static async getDownloadLink(id: string) { public static async getDownloadLink(id: string) {
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
wt: "4fd6sg89d7s6", wt: WT,
}); });
const response = await axios.get<{ const response = await axios.get<{

View File

@ -1 +1,2 @@
export * from "./gofile"; export * from "./gofile";
export * from "./qiwi";

View File

@ -0,0 +1,15 @@
import { requestWebPage } from "@main/helpers";
export class QiwiApi {
public static async getDownloadUrl(url: string) {
const document = await requestWebPage(url);
const fileName = document.querySelector("h1")?.textContent;
const slug = url.split("/").pop();
const extension = fileName?.split(".").pop();
const downloadUrl = `https://spyderrock.com/${slug}.${extension}`;
return downloadUrl;
}
}

View File

@ -1,5 +1,4 @@
import axios from "axios"; import axios from "axios";
import { JSDOM } from "jsdom";
import { requestWebPage } from "@main/helpers"; import { requestWebPage } from "@main/helpers";
import { HowLongToBeatCategory } from "@types"; import { HowLongToBeatCategory } from "@types";
import { formatName } from "@shared"; import { formatName } from "@shared";
@ -52,10 +51,7 @@ const parseListItems = ($lis: Element[]) => {
export const getHowLongToBeatGame = async ( export const getHowLongToBeatGame = async (
id: string id: string
): Promise<HowLongToBeatCategory[]> => { ): Promise<HowLongToBeatCategory[]> => {
const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`); const document = await requestWebPage(`https://howlongtobeat.com/game/${id}`);
const { window } = new JSDOM(response);
const { document } = window;
const $ul = document.querySelector(".shadow_shadow ul"); const $ul = document.querySelector(".shadow_shadow ul");
if (!$ul) return []; if (!$ul) return [];

View File

@ -3,7 +3,7 @@ import { HydraApi } from "../hydra-api";
import { gameRepository } from "@main/repository"; import { gameRepository } from "@main/repository";
export const createGame = async (game: Game) => { export const createGame = async (game: Game) => {
HydraApi.post(`/games`, { HydraApi.post(`/profile/games`, {
objectId: game.objectID, objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop, shop: game.shop,

View File

@ -4,7 +4,7 @@ import { steamGamesWorker } from "@main/workers";
import { getSteamAppAsset } from "@main/helpers"; import { getSteamAppAsset } from "@main/helpers";
export const mergeWithRemoteGames = async () => { export const mergeWithRemoteGames = async () => {
return HydraApi.get("/games") return HydraApi.get("/profile/games")
.then(async (response) => { .then(async (response) => {
for (const game of response) { for (const game of response) {
const localGame = await gameRepository.findOne({ const localGame = await gameRepository.findOne({

View File

@ -6,7 +6,7 @@ export const updateGamePlaytime = async (
deltaInMillis: number, deltaInMillis: number,
lastTimePlayed: Date lastTimePlayed: Date
) => { ) => {
HydraApi.put(`/games/${game.remoteId}`, { HydraApi.put(`/profile/games/${game.remoteId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000), playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed, lastTimePlayed,
}).catch(() => {}); }).catch(() => {});

View File

@ -14,7 +14,7 @@ export const uploadGamesBatch = async () => {
for (const chunk of gamesChunks) { for (const chunk of gamesChunks) {
await HydraApi.post( await HydraApi.post(
"/games/batch", "/profile/games/batch",
chunk.map((game) => { chunk.map((game) => {
return { return {
objectId: game.objectID, objectId: game.objectID,

View File

@ -6,6 +6,10 @@ log.transports.file.resolvePathFn = (
_: log.PathVariables, _: log.PathVariables,
message?: log.LogMessage | undefined message?: log.LogMessage | undefined
) => { ) => {
if (message?.scope === "python-instance") {
return path.join(logsPath, "pythoninstance.txt");
}
if (message?.level === "error") { if (message?.level === "error") {
return path.join(logsPath, "error.txt"); return path.join(logsPath, "error.txt");
} }
@ -23,4 +27,5 @@ log.errorHandler.startCatching({
log.initialize(); log.initialize();
export const pythonInstanceLogger = log.scope("python-instance");
export const logger = log.scope("main"); export const logger = log.scope("main");

View File

@ -10,6 +10,6 @@ export const startMainLoop = async () => {
DownloadManager.watchDownloads(), DownloadManager.watchDownloads(),
]); ]);
await sleep(500); await sleep(1000);
} }
}; };

View File

@ -4,12 +4,16 @@ import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync"; import { createGame, updateGamePlaytime } from "./library-sync";
import { GameRunning } from "@types"; import { GameRunning } from "@types";
import { PythonInstance } from "./download"; import { PythonInstance } from "./download";
import { Game } from "@main/entity";
export const gamesPlaytime = new Map< export const gamesPlaytime = new Map<
number, number,
{ lastTick: number; firstTick: number } { lastTick: number; firstTick: number; lastSyncTick: number }
>(); >();
const TICKS_TO_UPDATE_API = 120;
let currentTick = 1;
export const watchProcesses = async () => { export const watchProcesses = async () => {
const games = await gameRepository.find({ const games = await gameRepository.find({
where: { where: {
@ -30,48 +34,17 @@ export const watchProcesses = async () => {
if (gameProcess) { if (gameProcess) {
if (gamesPlaytime.has(game.id)) { if (gamesPlaytime.has(game.id)) {
const gamePlaytime = gamesPlaytime.get(game.id)!; onTickGame(game);
const zero = gamePlaytime.lastTick;
const delta = performance.now() - zero;
await gameRepository.update(game.id, {
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
lastTimePlayed: new Date(),
});
gamesPlaytime.set(game.id, {
...gamePlaytime,
lastTick: performance.now(),
});
} else { } else {
if (game.remoteId) { onOpenGame(game);
updateGamePlaytime(game, 0, new Date());
} else {
createGame({ ...game, lastTimePlayed: new Date() });
}
gamesPlaytime.set(game.id, {
lastTick: performance.now(),
firstTick: performance.now(),
});
} }
} else if (gamesPlaytime.has(game.id)) { } else if (gamesPlaytime.has(game.id)) {
const gamePlaytime = gamesPlaytime.get(game.id)!; onCloseGame(game);
gamesPlaytime.delete(game.id);
if (game.remoteId) {
updateGamePlaytime(
game,
performance.now() - gamePlaytime.firstTick,
game.lastTimePlayed!
);
} else {
createGame(game);
}
} }
} }
currentTick++;
if (WindowManager.mainWindow) { if (WindowManager.mainWindow) {
const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => { const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => {
return { return {
@ -86,3 +59,68 @@ export const watchProcesses = async () => {
); );
} }
}; };
function onOpenGame(game: Game) {
const now = performance.now();
gamesPlaytime.set(game.id, {
lastTick: now,
firstTick: now,
lastSyncTick: now,
});
if (game.remoteId) {
updateGamePlaytime(game, 0, new Date());
} else {
createGame({ ...game, lastTimePlayed: new Date() });
}
}
function onTickGame(game: Game) {
const now = performance.now();
const gamePlaytime = gamesPlaytime.get(game.id)!;
const delta = now - gamePlaytime.lastTick;
gameRepository.update(game.id, {
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
lastTimePlayed: new Date(),
});
gamesPlaytime.set(game.id, {
...gamePlaytime,
lastTick: now,
});
if (currentTick % TICKS_TO_UPDATE_API === 0) {
if (game.remoteId) {
updateGamePlaytime(
game,
now - gamePlaytime.lastSyncTick,
game.lastTimePlayed!
);
} else {
createGame(game);
}
gamesPlaytime.set(game.id, {
...gamePlaytime,
lastSyncTick: now,
});
}
}
const onCloseGame = (game: Game) => {
const gamePlaytime = gamesPlaytime.get(game.id)!;
gamesPlaytime.delete(game.id);
if (game.remoteId) {
updateGamePlaytime(
game,
performance.now() - gamePlaytime.firstTick,
game.lastTimePlayed!
);
} else {
createGame(game);
}
};

View File

@ -8,11 +8,25 @@ export class RepacksManager {
private static repacksIndex = new flexSearch.Index(); private static repacksIndex = new flexSearch.Index();
public static async updateRepacks() { public static async updateRepacks() {
this.repacks = await repackRepository.find({ this.repacks = await repackRepository
order: { .find({
createdAt: "DESC", order: {
}, createdAt: "DESC",
}); },
})
.then((repacks) =>
repacks.map((repack) => {
const uris: string[] = [];
const magnet = repack?.magnet;
if (magnet) uris.push(magnet);
return {
...repack,
uris: [...uris, ...JSON.parse(repack.uris)],
};
})
);
for (let i = 0; i < this.repacks.length; i++) { for (let i = 0; i < this.repacks.length; i++) {
this.repacksIndex.remove(i); this.repacksIndex.remove(i);

View File

@ -158,7 +158,7 @@ export class WindowManager {
const recentlyPlayedGames: Array<MenuItemConstructorOptions | MenuItem> = const recentlyPlayedGames: Array<MenuItemConstructorOptions | MenuItem> =
games.map(({ title, executablePath }) => ({ games.map(({ title, executablePath }) => ({
label: title, label: title.length > 15 ? `${title.slice(0, 15)}` : title,
type: "normal", type: "normal",
click: async () => { click: async () => {
if (!executablePath) return; if (!executablePath) return;

View File

@ -10,6 +10,7 @@ import type {
StartGameDownloadPayload, StartGameDownloadPayload,
GameRunning, GameRunning,
FriendRequestAction, FriendRequestAction,
UpdateProfileProps,
} from "@types"; } from "@types";
contextBridge.exposeInMainWorld("electron", { contextBridge.exposeInMainWorld("electron", {
@ -137,8 +138,8 @@ contextBridge.exposeInMainWorld("electron", {
getMe: () => ipcRenderer.invoke("getMe"), getMe: () => ipcRenderer.invoke("getMe"),
undoFriendship: (userId: string) => undoFriendship: (userId: string) =>
ipcRenderer.invoke("undoFriendship", userId), ipcRenderer.invoke("undoFriendship", userId),
updateProfile: (displayName: string, newProfileImagePath: string | null) => updateProfile: (updateProfile: UpdateProfileProps) =>
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), ipcRenderer.invoke("updateProfile", updateProfile),
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
updateFriendRequest: (userId: string, action: FriendRequestAction) => updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action), ipcRenderer.invoke("updateFriendRequest", userId, action),
@ -151,6 +152,8 @@ contextBridge.exposeInMainWorld("electron", {
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
getUserFriends: (userId: string, take: number, skip: number) => getUserFriends: (userId: string, take: number, skip: number) =>
ipcRenderer.invoke("getUserFriends", userId, take, skip), ipcRenderer.invoke("getUserFriends", userId, take, skip),
getUserBlocks: (take: number, skip: number) =>
ipcRenderer.invoke("getUserBlocks", take, skip),
/* Auth */ /* Auth */
signOut: () => ipcRenderer.invoke("signOut"), signOut: () => ipcRenderer.invoke("signOut"),

View File

@ -108,7 +108,7 @@ export function App() {
fetchFriendRequests(); fetchFriendRequests();
} }
}); });
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, fetchFriendRequests, updateUserDetails, dispatch]);
const onSignIn = useCallback(() => { const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
@ -118,7 +118,13 @@ export function App() {
showSuccessToast(t("successfully_signed_in")); showSuccessToast(t("successfully_signed_in"));
} }
}); });
}, [fetchUserDetails, t, showSuccessToast, updateUserDetails]); }, [
fetchUserDetails,
fetchFriendRequests,
t,
showSuccessToast,
updateUserDetails,
]);
useEffect(() => { useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesRunning) => { const unsubscribe = window.electron.onGamesRunning((gamesRunning) => {

View File

@ -78,7 +78,7 @@ export function SidebarProfile() {
)} )}
</div> </div>
{userDetails && gameRunning && ( {userDetails && gameRunning?.iconUrl && (
<img <img
alt={gameRunning.title} alt={gameRunning.title}
width={24} width={24}

View File

@ -7,4 +7,5 @@ export const DOWNLOADER_NAME = {
[Downloader.Torrent]: "Torrent", [Downloader.Torrent]: "Torrent",
[Downloader.Gofile]: "Gofile", [Downloader.Gofile]: "Gofile",
[Downloader.PixelDrain]: "PixelDrain", [Downloader.PixelDrain]: "PixelDrain",
[Downloader.Qiwi]: "Qiwi",
}; };

View File

@ -17,6 +17,7 @@ import type {
FriendRequest, FriendRequest,
FriendRequestAction, FriendRequestAction,
UserFriends, UserFriends,
UserBlocks,
} from "@types"; } from "@types";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
@ -135,14 +136,12 @@ declare global {
take: number, take: number,
skip: number skip: number
) => Promise<UserFriends>; ) => Promise<UserFriends>;
getUserBlocks: (take: number, skip: number) => Promise<UserBlocks>;
/* Profile */ /* Profile */
getMe: () => Promise<UserProfile | null>; getMe: () => Promise<UserProfile | null>;
undoFriendship: (userId: string) => Promise<void>; undoFriendship: (userId: string) => Promise<void>;
updateProfile: ( updateProfile: (updateProfile: UpdateProfileProps) => Promise<UserProfile>;
displayName: string,
newProfileImagePath: string | null
) => Promise<UserProfile>;
getFriendRequests: () => Promise<FriendRequest[]>; getFriendRequests: () => Promise<FriendRequest[]>;
updateFriendRequest: ( updateFriendRequest: (
userId: string, userId: string,

View File

@ -22,9 +22,10 @@ export function useDownload() {
); );
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const startDownload = (payload: StartGameDownloadPayload) => { const startDownload = async (payload: StartGameDownloadPayload) => {
dispatch(clearDownload()); dispatch(clearDownload());
window.electron.startGameDownload(payload).then((game) => {
return window.electron.startGameDownload(payload).then((game) => {
updateLibrary(); updateLibrary();
return game; return game;

View File

@ -8,8 +8,9 @@ import {
setFriendsModalHidden, setFriendsModalHidden,
} from "@renderer/features"; } from "@renderer/features";
import { profileBackgroundFromProfileImage } from "@renderer/helpers"; import { profileBackgroundFromProfileImage } from "@renderer/helpers";
import { FriendRequestAction, UserDetails } from "@types"; import { FriendRequestAction, UpdateProfileProps, UserDetails } from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { logger } from "@renderer/logger";
export function useUserDetails() { export function useUserDetails() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -43,7 +44,10 @@ export function useUserDetails() {
if (userDetails.profileImageUrl) { if (userDetails.profileImageUrl) {
const profileBackground = await profileBackgroundFromProfileImage( const profileBackground = await profileBackgroundFromProfileImage(
userDetails.profileImageUrl userDetails.profileImageUrl
); ).catch((err) => {
logger.error("profileBackgroundFromProfileImage", err);
return `#151515B3`;
});
dispatch(setProfileBackground(profileBackground)); dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem( window.localStorage.setItem(
@ -74,12 +78,8 @@ export function useUserDetails() {
}, [clearUserDetails]); }, [clearUserDetails]);
const patchUser = useCallback( const patchUser = useCallback(
async (displayName: string, imageProfileUrl: string | null) => { async (props: UpdateProfileProps) => {
const response = await window.electron.updateProfile( const response = await window.electron.updateProfile(props);
displayName,
imageProfileUrl
);
return updateUserDetails(response); return updateUserDetails(response);
}, },
[updateUserDetails] [updateUserDetails]
@ -99,7 +99,7 @@ export function useUserDetails() {
dispatch(setFriendsModalVisible({ initialTab, userId })); dispatch(setFriendsModalVisible({ initialTab, userId }));
fetchFriendRequests(); fetchFriendRequests();
}, },
[dispatch] [dispatch, fetchFriendRequests]
); );
const hideFriendsModal = useCallback(() => { const hideFriendsModal = useCallback(() => {

View File

@ -23,7 +23,7 @@ import {
} from "@renderer/context"; } from "@renderer/context";
import { useDownload } from "@renderer/hooks"; import { useDownload } from "@renderer/hooks";
import { GameOptionsModal, RepacksModal } from "./modals"; import { GameOptionsModal, RepacksModal } from "./modals";
import { Downloader } from "@shared"; import { Downloader, getDownloadersForUri } from "@shared";
export function GameDetails() { export function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null); const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
@ -70,6 +70,9 @@ export function GameDetails() {
} }
}; };
const selectRepackUri = (repack: GameRepack, downloader: Downloader) =>
repack.uris.find((uri) => getDownloadersForUri(uri).includes(downloader))!;
return ( return (
<GameDetailsContextProvider> <GameDetailsContextProvider>
<GameDetailsContextConsumer> <GameDetailsContextConsumer>
@ -96,6 +99,7 @@ export function GameDetails() {
downloader, downloader,
shop: shop as GameShop, shop: shop as GameShop,
downloadPath, downloadPath,
uri: selectRepackUri(repack, downloader),
}); });
await updateGame(); await updateGame();

View File

@ -20,13 +20,16 @@ export const hintText = style({
}); });
export const downloaders = style({ export const downloaders = style({
display: "flex", display: "grid",
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
gridTemplateColumns: "repeat(2, 1fr)",
}); });
export const downloaderOption = style({ export const downloaderOption = style({
flex: "1",
position: "relative", position: "relative",
":only-child": {
gridColumn: "1 / -1",
},
}); });
export const downloaderIcon = style({ export const downloaderIcon = style({

View File

@ -5,7 +5,7 @@ import { DiskSpace } from "check-disk-space";
import * as styles from "./download-settings-modal.css"; import * as styles from "./download-settings-modal.css";
import { Button, Link, Modal, TextField } from "@renderer/components"; import { Button, Link, Modal, TextField } from "@renderer/components";
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUri } from "@shared"; import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import type { GameRepack } from "@types"; import type { GameRepack } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
@ -48,8 +48,8 @@ export function DownloadSettingsModal({
}, [visible, selectedPath]); }, [visible, selectedPath]);
const downloaders = useMemo(() => { const downloaders = useMemo(() => {
return getDownloadersForUri(repack?.magnet ?? ""); return getDownloadersForUris(repack?.uris ?? []);
}, [repack?.magnet]); }, [repack?.uris]);
useEffect(() => { useEffect(() => {
if (userPreferences?.downloadsPath) { if (userPreferences?.downloadsPath) {

View File

@ -76,6 +76,13 @@ export function RepacksModal({
); );
}; };
const checkIfLastDownloadedOption = (repack: GameRepack) => {
if (infoHash) return repack.uris.some((uri) => uri.includes(infoHash));
if (!game?.uri) return false;
return repack.uris.some((uri) => uri.includes(game?.uri ?? ""));
};
return ( return (
<> <>
<DownloadSettingsModal <DownloadSettingsModal
@ -97,9 +104,7 @@ export function RepacksModal({
<div className={styles.repacks}> <div className={styles.repacks}>
{filteredRepacks.map((repack) => { {filteredRepacks.map((repack) => {
const isLastDownloadedOption = const isLastDownloadedOption = checkIfLastDownloadedOption(repack);
infoHash !== null &&
repack.magnet.toLowerCase().includes(infoHash);
return ( return (
<Button <Button

View File

@ -42,10 +42,3 @@ export const downloadSourcesHeader = style({
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
}); });
export const separator = style({
height: "100%",
width: "1px",
backgroundColor: vars.color.border,
margin: `${SPACING_UNIT}px 0`,
});

View File

@ -134,15 +134,6 @@ export function SettingsDownloadSources() {
downloadSource.downloadCount.toLocaleString(), downloadSource.downloadCount.toLocaleString(),
})} })}
</small> </small>
<div className={styles.separator} />
<small>
{t("download_options", {
count: downloadSource.repackCount,
countFormatted: downloadSource.repackCount.toLocaleString(),
})}
</small>
</div> </div>
</div> </div>

View File

@ -4,7 +4,6 @@ import {
XCircleIcon, XCircleIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css"; import * as styles from "./user-friend-modal.css";
import cn from "classnames";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -12,21 +11,26 @@ export type UserFriendItemProps = {
userId: string; userId: string;
profileImageUrl: string | null; profileImageUrl: string | null;
displayName: string; displayName: string;
onClickItem: (userId: string) => void;
} & ( } & (
| { type: "ACCEPTED"; onClickUndoFriendship: (userId: string) => void } | {
type: "ACCEPTED";
onClickUndoFriendship: (userId: string) => void;
onClickItem: (userId: string) => void;
}
| { type: "BLOCKED"; onClickUnblock: (userId: string) => void }
| { | {
type: "SENT" | "RECEIVED"; type: "SENT" | "RECEIVED";
onClickCancelRequest: (userId: string) => void; onClickCancelRequest: (userId: string) => void;
onClickAcceptRequest: (userId: string) => void; onClickAcceptRequest: (userId: string) => void;
onClickRefuseRequest: (userId: string) => void; onClickRefuseRequest: (userId: string) => void;
onClickItem: (userId: string) => void;
} }
| { type: null } | { type: null; onClickItem: (userId: string) => void }
); );
export const UserFriendItem = (props: UserFriendItemProps) => { export const UserFriendItem = (props: UserFriendItemProps) => {
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
const { userId, profileImageUrl, displayName, type, onClickItem } = props; const { userId, profileImageUrl, displayName, type } = props;
const getRequestDescription = () => { const getRequestDescription = () => {
if (type === "ACCEPTED" || type === null) return null; if (type === "ACCEPTED" || type === null) return null;
@ -86,15 +90,69 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
); );
} }
if (type === "BLOCKED") {
return (
<button
className={styles.cancelRequestButton}
onClick={() => props.onClickUnblock(userId)}
title={t("unblock")}
>
<XCircleIcon size={28} />
</button>
);
}
return null; return null;
}; };
if (type === "BLOCKED") {
return (
<div className={styles.friendListContainer}>
<div className={styles.friendListButton} style={{ cursor: "inherit" }}>
<div className={styles.friendAvatarContainer}>
{profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={displayName}
src={profileImageUrl}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
flex: "1",
minWidth: 0,
}}
>
<p className={styles.friendListDisplayName}>{displayName}</p>
</div>
</div>
<div
style={{
position: "absolute",
right: "8px",
display: "flex",
gap: `${SPACING_UNIT}px`,
}}
>
{getRequestActions()}
</div>
</div>
);
}
return ( return (
<div className={cn(styles.friendListContainer, styles.profileContentBox)}> <div className={styles.friendListContainer}>
<button <button
type="button" type="button"
className={styles.friendListButton} className={styles.friendListButton}
onClick={() => onClickItem(userId)} onClick={() => props.onClickItem(userId)}
> >
<div className={styles.friendAvatarContainer}> <div className={styles.friendAvatarContainer}>
{profileImageUrl ? ( {profileImageUrl ? (

View File

@ -40,20 +40,16 @@ export const UserFriendModalAddFriend = ({
}); });
}; };
const resetAndClose = () => {
setFriendCode("");
closeModal();
};
const handleClickRequest = (userId: string) => { const handleClickRequest = (userId: string) => {
resetAndClose(); closeModal();
navigate(`/user/${userId}`); navigate(`/user/${userId}`);
}; };
const handleClickSeeProfile = () => { const handleClickSeeProfile = () => {
resetAndClose(); closeModal();
// TODO: add validation for this input? if (friendCode.length === 8) {
navigate(`/user/${friendCode}`); navigate(`/user/${friendCode}`);
}
}; };
const handleCancelFriendRequest = (userId: string) => { const handleCancelFriendRequest = (userId: string) => {
@ -122,7 +118,8 @@ export const UserFriendModalAddFriend = ({
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
}} }}
> >
<h3>Pendentes</h3> <h3>{t("pending")}</h3>
{friendRequests.length === 0 && <p>{t("no_pending_invites")}</p>}
{friendRequests.map((request) => { {friendRequests.map((request) => {
return ( return (
<UserFriendItem <UserFriendItem

View File

@ -1,10 +1,11 @@
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { UserFriend } from "@types"; import { UserFriend } from "@types";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { UserFriendItem } from "./user-friend-item"; import { UserFriendItem } from "./user-friend-item";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useToast, useUserDetails } from "@renderer/hooks"; import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
export interface UserFriendModalListProps { export interface UserFriendModalListProps {
userId: string; userId: string;
@ -22,14 +23,17 @@ export const UserFriendModalList = ({
const navigate = useNavigate(); const navigate = useNavigate();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [maxPage, setMaxPage] = useState(0); const [maxPage, setMaxPage] = useState(0);
const [friends, setFriends] = useState<UserFriend[]>([]); const [friends, setFriends] = useState<UserFriend[]>([]);
const listContainer = useRef<HTMLDivElement>(null);
const { userDetails, undoFriendship } = useUserDetails(); const { userDetails, undoFriendship } = useUserDetails();
const isMe = userDetails?.id == userId; const isMe = userDetails?.id == userId;
const loadNextPage = () => { const loadNextPage = () => {
if (page > maxPage) return; if (page > maxPage) return;
setIsLoading(true);
window.electron window.electron
.getUserFriends(userId, pageSize, page * pageSize) .getUserFriends(userId, pageSize, page * pageSize)
.then((newPage) => { .then((newPage) => {
@ -40,9 +44,29 @@ export const UserFriendModalList = ({
setFriends([...friends, ...newPage.friends]); setFriends([...friends, ...newPage.friends]);
setPage(page + 1); setPage(page + 1);
}) })
.catch(() => {}); .catch(() => {})
.finally(() => setIsLoading(false));
}; };
const handleScroll = () => {
const scrollTop = listContainer.current?.scrollTop || 0;
const scrollHeight = listContainer.current?.scrollHeight || 0;
const clientHeight = listContainer.current?.clientHeight || 0;
const maxScrollTop = scrollHeight - clientHeight;
if (scrollTop < maxScrollTop * 0.9 || isLoading) {
return;
}
loadNextPage();
};
useEffect(() => {
const container = listContainer.current;
container?.addEventListener("scroll", handleScroll);
return () => container?.removeEventListener("scroll", handleScroll);
}, [isLoading]);
const reloadList = () => { const reloadList = () => {
setPage(0); setPage(0);
setMaxPage(0); setMaxPage(0);
@ -70,26 +94,42 @@ export const UserFriendModalList = ({
}; };
return ( return (
<div <SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
style={{ <div
display: "flex", ref={listContainer}
flexDirection: "column", style={{
gap: `${SPACING_UNIT * 2}px`, display: "flex",
}} flexDirection: "column",
> gap: `${SPACING_UNIT * 2}px`,
{friends.map((friend) => { maxHeight: "400px",
return ( overflowY: "scroll",
<UserFriendItem }}
userId={friend.id} >
displayName={friend.displayName} {!isLoading && friends.length === 0 && <p>{t("no_friends_added")}</p>}
profileImageUrl={friend.profileImageUrl} {friends.map((friend) => {
onClickItem={handleClickFriend} return (
onClickUndoFriendship={handleUndoFriendship} <UserFriendItem
type={isMe ? "ACCEPTED" : null} userId={friend.id}
key={friend.id} displayName={friend.displayName}
profileImageUrl={friend.profileImageUrl}
onClickItem={handleClickFriend}
onClickUndoFriendship={handleUndoFriendship}
type={isMe ? "ACCEPTED" : null}
key={"modal" + friend.id}
/>
);
})}
{isLoading && (
<Skeleton
style={{
width: "100%",
height: "54px",
overflow: "hidden",
borderRadius: "4px",
}}
/> />
); )}
})} </div>
</div> </SkeletonTheme>
); );
}; };

View File

@ -1,17 +1,6 @@
import { SPACING_UNIT, vars } from "../../../theme.css"; import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
export const profileContentBox = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%",
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)",
transition: "all ease 0.3s",
});
export const friendAvatarContainer = style({ export const friendAvatarContainer = style({
width: "35px", width: "35px",
minWidth: "35px", minWidth: "35px",
@ -42,8 +31,14 @@ export const profileAvatar = style({
}); });
export const friendListContainer = style({ export const friendListContainer = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%", width: "100%",
height: "54px", height: "54px",
minHeight: "54px",
transition: "all ease 0.2s", transition: "all ease 0.2s",
position: "relative", position: "relative",
":hover": { ":hover": {
@ -90,3 +85,15 @@ export const cancelRequestButton = style({
color: vars.color.danger, color: vars.color.danger,
}, },
}); });
export const friendCodeButton = style({
color: vars.color.body,
cursor: "pointer",
display: "flex",
gap: `${SPACING_UNIT / 2}px`,
alignItems: "center",
transition: "all ease 0.2s",
":hover": {
color: vars.color.muted,
},
});

View File

@ -1,10 +1,12 @@
import { Button, Modal } from "@renderer/components"; import { Button, Modal } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend"; import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend";
import { useUserDetails } from "@renderer/hooks"; import { useToast, useUserDetails } from "@renderer/hooks";
import { UserFriendModalList } from "./user-friend-modal-list"; import { UserFriendModalList } from "./user-friend-modal-list";
import { CopyIcon } from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css";
export enum UserFriendModalTab { export enum UserFriendModalTab {
FriendsList, FriendsList,
@ -32,6 +34,8 @@ export const UserFriendModal = ({
initialTab || UserFriendModalTab.FriendsList initialTab || UserFriendModalTab.FriendsList
); );
const { showSuccessToast } = useToast();
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();
const isMe = userDetails?.id == userId; const isMe = userDetails?.id == userId;
@ -53,6 +57,11 @@ export const UserFriendModal = ({
return <></>; return <></>;
}; };
const copyToClipboard = useCallback(() => {
navigator.clipboard.writeText(userDetails!.id);
showSuccessToast(t("friend_code_copied"));
}, [userDetails, showSuccessToast, t]);
return ( return (
<Modal visible={visible} title={t("friends")} onClose={onClose}> <Modal visible={visible} title={t("friends")} onClose={onClose}>
<div <div
@ -64,19 +73,37 @@ export const UserFriendModal = ({
}} }}
> >
{isMe && ( {isMe && (
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}> <>
{tabs.map((tab, index) => { <div
return ( style={{
<Button display: "flex",
key={tab} gap: `${SPACING_UNIT}px`,
theme={index === currentTab ? "primary" : "outline"} alignItems: "center",
onClick={() => setCurrentTab(index)} }}
> >
{tab} <p>Seu código de amigo: </p>
</Button> <button
); className={styles.friendCodeButton}
})} onClick={copyToClipboard}
</section> >
<h3>{userDetails.id}</h3>
<CopyIcon />
</button>
</div>
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
{tabs.map((tab, index) => {
return (
<Button
key={tab}
theme={index === currentTab ? "primary" : "outline"}
onClick={() => setCurrentTab(index)}
>
{tab}
</Button>
);
})}
</section>
</>
)} )}
{renderTab()} {renderTab()}
</div> </div>

View File

@ -1,4 +1,9 @@
import { FriendRequestAction, UserGame, UserProfile } from "@types"; import {
FriendRequestAction,
GameRunning,
UserGame,
UserProfile,
} from "@types";
import cn from "classnames"; import cn from "classnames";
import * as styles from "./user.css"; import * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
@ -25,7 +30,7 @@ import {
XCircleIcon, XCircleIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { Button, Link } from "@renderer/components"; import { Button, Link } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal"; import { UserProfileSettingsModal } from "./user-profile-settings-modal";
import { UserSignOutModal } from "./user-sign-out-modal"; import { UserSignOutModal } from "./user-sign-out-modal";
import { UserFriendModalTab } from "../shared-modals/user-friend-modal"; import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
import { UserBlockModal } from "./user-block-modal"; import { UserBlockModal } from "./user-block-modal";
@ -44,7 +49,6 @@ export function UserContent({
updateUserProfile, updateUserProfile,
}: ProfileContentProps) { }: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile"); const { t, i18n } = useTranslation("user_profile");
const { const {
userDetails, userDetails,
profileBackground, profileBackground,
@ -60,9 +64,11 @@ export function UserContent({
const [profileContentBoxBackground, setProfileContentBoxBackground] = const [profileContentBoxBackground, setProfileContentBoxBackground] =
useState<string | undefined>(); useState<string | undefined>();
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showProfileSettingsModal, setShowProfileSettingsModal] =
useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false);
const [showUserBlockModal, setShowUserBlockModal] = useState(false); const [showUserBlockModal, setShowUserBlockModal] = useState(false);
const [currentGame, setCurrentGame] = useState<GameRunning | null>(null);
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -95,7 +101,7 @@ export function UserContent({
}; };
const handleEditProfile = () => { const handleEditProfile = () => {
setShowEditProfileModal(true); setShowProfileSettingsModal(true);
}; };
const handleOnClickFriend = (userId: string) => { const handleOnClickFriend = (userId: string) => {
@ -112,9 +118,18 @@ export function UserContent({
const isMe = userDetails?.id == userProfile.id; const isMe = userDetails?.id == userProfile.id;
useEffect(() => {
if (isMe && gameRunning) {
setCurrentGame(gameRunning);
return;
}
setCurrentGame(userProfile.currentGame);
}, [gameRunning, isMe, userProfile.currentGame]);
useEffect(() => { useEffect(() => {
if (isMe) fetchFriendRequests(); if (isMe) fetchFriendRequests();
}, [isMe]); }, [isMe, fetchFriendRequests]);
useEffect(() => { useEffect(() => {
if (isMe && profileBackground) { if (isMe && profileBackground) {
@ -128,7 +143,7 @@ export function UserContent({
} }
); );
} }
}, [profileBackground, isMe]); }, [profileBackground, isMe, userProfile.profileImageUrl]);
const handleFriendAction = (userId: string, action: FriendAction) => { const handleFriendAction = (userId: string, action: FriendAction) => {
try { try {
@ -159,13 +174,18 @@ export function UserContent({
}; };
const showFriends = isMe || userProfile.totalFriends > 0; const showFriends = isMe || userProfile.totalFriends > 0;
const showProfileContent =
isMe ||
userProfile.profileVisibility === "PUBLIC" ||
(userProfile.relation?.status === "ACCEPTED" &&
userProfile.profileVisibility === "FRIENDS");
const getProfileActions = () => { const getProfileActions = () => {
if (isMe) { if (isMe) {
return ( return (
<> <>
<Button theme="outline" onClick={handleEditProfile}> <Button theme="outline" onClick={handleEditProfile}>
{t("edit_profile")} {t("settings")}
</Button> </Button>
<Button theme="danger" onClick={() => setShowSignOutModal(true)}> <Button theme="danger" onClick={() => setShowSignOutModal(true)}>
@ -251,9 +271,9 @@ export function UserContent({
return ( return (
<> <>
<UserEditProfileModal <UserProfileSettingsModal
visible={showEditProfileModal} visible={showProfileSettingsModal}
onClose={() => setShowEditProfileModal(false)} onClose={() => setShowProfileSettingsModal(false)}
updateUserProfile={updateUserProfile} updateUserProfile={updateUserProfile}
userProfile={userProfile} userProfile={userProfile}
/> />
@ -278,10 +298,10 @@ export function UserContent({
position: "relative", position: "relative",
}} }}
> >
{gameRunning && isMe && ( {currentGame && (
<img <img
src={steamUrlBuilder.libraryHero(gameRunning.objectID)} src={steamUrlBuilder.libraryHero(currentGame.objectID)}
alt={gameRunning.title} alt={currentGame.title}
className={styles.profileBackground} className={styles.profileBackground}
/> />
)} )}
@ -309,7 +329,7 @@ export function UserContent({
<div className={styles.profileInformation}> <div className={styles.profileInformation}>
<h2 style={{ fontWeight: "bold" }}>{userProfile.displayName}</h2> <h2 style={{ fontWeight: "bold" }}>{userProfile.displayName}</h2>
{isMe && gameRunning && ( {currentGame && (
<div <div
style={{ style={{
display: "flex", display: "flex",
@ -325,14 +345,14 @@ export function UserContent({
alignItems: "center", alignItems: "center",
}} }}
> >
<Link to={buildGameDetailsPath(gameRunning)}> <Link to={buildGameDetailsPath(currentGame)}>
{gameRunning.title} {currentGame.title}
</Link> </Link>
</div> </div>
<small> <small>
{t("playing_for", { {t("playing_for", {
amount: formatDiffInMillis( amount: formatDiffInMillis(
gameRunning.sessionDurationInMillis, currentGame.sessionDurationInMillis,
new Date() new Date()
), ),
})} })}
@ -361,121 +381,69 @@ export function UserContent({
</div> </div>
</section> </section>
<div className={styles.profileContent}> {showProfileContent && (
<div className={styles.profileGameSection}> <div className={styles.profileContent}>
<h2>{t("activity")}</h2>
{!userProfile.recentGames.length ? (
<div className={styles.noDownloads}>
<div className={styles.telescopeIcon}>
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{userProfile.recentGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.feedItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
>
<img
className={styles.feedGameIcon}
src={game.cover}
alt={game.title}
/>
<div className={styles.gameInformation}>
<h4>{game.title}</h4>
<small>
{t("last_time_played", {
period: formatDistance(
game.lastTimePlayed!,
new Date(),
{
addSuffix: true,
}
),
})}
</small>
</div>
</button>
))}
</div>
)}
</div>
<div className={styles.contentSidebar}>
<div className={styles.profileGameSection}> <div className={styles.profileGameSection}>
<div <h2>{t("activity")}</h2>
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{t("library")}</h2>
{!userProfile.recentGames.length ? (
<div className={styles.noDownloads}>
<div className={styles.telescopeIcon}>
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
) : (
<div <div
style={{ style={{
flex: 1, display: "flex",
backgroundColor: vars.color.border, flexDirection: "column",
height: "1px", gap: `${SPACING_UNIT * 2}px`,
}} }}
/> >
<h3 style={{ fontWeight: "400" }}> {userProfile.recentGames.map((game) => (
{userProfile.libraryGames.length} <button
</h3> key={game.objectID}
</div> className={cn(styles.feedItem, styles.profileContentBox)}
<small>{t("total_play_time", { amount: formatPlayTime() })}</small> onClick={() => handleGameClick(game)}
<div >
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
title={game.title}
>
{game.iconUrl ? (
<img <img
className={styles.libraryGameIcon} className={styles.feedGameIcon}
src={game.iconUrl} src={game.cover}
alt={game.title} alt={game.title}
/> />
) : ( <div className={styles.gameInformation}>
<SteamLogo className={styles.libraryGameIcon} /> <h4>{game.title}</h4>
)} <small>
</button> {t("last_time_played", {
))} period: formatDistance(
</div> game.lastTimePlayed!,
new Date(),
{
addSuffix: true,
}
),
})}
</small>
</div>
</button>
))}
</div>
)}
</div> </div>
{showFriends && ( <div className={styles.contentSidebar}>
<div className={styles.friendsSection}> <div className={styles.profileGameSection}>
<button <div
className={styles.friendsSectionHeader} style={{
onClick={() => display: "flex",
showFriendsModal( alignItems: "center",
UserFriendModalTab.FriendsList, justifyContent: "space-between",
userProfile.id gap: `${SPACING_UNIT * 2}px`,
) }}
}
> >
<h2>{t("friends")}</h2> <h2>{t("library")}</h2>
<div <div
style={{ style={{
@ -485,64 +453,123 @@ export function UserContent({
}} }}
/> />
<h3 style={{ fontWeight: "400" }}> <h3 style={{ fontWeight: "400" }}>
{userProfile.totalFriends} {userProfile.libraryGames.length}
</h3> </h3>
</button> </div>
<small>
{t("total_play_time", { amount: formatPlayTime() })}
</small>
<div <div
style={{ style={{
display: "flex", display: "grid",
flexDirection: "column", gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
}} }}
> >
{userProfile.friends.map((friend) => { {userProfile.libraryGames.map((game) => (
return ( <button
<button key={game.objectID}
key={friend.id} className={cn(
className={cn( styles.gameListItem,
styles.profileContentBox, styles.profileContentBox
styles.friendListContainer )}
)} onClick={() => handleGameClick(game)}
onClick={() => handleOnClickFriend(friend.id)} title={game.title}
>
<div className={styles.friendAvatarContainer}>
{friend.profileImageUrl ? (
<img
className={styles.friendProfileIcon}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<p className={styles.friendListDisplayName}>
{friend.displayName}
</p>
</button>
);
})}
{isMe && (
<Button
theme="outline"
onClick={() =>
showFriendsModal(
UserFriendModalTab.AddFriend,
userProfile.id
)
}
> >
<PlusIcon /> {t("add")} {game.iconUrl ? (
</Button> <img
)} className={styles.libraryGameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.libraryGameIcon} />
)}
</button>
))}
</div> </div>
</div> </div>
)}
{showFriends && (
<div className={styles.friendsSection}>
<button
className={styles.friendsSectionHeader}
onClick={() =>
showFriendsModal(
UserFriendModalTab.FriendsList,
userProfile.id
)
}
>
<h2>{t("friends")}</h2>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.totalFriends}
</h3>
</button>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.friends.map((friend) => {
return (
<button
key={friend.id}
className={cn(
styles.profileContentBox,
styles.friendListContainer
)}
onClick={() => handleOnClickFriend(friend.id)}
>
<div className={styles.friendAvatarContainer}>
{friend.profileImageUrl ? (
<img
className={styles.friendProfileIcon}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<p className={styles.friendListDisplayName}>
{friend.displayName}
</p>
</button>
);
})}
{isMe && (
<Button
theme="outline"
onClick={() =>
showFriendsModal(
UserFriendModalTab.AddFriend,
userProfile.id
)
}
>
<PlusIcon /> {t("add")}
</Button>
)}
</div>
</div>
)}
</div>
</div> </div>
</div> )}
</> </>
); );
} }

View File

@ -1,147 +0,0 @@
import { Button, Modal, TextField } from "@renderer/components";
import { UserProfile } from "@types";
import * as styles from "./user.css";
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useMemo, useState } from "react";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
export interface UserEditProfileModalProps {
userProfile: UserProfile;
visible: boolean;
onClose: () => void;
updateUserProfile: () => Promise<void>;
}
export const UserEditProfileModal = ({
userProfile,
visible,
onClose,
updateUserProfile,
}: UserEditProfileModalProps) => {
const { t } = useTranslation("user_profile");
const [displayName, setDisplayName] = useState("");
const [newImagePath, setNewImagePath] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const { patchUser } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
useEffect(() => {
setDisplayName(userProfile.displayName);
}, [userProfile.displayName]);
const handleChangeProfileAvatar = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setNewImagePath(path);
}
};
const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault();
setIsSaving(true);
patchUser(displayName, newImagePath)
.then(async () => {
await updateUserProfile();
showSuccessToast(t("saved_successfully"));
cleanFormAndClose();
})
.catch(() => {
showErrorToast(t("try_again"));
})
.finally(() => {
setIsSaving(false);
});
};
const resetModal = () => {
setDisplayName(userProfile.displayName);
setNewImagePath(null);
};
const cleanFormAndClose = () => {
resetModal();
onClose();
};
const avatarUrl = useMemo(() => {
if (newImagePath) return `local:${newImagePath}`;
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
return null;
}, [newImagePath, userProfile.profileImageUrl]);
return (
<>
<Modal
visible={visible}
title={t("edit_profile")}
onClose={cleanFormAndClose}
>
<form
onSubmit={handleSaveProfile}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: `${SPACING_UNIT * 3}px`,
width: "350px",
}}
>
<button
type="button"
className={styles.profileAvatarEditContainer}
onClick={handleChangeProfileAvatar}
>
{avatarUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={avatarUrl}
/>
) : (
<PersonIcon size={96} />
)}
<div className={styles.editProfileImageBadge}>
<DeviceCameraIcon size={16} />
</div>
</button>
<TextField
label={t("display_name")}
value={displayName}
required
minLength={3}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setDisplayName(e.target.value)}
/>
<Button
disabled={isSaving}
style={{ alignSelf: "end" }}
type="submit"
>
{isSaving ? t("saving") : t("save")}
</Button>
</form>
</Modal>
</>
);
};

View File

@ -0,0 +1 @@
export * from "./user-profile-settings-modal";

View File

@ -0,0 +1,118 @@
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { UserFriend } from "@types";
import { useEffect, useRef, useState } from "react";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import { UserFriendItem } from "@renderer/pages/shared-modals/user-friend-modal/user-friend-item";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
const pageSize = 12;
export const UserEditProfileBlockList = () => {
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [maxPage, setMaxPage] = useState(0);
const [blocks, setBlocks] = useState<UserFriend[]>([]);
const listContainer = useRef<HTMLDivElement>(null);
const { unblockUser } = useUserDetails();
const loadNextPage = () => {
if (page > maxPage) return;
setIsLoading(true);
window.electron
.getUserBlocks(pageSize, page * pageSize)
.then((newPage) => {
if (page === 0) {
setMaxPage(newPage.totalBlocks / pageSize);
}
setBlocks([...blocks, ...newPage.blocks]);
setPage(page + 1);
})
.catch(() => {})
.finally(() => setIsLoading(false));
};
const handleScroll = () => {
const scrollTop = listContainer.current?.scrollTop || 0;
const scrollHeight = listContainer.current?.scrollHeight || 0;
const clientHeight = listContainer.current?.clientHeight || 0;
const maxScrollTop = scrollHeight - clientHeight;
if (scrollTop < maxScrollTop * 0.9 || isLoading) {
return;
}
loadNextPage();
};
useEffect(() => {
const container = listContainer.current;
container?.addEventListener("scroll", handleScroll);
return () => container?.removeEventListener("scroll", handleScroll);
}, [isLoading]);
const reloadList = () => {
setPage(0);
setMaxPage(0);
setBlocks([]);
loadNextPage();
};
useEffect(() => {
reloadList();
}, []);
const handleUnblock = (userId: string) => {
unblockUser(userId)
.then(() => {
reloadList();
})
.catch(() => {
showErrorToast(t("try_again"));
});
};
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<div
ref={listContainer}
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
maxHeight: "400px",
overflowY: "scroll",
}}
>
{!isLoading && blocks.length === 0 && <p>{t("no_blocked_users")}</p>}
{blocks.map((friend) => {
return (
<UserFriendItem
userId={friend.id}
displayName={friend.displayName}
profileImageUrl={friend.profileImageUrl}
onClickUnblock={handleUnblock}
type={"BLOCKED"}
key={friend.id}
/>
);
})}
{isLoading && (
<Skeleton
style={{
width: "100%",
height: "54px",
overflow: "hidden",
borderRadius: "4px",
}}
/>
)}
</div>
</SkeletonTheme>
);
};

View File

@ -0,0 +1,149 @@
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
import { Button, SelectField, TextField } from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { UserProfile } from "@types";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "../user.css";
import { SPACING_UNIT } from "@renderer/theme.css";
export interface UserEditProfileProps {
userProfile: UserProfile;
updateUserProfile: () => Promise<void>;
}
export const UserEditProfile = ({
userProfile,
updateUserProfile,
}: UserEditProfileProps) => {
const { t } = useTranslation("user_profile");
const [form, setForm] = useState({
displayName: userProfile.displayName,
profileVisibility: userProfile.profileVisibility,
imageProfileUrl: null as string | null,
});
const [isSaving, setIsSaving] = useState(false);
const { patchUser } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const [profileVisibilityOptions, setProfileVisibilityOptions] = useState<
{ value: string; label: string }[]
>([]);
useEffect(() => {
setProfileVisibilityOptions([
{ value: "PUBLIC", label: t("public") },
{ value: "FRIENDS", label: t("friends_only") },
{ value: "PRIVATE", label: t("private") },
]);
}, [t]);
const handleChangeProfileAvatar = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setForm({ ...form, imageProfileUrl: path });
}
};
const handleProfileVisibilityChange = (event) => {
setForm({
...form,
profileVisibility: event.target.value,
});
};
const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault();
setIsSaving(true);
patchUser(form)
.then(async () => {
await updateUserProfile();
showSuccessToast(t("saved_successfully"));
})
.catch(() => {
showErrorToast(t("try_again"));
})
.finally(() => {
setIsSaving(false);
});
};
const avatarUrl = useMemo(() => {
if (form.imageProfileUrl) return `local:${form.imageProfileUrl}`;
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
return null;
}, [form, userProfile]);
return (
<form
onSubmit={handleSaveProfile}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: `${SPACING_UNIT * 3}px`,
width: "350px",
}}
>
<button
type="button"
className={styles.profileAvatarEditContainer}
onClick={handleChangeProfileAvatar}
>
{avatarUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={avatarUrl}
/>
) : (
<PersonIcon size={96} />
)}
<div className={styles.editProfileImageBadge}>
<DeviceCameraIcon size={16} />
</div>
</button>
<TextField
label={t("display_name")}
value={form.displayName}
required
minLength={3}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
/>
<SelectField
label={t("privacy")}
value={form.profileVisibility}
onChange={handleProfileVisibilityChange}
options={profileVisibilityOptions.map((visiblity) => ({
key: visiblity.value,
value: visiblity.value,
label: visiblity.label,
}))}
/>
<Button disabled={isSaving} style={{ alignSelf: "end" }} type="submit">
{isSaving ? t("saving") : t("save")}
</Button>
</form>
);
};

View File

@ -0,0 +1,73 @@
import { Button, Modal } from "@renderer/components";
import { UserProfile } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { UserEditProfile } from "./user-edit-profile";
import { UserEditProfileBlockList } from "./user-block-list";
export interface UserProfileSettingsModalProps {
userProfile: UserProfile;
visible: boolean;
onClose: () => void;
updateUserProfile: () => Promise<void>;
}
export const UserProfileSettingsModal = ({
userProfile,
visible,
onClose,
updateUserProfile,
}: UserProfileSettingsModalProps) => {
const { t } = useTranslation("user_profile");
const tabs = [t("edit_profile"), t("blocked_users")];
const [currentTabIndex, setCurrentTabIndex] = useState(0);
const renderTab = () => {
if (currentTabIndex == 0) {
return (
<UserEditProfile
userProfile={userProfile}
updateUserProfile={updateUserProfile}
/>
);
}
if (currentTabIndex == 1) {
return <UserEditProfileBlockList />;
}
return <></>;
};
return (
<>
<Modal visible={visible} title={t("settings")} onClose={onClose}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
{tabs.map((tab, index) => {
return (
<Button
key={tab}
theme={index === currentTabIndex ? "primary" : "outline"}
onClick={() => setCurrentTabIndex(index)}
>
{tab}
</Button>
);
})}
</section>
{renderTab()}
</div>
</Modal>
</>
);
};

View File

@ -60,6 +60,7 @@ export const friendListDisplayName = style({
}); });
export const profileAvatarEditContainer = style({ export const profileAvatarEditContainer = style({
alignSelf: "center",
width: "128px", width: "128px",
height: "128px", height: "128px",
display: "flex", display: "flex",

View File

@ -31,7 +31,7 @@ export const User = () => {
navigate(-1); navigate(-1);
} }
}); });
}, [dispatch, userId, t]); }, [dispatch, navigate, showErrorToast, userId, t]);
useEffect(() => { useEffect(() => {
getUserProfile(); getUserProfile();

View File

@ -3,6 +3,7 @@ export enum Downloader {
Torrent, Torrent,
Gofile, Gofile,
PixelDrain, PixelDrain,
Qiwi,
} }
export enum DownloadSourceStatus { export enum DownloadSourceStatus {
@ -73,13 +74,27 @@ const realDebridHosts = ["https://1fichier.com", "https://mediafire.com"];
export const getDownloadersForUri = (uri: string) => { export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile]; if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile];
if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain]; if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain];
if (uri.startsWith("https://qiwi.gg")) return [Downloader.Qiwi];
if (realDebridHosts.some((host) => uri.startsWith(host))) if (realDebridHosts.some((host) => uri.startsWith(host)))
return [Downloader.RealDebrid]; return [Downloader.RealDebrid];
if (uri.startsWith("magnet:")) if (uri.startsWith("magnet:")) {
return [Downloader.Torrent, Downloader.RealDebrid]; return [Downloader.Torrent, Downloader.RealDebrid];
}
return []; return [];
}; };
export const getDownloadersForUris = (uris: string[]) => {
const downloadersSet = uris.reduce<Set<Downloader>>((prev, next) => {
const downloaders = getDownloadersForUri(next);
downloaders.forEach((downloader) => prev.add(downloader));
return prev;
}, new Set());
return Array.from(downloadersSet);
};

View File

@ -67,7 +67,11 @@ export interface SteamAppDetails {
export interface GameRepack { export interface GameRepack {
id: number; id: number;
title: string; title: string;
/**
* @deprecated Use uris instead
*/
magnet: string; magnet: string;
uris: string[];
repacker: string; repacker: string;
fileSize: string | null; fileSize: string | null;
uploadDate: Date | string | null; uploadDate: Date | string | null;
@ -137,9 +141,9 @@ export interface Game {
export type LibraryGame = Omit<Game, "repacks">; export type LibraryGame = Omit<Game, "repacks">;
export interface GameRunning { export interface GameRunning {
id: number; id?: number;
title: string; title: string;
iconUrl: string; iconUrl: string | null;
objectID: string; objectID: string;
shop: GameShop; shop: GameShop;
sessionDurationInMillis: number; sessionDurationInMillis: number;
@ -194,6 +198,7 @@ export interface StartGameDownloadPayload {
objectID: string; objectID: string;
title: string; title: string;
shop: GameShop; shop: GameShop;
uri: string;
downloadPath: string; downloadPath: string;
downloader: Downloader; downloader: Downloader;
} }
@ -282,6 +287,11 @@ export interface UserFriends {
friends: UserFriend[]; friends: UserFriend[];
} }
export interface UserBlocks {
totalBlocks: number;
blocks: UserFriend[];
}
export interface FriendRequest { export interface FriendRequest {
id: string; id: string;
displayName: string; displayName: string;
@ -308,6 +318,14 @@ export interface UserProfile {
friends: UserFriend[]; friends: UserFriend[];
totalFriends: number; totalFriends: number;
relation: UserRelation | null; relation: UserRelation | null;
currentGame: GameRunning | null;
}
export interface UpdateProfileProps {
displayName?: string;
profileVisibility?: "PUBLIC" | "PRIVATE" | "FRIENDS";
profileImageUrl?: string | null;
bio?: string;
} }
export interface DownloadSource { export interface DownloadSource {

View File

@ -20,6 +20,23 @@ if start_download_payload:
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
rpc_password_header = 'x-hydra-rpc-password' rpc_password_header = 'x-hydra-rpc-password'
skip_log_routes = [
"process-list",
"status"
]
def log_error(self, format, *args):
sys.stderr.write("%s - - [%s] %s\n" %
(self.address_string(),
self.log_date_time_string(),
format%args))
def log_message(self, format, *args):
for route in self.skip_log_routes:
if route in args[0]: return
super().log_message(format, *args)
def do_GET(self): def do_GET(self):
if self.path == "/status": if self.path == "/status":
if self.headers.get(self.rpc_password_header) != rpc_password: if self.headers.get(self.rpc_password_header) != rpc_password: