diff --git a/.env.example b/.env.example index c2ad43d9..47d1a1e3 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 20a00ccf..b55b280e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0684c6c..d1bc8993 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.prettierignore b/.prettierignore index 05d298a1..9b6e9df6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ pnpm-lock.yaml LICENSE.md tsconfig.json tsconfig.*.json +src/main/migrations diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b24509d3..e2726b79 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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" } } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index ef94c31f..36d38c96 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -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" } } diff --git a/src/main/data-source.ts b/src/main/data-source.ts index b47ce2c0..446ccbdc 100644 --- a/src/main/data-source.ts +++ b/src/main/data-source.ts @@ -6,32 +6,24 @@ 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 -) => - new DataSource({ - type: "better-sqlite3", - entities: [ - Game, - Repack, - UserPreferences, - GameShopCache, - DownloadSource, - DownloadQueue, - UserAuth, - ], - synchronize: true, - database: databasePath, - ...options, - }); - -export const dataSource = createDataSource({ +export const dataSource = new DataSource({ + type: "better-sqlite3", + entities: [ + Game, + Repack, + UserPreferences, + GameShopCache, + DownloadSource, + DownloadQueue, + UserAuth, + ], + synchronize: true, + database: databasePath, migrations, }); diff --git a/src/main/entity/repack.entity.ts b/src/main/entity/repack.entity.ts index 1d5259fd..ff3f16cb 100644 --- a/src/main/entity/repack.entity.ts +++ b/src/main/entity/repack.entity.ts @@ -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; diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts index 8f24caad..b8565645 100644 --- a/src/main/events/download-sources/get-download-sources.ts +++ b/src/main/events/download-sources/get-download-sources.ts @@ -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); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 57daf51c..3963e4b0 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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); diff --git a/src/main/events/library/remove-game-from-library.ts b/src/main/events/library/remove-game-from-library.ts index 468f5b26..a8fc8b01 100644 --- a/src/main/events/library/remove-game-from-library.ts +++ b/src/main/events/library/remove-game-from-library.ts @@ -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(() => {}); } }; diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index 8620eaa1..50d2ab66 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -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 => { - 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); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index cea41596..f4db999f 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -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!); }; diff --git a/src/main/events/user/block-user.ts b/src/main/events/user/block-user.ts index 8003f478..c81231e5 100644 --- a/src/main/events/user/block-user.ts +++ b/src/main/events/user/block-user.ts @@ -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); diff --git a/src/main/events/user/get-user-blocks.ts b/src/main/events/user/get-user-blocks.ts new file mode 100644 index 00000000..65bb3eb4 --- /dev/null +++ b/src/main/events/user/get-user-blocks.ts @@ -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 => { + return HydraApi.get(`/profile/blocks`, { take, skip }); +}; + +registerEvent("getUserBlocks", getUserBlocks); diff --git a/src/main/events/user/get-user-friends.ts b/src/main/events/user/get-user-friends.ts index 28783459..5ff4c8a4 100644 --- a/src/main/events/user/get-user-friends.ts +++ b/src/main/events/user/get-user-friends.ts @@ -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 ( diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts index eb4f0619..68d69969 100644 --- a/src/main/events/user/get-user.ts +++ b/src/main/events/user/get-user.ts @@ -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 => { 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 => { + if (!currentGame) { + return null; + } + + const gameRunning = await getSteamUserGame(currentGame); + + return { + ...gameRunning, + sessionDurationInMillis: currentGame.sessionDurationInSeconds * 1000, + }; +}; + +const getSteamUserGame = async (game): Promise => { + 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); diff --git a/src/main/events/user/unblock-user.ts b/src/main/events/user/unblock-user.ts index ac678dbd..c604a0b5 100644 --- a/src/main/events/user/unblock-user.ts +++ b/src/main/events/user/unblock-user.ts @@ -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); diff --git a/src/main/helpers/download-source.ts b/src/main/helpers/download-source.ts index 012a4d24..c216212a 100644 --- a/src/main/helpers/download-source.ts +++ b/src/main/helpers/download-source.ts @@ -17,7 +17,8 @@ export const insertDownloadsFromSource = async ( const repacks: QueryDeepPartialEntity[] = 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, diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 902b927d..b0ff391f 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -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"; diff --git a/src/main/index.ts b/src/main/index.ts index 9ff74bf6..e288302b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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", () => { diff --git a/src/main/migrations/1715900413313-fix_repack_uploadDate.ts b/src/main/migrations/1715900413313-fix_repack_uploadDate.ts deleted file mode 100644 index e9d0a6c2..00000000 --- a/src/main/migrations/1715900413313-fix_repack_uploadDate.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class FixRepackUploadDate1715900413313 implements MigrationInterface { - public async up(_: QueryRunner): Promise { - return; - } - - public async down(_: QueryRunner): Promise { - return; - } -} diff --git a/src/main/migrations/1716776027208-alter_lastTimePlayed_to_datime.ts b/src/main/migrations/1716776027208-alter_lastTimePlayed_to_datime.ts deleted file mode 100644 index 6a562915..00000000 --- a/src/main/migrations/1716776027208-alter_lastTimePlayed_to_datime.ts +++ /dev/null @@ -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 { - // 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 { - 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] - ); - } - } -} diff --git a/src/main/migrations/1724081695967-Hydra_2_0_3.ts b/src/main/migrations/1724081695967-Hydra_2_0_3.ts new file mode 100644 index 00000000..5ab18acb --- /dev/null +++ b/src/main/migrations/1724081695967-Hydra_2_0_3.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Hydra2031724081695967 implements MigrationInterface { + name = 'Hydra2031724081695967' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/src/main/migrations/1724081984535-DowloadsRefactor.ts b/src/main/migrations/1724081984535-DowloadsRefactor.ts new file mode 100644 index 00000000..3afc8444 --- /dev/null +++ b/src/main/migrations/1724081984535-DowloadsRefactor.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DowloadsRefactor1724081984535 implements MigrationInterface { + name = 'DowloadsRefactor1724081984535' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/src/main/migrations/index.ts b/src/main/migrations/index.ts index c0c96e45..5546bce0 100644 --- a/src/main/migrations/index.ts +++ b/src/main/migrations/index.ts @@ -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"; diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 52a66693..d4733a32 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -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,26 +96,38 @@ export class DownloadManager { } static async startDownload(game: Game) { - if (game.downloader === Downloader.Gofile) { - const id = game!.uri!.split("/").pop(); + switch (game.downloader) { + case Downloader.Gofile: { + const id = game!.uri!.split("/").pop(); - const token = await GofileApi.authorize(); - const downloadLink = await GofileApi.getDownloadLink(id!); + const token = await GofileApi.authorize(); + const downloadLink = await GofileApi.getDownloadLink(id!); - GenericHTTPDownloader.startDownload(game, downloadLink, { - Cookie: `accountToken=${token}`, - }); - } else if (game.downloader === Downloader.PixelDrain) { - const id = game!.uri!.split("/").pop(); + GenericHttpDownloader.startDownload(game, downloadLink, { + Cookie: `accountToken=${token}`, + }); + break; + } + case Downloader.PixelDrain: { + const id = game!.uri!.split("/").pop(); - await GenericHTTPDownloader.startDownload( - game, - `https://pixeldrain.com/api/file/${id}?download` - ); - } else if (game.downloader === Downloader.Torrent) { - PythonInstance.startDownload(game); - } else if (game.downloader === Downloader.RealDebrid) { - RealDebridDownloader.startDownload(game); + await GenericHttpDownloader.startDownload( + game, + `https://pixeldrain.com/api/file/${id}?download` + ); + break; + } + case Downloader.Qiwi: { + 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; diff --git a/src/main/services/download/generic-http-downloader.ts b/src/main/services/download/generic-http-downloader.ts index 8384a5fd..055c8561 100644 --- a/src/main/services/download/generic-http-downloader.ts +++ b/src/main/services/download/generic-http-downloader.ts @@ -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(); - private static downloadingGame: Game | null = null; +export class GenericHttpDownloader { + public static downloads = new Map(); + 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(); } } } diff --git a/src/main/services/download/http-download.ts b/src/main/services/download/http-download.ts index cd8cbee5..4f6c31a9 100644 --- a/src/main/services/download/http-download.ts +++ b/src/main/services/download/http-download.ts @@ -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 = {}; + constructor( + private downloadPath: string, + private downloadUrl: string, + private headers?: Record + ) {} - public static getStatus(gid: string): { - completedLength: number; - totalLength: number; - downloadSpeed: number; - folderName: string; - } | null { - const downloadItem = this.downloads[gid]; - if (downloadItem) { - return { - completedLength: downloadItem.getReceivedBytes(), - totalLength: downloadItem.getTotalBytes(), - downloadSpeed: downloadItem.getCurrentBytesPerSecond(), - folderName: downloadItem.getFilename(), - }; - } - - return null; + public getStatus() { + return { + completedLength: this.downloadItem.getReceivedBytes(), + totalLength: this.downloadItem.getTotalBytes(), + downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(), + folderName: this.downloadItem.getFilename(), + }; } - static async cancelDownload(gid: string) { - const downloadItem = this.downloads[gid]; - downloadItem?.cancel(); - delete this.downloads[gid]; + async cancelDownload() { + this.downloadItem.cancel(); } - static async pauseDownload(gid: string) { - const downloadItem = this.downloads[gid]; - downloadItem?.pause(); + async pauseDownload() { + this.downloadItem.pause(); } - static async resumeDownload(gid: string) { - const downloadItem = this.downloads[gid]; - downloadItem?.resume(); + async resumeDownload() { + this.downloadItem.resume(); } - static async startDownload( - downloadPath: string, - downloadUrl: string, - headers?: Record - ) { - return new Promise((resolve) => { - const options = headers ? { headers } : {}; - WindowManager.mainWindow?.webContents.downloadURL(downloadUrl, options); + async startDownload() { + return new Promise((resolve) => { + const options = this.headers ? { headers: this.headers } : {}; + WindowManager.mainWindow?.webContents.downloadURL( + this.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); } ); }); diff --git a/src/main/services/download/python-instance.ts b/src/main/services/download/python-instance.ts index c534e41d..37ec17db 100644 --- a/src/main/services/download/python-instance.ts +++ b/src/main/services/download/python-instance.ts @@ -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", { - action: "start", - game_id: game.id, - magnet: game.uri, - save_path: game.downloadPath, - } as StartDownloadPayload); + await this.rpc + .post("/action", { + action: "start", + game_id: game.id, + magnet: game.uri, + save_path: game.downloadPath, + } 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(); + }); + } } diff --git a/src/main/services/download/real-debrid-downloader.ts b/src/main/services/download/real-debrid-downloader.ts index c6925f57..2818644a 100644 --- a/src/main/services/download/real-debrid-downloader.ts +++ b/src/main/services/download/real-debrid-downloader.ts @@ -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(); - 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); } } } diff --git a/src/main/services/download/torrent-client.ts b/src/main/services/download/torrent-client.ts index 93d20b7f..2a16acad 100644 --- a/src/main/services/download/torrent-client.ts +++ b/src/main/services/download/torrent-client.ts @@ -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> = { 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; } }; diff --git a/src/main/services/hosters/gofile.ts b/src/main/services/hosters/gofile.ts index 770bb15f..2c23556f 100644 --- a/src/main/services/hosters/gofile.ts +++ b/src/main/services/hosters/gofile.ts @@ -16,6 +16,8 @@ export interface GofileContentsResponse { children: Record; } +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<{ diff --git a/src/main/services/hosters/index.ts b/src/main/services/hosters/index.ts index 921c45b1..4c5b1803 100644 --- a/src/main/services/hosters/index.ts +++ b/src/main/services/hosters/index.ts @@ -1 +1,2 @@ export * from "./gofile"; +export * from "./qiwi"; diff --git a/src/main/services/hosters/qiwi.ts b/src/main/services/hosters/qiwi.ts new file mode 100644 index 00000000..e18b011c --- /dev/null +++ b/src/main/services/hosters/qiwi.ts @@ -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; + } +} diff --git a/src/main/services/how-long-to-beat.ts b/src/main/services/how-long-to-beat.ts index 39b938c5..67e96942 100644 --- a/src/main/services/how-long-to-beat.ts +++ b/src/main/services/how-long-to-beat.ts @@ -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 => { - 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 []; diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts index b66a1897..6699788c 100644 --- a/src/main/services/library-sync/create-game.ts +++ b/src/main/services/library-sync/create-game.ts @@ -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, diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index 2a6b5bb5..2b3f51b3 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -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({ diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts index 39206a12..5cfc4103 100644 --- a/src/main/services/library-sync/update-game-playtime.ts +++ b/src/main/services/library-sync/update-game-playtime.ts @@ -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(() => {}); diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index 88f02375..22dc595e 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -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, diff --git a/src/main/services/logger.ts b/src/main/services/logger.ts index 8da27a9e..1eb7060b 100644 --- a/src/main/services/logger.ts +++ b/src/main/services/logger.ts @@ -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"); diff --git a/src/main/services/main-loop.ts b/src/main/services/main-loop.ts index ca72707f..f2ec51ba 100644 --- a/src/main/services/main-loop.ts +++ b/src/main/services/main-loop.ts @@ -10,6 +10,6 @@ export const startMainLoop = async () => { DownloadManager.watchDownloads(), ]); - await sleep(500); + await sleep(1000); } }; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 0f7efa62..080f1efc 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -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,48 +34,17 @@ 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); - - if (game.remoteId) { - updateGamePlaytime( - game, - performance.now() - gamePlaytime.firstTick, - game.lastTimePlayed! - ); - } else { - createGame(game); - } + onCloseGame(game); } } + currentTick++; + if (WindowManager.mainWindow) { const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => { 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); + } +}; diff --git a/src/main/services/repacks-manager.ts b/src/main/services/repacks-manager.ts index 02821127..93157d6c 100644 --- a/src/main/services/repacks-manager.ts +++ b/src/main/services/repacks-manager.ts @@ -8,11 +8,25 @@ export class RepacksManager { private static repacksIndex = new flexSearch.Index(); public static async updateRepacks() { - this.repacks = await repackRepository.find({ - order: { - createdAt: "DESC", - }, - }); + 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); diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 201b13ad..025e7219 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -158,7 +158,7 @@ export class WindowManager { const recentlyPlayedGames: Array = games.map(({ title, executablePath }) => ({ - label: title, + label: title.length > 15 ? `${title.slice(0, 15)}…` : title, type: "normal", click: async () => { if (!executablePath) return; diff --git a/src/preload/index.ts b/src/preload/index.ts index 3350a340..087d573a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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"), diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 8c6f7604..2b9ac187 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -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) => { diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index 81736e37..069831cb 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -78,7 +78,7 @@ export function SidebarProfile() { )} - {userDetails && gameRunning && ( + {userDetails && gameRunning?.iconUrl && ( {gameRunning.title} Promise; + getUserBlocks: (take: number, skip: number) => Promise; /* Profile */ getMe: () => Promise; undoFriendship: (userId: string) => Promise; - updateProfile: ( - displayName: string, - newProfileImagePath: string | null - ) => Promise; + updateProfile: (updateProfile: UpdateProfileProps) => Promise; getFriendRequests: () => Promise; updateFriendRequest: ( userId: string, diff --git a/src/renderer/src/hooks/use-download.ts b/src/renderer/src/hooks/use-download.ts index f58a8765..07c885cf 100644 --- a/src/renderer/src/hooks/use-download.ts +++ b/src/renderer/src/hooks/use-download.ts @@ -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; diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 21690e7e..0cf2a381 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -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(() => { diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index 5f32965a..5ac9673f 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -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(null); @@ -70,6 +70,9 @@ export function GameDetails() { } }; + const selectRepackUri = (repack: GameRepack, downloader: Downloader) => + repack.uris.find((uri) => getDownloadersForUri(uri).includes(downloader))!; + return ( @@ -96,6 +99,7 @@ export function GameDetails() { downloader, shop: shop as GameShop, downloadPath, + uri: selectRepackUri(repack, downloader), }); await updateGame(); diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts b/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts index d5655d94..5450378c 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts @@ -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({ diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index dff73ea0..3450af24 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -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) { diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index f9d351af..0d1b9c1d 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -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 ( <> {filteredRepacks.map((repack) => { - const isLastDownloadedOption = - infoHash !== null && - repack.magnet.toLowerCase().includes(infoHash); + const isLastDownloadedOption = checkIfLastDownloadedOption(repack); return ( + ); + } + return null; }; + if (type === "BLOCKED") { + return ( +
+
+
+ {profileImageUrl ? ( + {displayName} + ) : ( + + )} +
+
+

{displayName}

+
+
+ +
+ {getRequestActions()} +
+
+ ); + } + return ( -
+
- ); - })} - + <> +
+

Seu código de amigo:

+ +
+
+ {tabs.map((tab, index) => { + return ( + + ); + })} +
+ )} {renderTab()}
diff --git a/src/renderer/src/pages/user/user-content.tsx b/src/renderer/src/pages/user/user-content.tsx index 7425f102..c334389e 100644 --- a/src/renderer/src/pages/user/user-content.tsx +++ b/src/renderer/src/pages/user/user-content.tsx @@ -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(); - const [showEditProfileModal, setShowEditProfileModal] = useState(false); + const [showProfileSettingsModal, setShowProfileSettingsModal] = + useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false); const [showUserBlockModal, setShowUserBlockModal] = useState(false); + const [currentGame, setCurrentGame] = useState(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 ( <> - ))} -
- )} - - -
+ {showProfileContent && ( +
-
-

{t("library")}

+

{t("activity")}

+ {!userProfile.recentGames.length ? ( +
+
+ +
+

{t("no_recent_activity_title")}

+ {isMe &&

{t("no_recent_activity_description")}

} +
+ ) : (
-

- {userProfile.libraryGames.length} -

-
- {t("total_play_time", { amount: formatPlayTime() })} -
- {userProfile.libraryGames.map((game) => ( - - ))} -
+
+

{game.title}

+ + {t("last_time_played", { + period: formatDistance( + game.lastTimePlayed!, + new Date(), + { + addSuffix: true, + } + ), + })} + +
+ + ))} +
+ )}
- {showFriends && ( -
- - +
+ + {t("total_play_time", { amount: formatPlayTime() })} +
- {userProfile.friends.map((friend) => { - return ( - - ); - })} - - {isMe && ( - - )} + {game.iconUrl ? ( + {game.title} + ) : ( + + )} + + ))}
- )} + + {showFriends && ( +
+ + +
+ {userProfile.friends.map((friend) => { + return ( + + ); + })} + + {isMe && ( + + )} +
+
+ )} +
- + )} ); } diff --git a/src/renderer/src/pages/user/user-edit-modal.tsx b/src/renderer/src/pages/user/user-edit-modal.tsx deleted file mode 100644 index a22650ee..00000000 --- a/src/renderer/src/pages/user/user-edit-modal.tsx +++ /dev/null @@ -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; -} - -export const UserEditProfileModal = ({ - userProfile, - visible, - onClose, - updateUserProfile, -}: UserEditProfileModalProps) => { - const { t } = useTranslation("user_profile"); - - const [displayName, setDisplayName] = useState(""); - const [newImagePath, setNewImagePath] = useState(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 = 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 ( - <> - -
- - - setDisplayName(e.target.value)} - /> - - -
- - ); -}; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/index.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/index.tsx new file mode 100644 index 00000000..896d3684 --- /dev/null +++ b/src/renderer/src/pages/user/user-profile-settings-modal/index.tsx @@ -0,0 +1 @@ +export * from "./user-profile-settings-modal"; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/user-block-list.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/user-block-list.tsx new file mode 100644 index 00000000..0790b725 --- /dev/null +++ b/src/renderer/src/pages/user/user-profile-settings-modal/user-block-list.tsx @@ -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([]); + const listContainer = useRef(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 ( + +
+ {!isLoading && blocks.length === 0 &&

{t("no_blocked_users")}

} + {blocks.map((friend) => { + return ( + + ); + })} + {isLoading && ( + + )} +
+
+ ); +}; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/user-edit-profile.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/user-edit-profile.tsx new file mode 100644 index 00000000..f6a430ba --- /dev/null +++ b/src/renderer/src/pages/user/user-profile-settings-modal/user-edit-profile.tsx @@ -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; +} + +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 = 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 ( +
+ + + setForm({ ...form, displayName: e.target.value })} + /> + + ({ + key: visiblity.value, + value: visiblity.value, + label: visiblity.label, + }))} + /> + + + + ); +}; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/user-profile-settings-modal.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/user-profile-settings-modal.tsx new file mode 100644 index 00000000..d71b1bd7 --- /dev/null +++ b/src/renderer/src/pages/user/user-profile-settings-modal/user-profile-settings-modal.tsx @@ -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; +} + +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 ( + + ); + } + + if (currentTabIndex == 1) { + return ; + } + + return <>; + }; + + return ( + <> + +
+
+ {tabs.map((tab, index) => { + return ( + + ); + })} +
+ {renderTab()} +
+
+ + ); +}; diff --git a/src/renderer/src/pages/user/user.css.ts b/src/renderer/src/pages/user/user.css.ts index f9b1b09a..4e1c2139 100644 --- a/src/renderer/src/pages/user/user.css.ts +++ b/src/renderer/src/pages/user/user.css.ts @@ -60,6 +60,7 @@ export const friendListDisplayName = style({ }); export const profileAvatarEditContainer = style({ + alignSelf: "center", width: "128px", height: "128px", display: "flex", diff --git a/src/renderer/src/pages/user/user.tsx b/src/renderer/src/pages/user/user.tsx index 4c45f789..565d412a 100644 --- a/src/renderer/src/pages/user/user.tsx +++ b/src/renderer/src/pages/user/user.tsx @@ -31,7 +31,7 @@ export const User = () => { navigate(-1); } }); - }, [dispatch, userId, t]); + }, [dispatch, navigate, showErrorToast, userId, t]); useEffect(() => { getUserProfile(); diff --git a/src/shared/index.ts b/src/shared/index.ts index af4ac17d..28e7315b 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -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>((prev, next) => { + const downloaders = getDownloadersForUri(next); + downloaders.forEach((downloader) => prev.add(downloader)); + + return prev; + }, new Set()); + + return Array.from(downloadersSet); +}; diff --git a/src/types/index.ts b/src/types/index.ts index ac352a91..3260d274 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; 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 { diff --git a/torrent-client/main.py b/torrent-client/main.py index c25f95a4..a2ea190b 100644 --- a/torrent-client/main.py +++ b/torrent-client/main.py @@ -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: