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_API_URL=API_URL
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN
SENTRY_AUTH_TOKEN=

View File

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

View File

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

View File

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

View File

@ -174,12 +174,9 @@
"validate_download_source": "Validate",
"remove_download_source": "Remove",
"add_download_source": "Add source",
"download_count_zero": "No downloads in list",
"download_count_one": "{{countFormatted}} download in list",
"download_count_other": "{{countFormatted}} downloads in list",
"download_options_zero": "No download available",
"download_options_one": "{{countFormatted}} download available",
"download_options_other": "{{countFormatted}} downloads available",
"download_count_zero": "No download options",
"download_count_one": "{{countFormatted}} download option",
"download_count_other": "{{countFormatted}} download options",
"download_source_url": "Download source URL",
"add_download_source_description": "Insert the URL containing the .json file",
"download_source_up_to_date": "Up-to-date",
@ -261,6 +258,18 @@
"undo_friendship": "Undo friendship",
"request_accepted": "Request accepted",
"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",
"request_accepted": "Pedido de amizade aceito",
"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,17 +6,13 @@ import {
GameShopCache,
Repack,
UserPreferences,
UserAuth,
} from "@main/entity";
import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions";
import { databasePath } from "./constants";
import migrations from "./migrations";
import { UserAuth } from "./entity/user-auth";
import * as migrations from "./migrations";
export const createDataSource = (
options: Partial<BetterSqlite3ConnectionOptions>
) =>
new DataSource({
export const dataSource = new DataSource({
type: "better-sqlite3",
entities: [
Game,
@ -29,9 +25,5 @@ export const createDataSource = (
],
synchronize: true,
database: databasePath,
...options,
});
export const dataSource = createDataSource({
migrations,
});

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ const removeRemoveGameFromLibrary = async (gameId: number) => {
const game = await gameRepository.findOne({ where: { id: gameId } });
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 path from "node:path";
import { fileTypeFromFile } from "file-type";
import { UserProfile } from "@types";
import { UpdateProfileProps, UserProfile } from "@types";
const patchUserProfile = async (
displayName: string,
profileImageUrl?: string
) => {
if (profileImageUrl) {
return HydraApi.patch("/profile", {
displayName,
profileImageUrl,
});
} else {
return HydraApi.patch("/profile", {
displayName,
});
}
const patchUserProfile = async (updateProfile: UpdateProfileProps) => {
return HydraApi.patch("/profile", updateProfile);
};
const updateProfile = async (
_event: Electron.IpcMainInvokeEvent,
displayName: string,
newProfileImagePath: string | null
updateProfile: UpdateProfileProps
): Promise<UserProfile> => {
if (!newProfileImagePath) {
return patchUserProfile(displayName);
if (!updateProfile.profileImageUrl) {
return patchUserProfile(updateProfile);
}
const newProfileImagePath = updateProfile.profileImageUrl;
const stats = fs.statSync(newProfileImagePath);
const fileBuffer = fs.readFileSync(newProfileImagePath);
const fileSizeInBytes = stats.size;
@ -53,7 +42,7 @@ const updateProfile = async (
})
.catch(() => undefined);
return patchUserProfile(displayName, profileImageUrl);
return patchUserProfile({ ...updateProfile, profileImageUrl });
};
registerEvent("updateProfile", updateProfile);

View File

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

View File

@ -5,7 +5,7 @@ const blockUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.post(`/user/${userId}/block`);
await HydraApi.post(`/users/${userId}/block`);
};
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(`/user/${userId}/friends`, { take, skip });
return HydraApi.get(`/users/${userId}/friends`, { take, skip });
};
const getUserFriendsEvent = async (

View File

@ -1,7 +1,7 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { steamGamesWorker } from "@main/workers";
import { UserProfile } from "@types";
import { GameRunning, UserGame, UserProfile } from "@types";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { getSteamAppAsset } from "@main/helpers";
import { getUserFriends } from "./get-user-friends";
@ -12,7 +12,7 @@ const getUser = async (
): Promise<UserProfile | null> => {
try {
const [profile, friends] = await Promise.all([
HydraApi.get(`/user/${userId}`),
HydraApi.get(`/users/${userId}`),
getUserFriends(userId, 12, 0).catch(() => {
return { totalFriends: 0, friends: [] };
}),
@ -20,48 +20,57 @@ const getUser = async (
const recentGames = await Promise.all(
profile.recentGames.map(async (game) => {
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,
};
return getSteamUserGame(game);
})
);
const libraryGames = await Promise.all(
profile.libraryGames.map(async (game) => {
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,
};
return getSteamUserGame(game);
})
);
const currentGame = await getGameRunning(profile.currentGame);
return {
...profile,
libraryGames,
recentGames,
friends: friends.friends,
totalFriends: friends.totalFriends,
currentGame,
};
} catch (err) {
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);

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import axios from "axios";
import { JSDOM } from "jsdom";
import UserAgent from "user-agents";
export const getSteamAppAsset = (
@ -48,13 +49,19 @@ export const sleep = (ms: number) =>
export const requestWebPage = async (url: string) => {
const userAgent = new UserAgent();
return axios
const data = await axios
.get(url, {
headers: {
"User-Agent": userAgent.toString(),
},
})
.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";

View File

@ -20,8 +20,6 @@ autoUpdater.setFeedURL({
autoUpdater.logger = logger;
logger.log("Init Hydra");
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
@ -123,7 +121,6 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => {
/* Disconnects libtorrent */
PythonInstance.kill();
logger.log("Quit Hydra");
});
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";
import { AlterLastTimePlayedToDatime1716776027208 } from "./1716776027208-alter_lastTimePlayed_to_datime";
export default [
FixRepackUploadDate1715900413313,
AlterLastTimePlayedToDatime1716776027208,
];
export * from "./1724081695967-Hydra_2_0_3";
export * from "./1724081984535-DowloadsRefactor";

View File

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

View File

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

View File

@ -1,67 +1,52 @@
import { DownloadItem } from "electron";
import { WindowManager } from "../window-manager";
import path from "node:path";
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): {
completedLength: number;
totalLength: number;
downloadSpeed: number;
folderName: string;
} | null {
const downloadItem = this.downloads[gid];
if (downloadItem) {
public getStatus() {
return {
completedLength: downloadItem.getReceivedBytes(),
totalLength: downloadItem.getTotalBytes(),
downloadSpeed: downloadItem.getCurrentBytesPerSecond(),
folderName: downloadItem.getFilename(),
completedLength: this.downloadItem.getReceivedBytes(),
totalLength: this.downloadItem.getTotalBytes(),
downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(),
folderName: this.downloadItem.getFilename(),
};
}
return null;
async cancelDownload() {
this.downloadItem.cancel();
}
static async cancelDownload(gid: string) {
const downloadItem = this.downloads[gid];
downloadItem?.cancel();
delete this.downloads[gid];
async pauseDownload() {
this.downloadItem.pause();
}
static async pauseDownload(gid: string) {
const downloadItem = this.downloads[gid];
downloadItem?.pause();
async resumeDownload() {
this.downloadItem.resume();
}
static async resumeDownload(gid: string) {
const downloadItem = this.downloads[gid];
downloadItem?.resume();
}
async startDownload() {
return new Promise((resolve) => {
const options = this.headers ? { headers: this.headers } : {};
WindowManager.mainWindow?.webContents.downloadURL(
this.downloadUrl,
options
);
static async startDownload(
downloadPath: string,
downloadUrl: string,
headers?: Record<string, string>
) {
return new Promise<string>((resolve) => {
const options = headers ? { headers } : {};
WindowManager.mainWindow?.webContents.downloadURL(downloadUrl, options);
const gid = ++this.id;
WindowManager.mainWindow?.webContents.session.on(
WindowManager.mainWindow?.webContents.session.once(
"will-download",
(_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(downloadPath, item.getFilename()));
item.setSavePath(path.join(this.downloadPath, item.getFilename()));
resolve(gid.toString());
resolve(null);
}
);
});

View File

@ -19,6 +19,7 @@ import {
LibtorrentPayload,
ProcessPayload,
} from "./types";
import { pythonInstanceLogger as logger } from "../logger";
export class PythonInstance {
private static pythonProcess: cp.ChildProcess | null = null;
@ -32,11 +33,13 @@ export class PythonInstance {
});
public static spawn(args?: StartDownloadPayload) {
logger.log("spawning python process with args:", args);
this.pythonProcess = startRPCClient(args);
}
public static kill() {
if (this.pythonProcess) {
logger.log("killing python process");
this.pythonProcess.kill();
this.pythonProcess = null;
this.downloadingGameId = -1;
@ -45,6 +48,7 @@ export class PythonInstance {
public static killTorrent() {
if (this.pythonProcess) {
logger.log("killing torrent in python process");
this.rpc.post("/action", { action: "kill-torrent" });
this.downloadingGameId = -1;
}
@ -138,12 +142,14 @@ export class PythonInstance {
save_path: game.downloadPath!,
});
} else {
await this.rpc.post("/action", {
await this.rpc
.post("/action", {
action: "start",
game_id: game.id,
magnet: game.uri,
save_path: game.downloadPath,
} as StartDownloadPayload);
} as StartDownloadPayload)
.catch(this.handleRpcError);
}
this.downloadingGameId = game.id;
@ -159,4 +165,14 @@ export class PythonInstance {
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 { RealDebridClient } from "../real-debrid";
import { gameRepository } from "@main/repository";
import { calculateETA } from "./helpers";
import { DownloadProgress } from "@types";
import { HttpDownload } from "./http-download";
import { GenericHttpDownloader } from "./generic-http-downloader";
export class RealDebridDownloader {
private static downloads = new Map<number, string>();
private static downloadingGame: Game | null = null;
export class RealDebridDownloader extends GenericHttpDownloader {
private static realDebridTorrentId: string | null = null;
private static async getRealDebridDownloadUrl() {
@ -48,66 +43,6 @@ export class RealDebridDownloader {
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) {
if (this.downloads.has(game.id)) {
await this.resumeDownload(game.id!);
@ -128,32 +63,10 @@ export class RealDebridDownloader {
if (downloadUrl) {
this.realDebridTorrentId = null;
const gid = await HttpDownload.startDownload(
game.downloadPath!,
downloadUrl
);
const httpDownload = new HttpDownload(game.downloadPath!, downloadUrl);
httpDownload.startDownload();
this.downloads.set(game.id!, gid);
}
}
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);
this.downloads.set(game.id!, httpDownload);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,6 @@ export const startMainLoop = async () => {
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 { GameRunning } from "@types";
import { PythonInstance } from "./download";
import { Game } from "@main/entity";
export const gamesPlaytime = new Map<
number,
{ lastTick: number; firstTick: number }
{ lastTick: number; firstTick: number; lastSyncTick: number }
>();
const TICKS_TO_UPDATE_API = 120;
let currentTick = 1;
export const watchProcesses = async () => {
const games = await gameRepository.find({
where: {
@ -30,47 +34,16 @@ export const watchProcesses = async () => {
if (gameProcess) {
if (gamesPlaytime.has(game.id)) {
const gamePlaytime = gamesPlaytime.get(game.id)!;
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(),
});
onTickGame(game);
} else {
if (game.remoteId) {
updateGamePlaytime(game, 0, new Date());
} else {
createGame({ ...game, lastTimePlayed: new Date() });
}
gamesPlaytime.set(game.id, {
lastTick: performance.now(),
firstTick: performance.now(),
});
onOpenGame(game);
}
} else if (gamesPlaytime.has(game.id)) {
const gamePlaytime = gamesPlaytime.get(game.id)!;
gamesPlaytime.delete(game.id);
onCloseGame(game);
}
}
if (game.remoteId) {
updateGamePlaytime(
game,
performance.now() - gamePlaytime.firstTick,
game.lastTimePlayed!
);
} else {
createGame(game);
}
}
}
currentTick++;
if (WindowManager.mainWindow) {
const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => {
@ -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();
public static async updateRepacks() {
this.repacks = await repackRepository.find({
this.repacks = await repackRepository
.find({
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++) {
this.repacksIndex.remove(i);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,7 +23,7 @@ import {
} from "@renderer/context";
import { useDownload } from "@renderer/hooks";
import { GameOptionsModal, RepacksModal } from "./modals";
import { Downloader } from "@shared";
import { Downloader, getDownloadersForUri } from "@shared";
export function GameDetails() {
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 (
<GameDetailsContextProvider>
<GameDetailsContextConsumer>
@ -96,6 +99,7 @@ export function GameDetails() {
downloader,
shop: shop as GameShop,
downloadPath,
uri: selectRepackUri(repack, downloader),
});
await updateGame();

View File

@ -20,13 +20,16 @@ export const hintText = style({
});
export const downloaders = style({
display: "flex",
display: "grid",
gap: `${SPACING_UNIT}px`,
gridTemplateColumns: "repeat(2, 1fr)",
});
export const downloaderOption = style({
flex: "1",
position: "relative",
":only-child": {
gridColumn: "1 / -1",
},
});
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 { Button, Link, Modal, TextField } from "@renderer/components";
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 { SPACING_UNIT } from "@renderer/theme.css";
@ -48,8 +48,8 @@ export function DownloadSettingsModal({
}, [visible, selectedPath]);
const downloaders = useMemo(() => {
return getDownloadersForUri(repack?.magnet ?? "");
}, [repack?.magnet]);
return getDownloadersForUris(repack?.uris ?? []);
}, [repack?.uris]);
useEffect(() => {
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 (
<>
<DownloadSettingsModal
@ -97,9 +104,7 @@ export function RepacksModal({
<div className={styles.repacks}>
{filteredRepacks.map((repack) => {
const isLastDownloadedOption =
infoHash !== null &&
repack.magnet.toLowerCase().includes(infoHash);
const isLastDownloadedOption = checkIfLastDownloadedOption(repack);
return (
<Button

View File

@ -42,10 +42,3 @@ export const downloadSourcesHeader = style({
justifyContent: "space-between",
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(),
})}
</small>
<div className={styles.separator} />
<small>
{t("download_options", {
count: downloadSource.repackCount,
countFormatted: downloadSource.repackCount.toLocaleString(),
})}
</small>
</div>
</div>

View File

@ -4,7 +4,6 @@ import {
XCircleIcon,
} from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css";
import cn from "classnames";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
@ -12,21 +11,26 @@ export type UserFriendItemProps = {
userId: string;
profileImageUrl: string | null;
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";
onClickCancelRequest: (userId: string) => void;
onClickAcceptRequest: (userId: string) => void;
onClickRefuseRequest: (userId: string) => void;
onClickItem: (userId: string) => void;
}
| { type: null }
| { type: null; onClickItem: (userId: string) => void }
);
export const UserFriendItem = (props: UserFriendItemProps) => {
const { t } = useTranslation("user_profile");
const { userId, profileImageUrl, displayName, type, onClickItem } = props;
const { userId, profileImageUrl, displayName, type } = props;
const getRequestDescription = () => {
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;
};
if (type === "BLOCKED") {
return (
<div className={cn(styles.friendListContainer, styles.profileContentBox)}>
<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 (
<div className={styles.friendListContainer}>
<button
type="button"
className={styles.friendListButton}
onClick={() => onClickItem(userId)}
onClick={() => props.onClickItem(userId)}
>
<div className={styles.friendAvatarContainer}>
{profileImageUrl ? (

View File

@ -40,20 +40,16 @@ export const UserFriendModalAddFriend = ({
});
};
const resetAndClose = () => {
setFriendCode("");
closeModal();
};
const handleClickRequest = (userId: string) => {
resetAndClose();
closeModal();
navigate(`/user/${userId}`);
};
const handleClickSeeProfile = () => {
resetAndClose();
// TODO: add validation for this input?
closeModal();
if (friendCode.length === 8) {
navigate(`/user/${friendCode}`);
}
};
const handleCancelFriendRequest = (userId: string) => {
@ -122,7 +118,8 @@ export const UserFriendModalAddFriend = ({
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h3>Pendentes</h3>
<h3>{t("pending")}</h3>
{friendRequests.length === 0 && <p>{t("no_pending_invites")}</p>}
{friendRequests.map((request) => {
return (
<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 { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { UserFriendItem } from "./user-friend-item";
import { useNavigate } from "react-router-dom";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
export interface UserFriendModalListProps {
userId: string;
@ -22,14 +23,17 @@ export const UserFriendModalList = ({
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [maxPage, setMaxPage] = useState(0);
const [friends, setFriends] = useState<UserFriend[]>([]);
const listContainer = useRef<HTMLDivElement>(null);
const { userDetails, undoFriendship } = useUserDetails();
const isMe = userDetails?.id == userId;
const loadNextPage = () => {
if (page > maxPage) return;
setIsLoading(true);
window.electron
.getUserFriends(userId, pageSize, page * pageSize)
.then((newPage) => {
@ -40,9 +44,29 @@ export const UserFriendModalList = ({
setFriends([...friends, ...newPage.friends]);
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 = () => {
setPage(0);
setMaxPage(0);
@ -70,13 +94,18 @@ export const UserFriendModalList = ({
};
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 && friends.length === 0 && <p>{t("no_friends_added")}</p>}
{friends.map((friend) => {
return (
<UserFriendItem
@ -86,10 +115,21 @@ export const UserFriendModalList = ({
onClickItem={handleClickFriend}
onClickUndoFriendship={handleUndoFriendship}
type={isMe ? "ACCEPTED" : null}
key={friend.id}
key={"modal" + friend.id}
/>
);
})}
{isLoading && (
<Skeleton
style={{
width: "100%",
height: "54px",
overflow: "hidden",
borderRadius: "4px",
}}
/>
)}
</div>
</SkeletonTheme>
);
};

View File

@ -1,17 +1,6 @@
import { SPACING_UNIT, vars } from "../../../theme.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({
width: "35px",
minWidth: "35px",
@ -42,8 +31,14 @@ export const profileAvatar = style({
});
export const friendListContainer = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%",
height: "54px",
minHeight: "54px",
transition: "all ease 0.2s",
position: "relative",
":hover": {
@ -90,3 +85,15 @@ export const cancelRequestButton = style({
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 { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
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 { CopyIcon } from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css";
export enum UserFriendModalTab {
FriendsList,
@ -32,6 +34,8 @@ export const UserFriendModal = ({
initialTab || UserFriendModalTab.FriendsList
);
const { showSuccessToast } = useToast();
const { userDetails } = useUserDetails();
const isMe = userDetails?.id == userId;
@ -53,6 +57,11 @@ export const UserFriendModal = ({
return <></>;
};
const copyToClipboard = useCallback(() => {
navigator.clipboard.writeText(userDetails!.id);
showSuccessToast(t("friend_code_copied"));
}, [userDetails, showSuccessToast, t]);
return (
<Modal visible={visible} title={t("friends")} onClose={onClose}>
<div
@ -64,6 +73,23 @@ export const UserFriendModal = ({
}}
>
{isMe && (
<>
<div
style={{
display: "flex",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
}}
>
<p>Seu código de amigo: </p>
<button
className={styles.friendCodeButton}
onClick={copyToClipboard}
>
<h3>{userDetails.id}</h3>
<CopyIcon />
</button>
</div>
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
{tabs.map((tab, index) => {
return (
@ -77,6 +103,7 @@ export const UserFriendModal = ({
);
})}
</section>
</>
)}
{renderTab()}
</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 * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
@ -25,7 +30,7 @@ import {
XCircleIcon,
} from "@primer/octicons-react";
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 { UserFriendModalTab } from "../shared-modals/user-friend-modal";
import { UserBlockModal } from "./user-block-modal";
@ -44,7 +49,6 @@ export function UserContent({
updateUserProfile,
}: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile");
const {
userDetails,
profileBackground,
@ -60,9 +64,11 @@ export function UserContent({
const [profileContentBoxBackground, setProfileContentBoxBackground] =
useState<string | undefined>();
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showProfileSettingsModal, setShowProfileSettingsModal] =
useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false);
const [showUserBlockModal, setShowUserBlockModal] = useState(false);
const [currentGame, setCurrentGame] = useState<GameRunning | null>(null);
const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -95,7 +101,7 @@ export function UserContent({
};
const handleEditProfile = () => {
setShowEditProfileModal(true);
setShowProfileSettingsModal(true);
};
const handleOnClickFriend = (userId: string) => {
@ -112,9 +118,18 @@ export function UserContent({
const isMe = userDetails?.id == userProfile.id;
useEffect(() => {
if (isMe && gameRunning) {
setCurrentGame(gameRunning);
return;
}
setCurrentGame(userProfile.currentGame);
}, [gameRunning, isMe, userProfile.currentGame]);
useEffect(() => {
if (isMe) fetchFriendRequests();
}, [isMe]);
}, [isMe, fetchFriendRequests]);
useEffect(() => {
if (isMe && profileBackground) {
@ -128,7 +143,7 @@ export function UserContent({
}
);
}
}, [profileBackground, isMe]);
}, [profileBackground, isMe, userProfile.profileImageUrl]);
const handleFriendAction = (userId: string, action: FriendAction) => {
try {
@ -159,13 +174,18 @@ export function UserContent({
};
const showFriends = isMe || userProfile.totalFriends > 0;
const showProfileContent =
isMe ||
userProfile.profileVisibility === "PUBLIC" ||
(userProfile.relation?.status === "ACCEPTED" &&
userProfile.profileVisibility === "FRIENDS");
const getProfileActions = () => {
if (isMe) {
return (
<>
<Button theme="outline" onClick={handleEditProfile}>
{t("edit_profile")}
{t("settings")}
</Button>
<Button theme="danger" onClick={() => setShowSignOutModal(true)}>
@ -251,9 +271,9 @@ export function UserContent({
return (
<>
<UserEditProfileModal
visible={showEditProfileModal}
onClose={() => setShowEditProfileModal(false)}
<UserProfileSettingsModal
visible={showProfileSettingsModal}
onClose={() => setShowProfileSettingsModal(false)}
updateUserProfile={updateUserProfile}
userProfile={userProfile}
/>
@ -278,10 +298,10 @@ export function UserContent({
position: "relative",
}}
>
{gameRunning && isMe && (
{currentGame && (
<img
src={steamUrlBuilder.libraryHero(gameRunning.objectID)}
alt={gameRunning.title}
src={steamUrlBuilder.libraryHero(currentGame.objectID)}
alt={currentGame.title}
className={styles.profileBackground}
/>
)}
@ -309,7 +329,7 @@ export function UserContent({
<div className={styles.profileInformation}>
<h2 style={{ fontWeight: "bold" }}>{userProfile.displayName}</h2>
{isMe && gameRunning && (
{currentGame && (
<div
style={{
display: "flex",
@ -325,14 +345,14 @@ export function UserContent({
alignItems: "center",
}}
>
<Link to={buildGameDetailsPath(gameRunning)}>
{gameRunning.title}
<Link to={buildGameDetailsPath(currentGame)}>
{currentGame.title}
</Link>
</div>
<small>
{t("playing_for", {
amount: formatDiffInMillis(
gameRunning.sessionDurationInMillis,
currentGame.sessionDurationInMillis,
new Date()
),
})}
@ -361,6 +381,7 @@ export function UserContent({
</div>
</section>
{showProfileContent && (
<div className={styles.profileContent}>
<div className={styles.profileGameSection}>
<h2>{t("activity")}</h2>
@ -435,7 +456,9 @@ export function UserContent({
{userProfile.libraryGames.length}
</h3>
</div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<small>
{t("total_play_time", { amount: formatPlayTime() })}
</small>
<div
style={{
display: "grid",
@ -446,7 +469,10 @@ export function UserContent({
{userProfile.libraryGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)}
className={cn(
styles.gameListItem,
styles.profileContentBox
)}
onClick={() => handleGameClick(game)}
title={game.title}
>
@ -543,6 +569,7 @@ export function UserContent({
)}
</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({
alignSelf: "center",
width: "128px",
height: "128px",
display: "flex",

View File

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

View File

@ -3,6 +3,7 @@ export enum Downloader {
Torrent,
Gofile,
PixelDrain,
Qiwi,
}
export enum DownloadSourceStatus {
@ -73,13 +74,27 @@ const realDebridHosts = ["https://1fichier.com", "https://mediafire.com"];
export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile];
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)))
return [Downloader.RealDebrid];
if (uri.startsWith("magnet:"))
if (uri.startsWith("magnet:")) {
return [Downloader.Torrent, Downloader.RealDebrid];
}
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 {
id: number;
title: string;
/**
* @deprecated Use uris instead
*/
magnet: string;
uris: string[];
repacker: string;
fileSize: string | null;
uploadDate: Date | string | null;
@ -137,9 +141,9 @@ export interface Game {
export type LibraryGame = Omit<Game, "repacks">;
export interface GameRunning {
id: number;
id?: number;
title: string;
iconUrl: string;
iconUrl: string | null;
objectID: string;
shop: GameShop;
sessionDurationInMillis: number;
@ -194,6 +198,7 @@ export interface StartGameDownloadPayload {
objectID: string;
title: string;
shop: GameShop;
uri: string;
downloadPath: string;
downloader: Downloader;
}
@ -282,6 +287,11 @@ export interface UserFriends {
friends: UserFriend[];
}
export interface UserBlocks {
totalBlocks: number;
blocks: UserFriend[];
}
export interface FriendRequest {
id: string;
displayName: string;
@ -308,6 +318,14 @@ export interface UserProfile {
friends: UserFriend[];
totalFriends: number;
relation: UserRelation | null;
currentGame: GameRunning | null;
}
export interface UpdateProfileProps {
displayName?: string;
profileVisibility?: "PUBLIC" | "PRIVATE" | "FRIENDS";
profileImageUrl?: string | null;
bio?: string;
}
export interface DownloadSource {

View File

@ -20,6 +20,23 @@ if start_download_payload:
class Handler(BaseHTTPRequestHandler):
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):
if self.path == "/status":
if self.headers.get(self.rpc_password_header) != rpc_password: