diff --git a/.gitignore b/.gitignore index 7a6496a5..fb4badd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .vscode node_modules hydra-download-manager/ -aria2/ fastlist.exe __pycache__ dist diff --git a/electron-builder.yml b/electron-builder.yml index be300d36..cfdafe7d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,7 +3,6 @@ productName: Hydra directories: buildResources: build extraResources: - - aria2 - hydra-download-manager - seeds - from: node_modules/create-desktop-shortcuts/src/windows.vbs diff --git a/package.json b/package.json index 1b99734d..aa77084e 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "start": "electron-vite preview", "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", - "postinstall": "electron-builder install-app-deps && node ./postinstall.cjs", + "postinstall": "electron-builder install-app-deps", "build:unpack": "npm run build && electron-builder --dir", "build:win": "electron-vite build && electron-builder --win", "build:mac": "electron-vite build && electron-builder --mac", @@ -34,15 +34,13 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", - "@fontsource/fira-mono": "^5.0.13", - "@fontsource/fira-sans": "^5.0.20", + "@fontsource/noto-sans": "^5.0.22", "@primer/octicons-react": "^19.9.0", "@reduxjs/toolkit": "^2.2.3", "@sentry/electron": "^5.1.0", "@vanilla-extract/css": "^1.14.2", "@vanilla-extract/dynamic": "^2.1.1", "@vanilla-extract/recipes": "^0.5.2", - "aria2": "^4.1.2", "auto-launch": "^5.0.6", "axios": "^1.6.8", "better-sqlite3": "^9.5.0", @@ -97,7 +95,7 @@ "@types/user-agents": "^1.0.4", "@vanilla-extract/vite-plugin": "^4.0.7", "@vitejs/plugin-react": "^4.2.1", - "electron": "^30.0.9", + "electron": "^30.3.0", "electron-builder": "^24.9.1", "electron-vite": "^2.0.0", "eslint": "^8.56.0", diff --git a/postinstall.cjs b/postinstall.cjs deleted file mode 100644 index 547af988..00000000 --- a/postinstall.cjs +++ /dev/null @@ -1,50 +0,0 @@ -const { default: axios } = require("axios"); -const util = require("node:util"); -const fs = require("node:fs"); - -const exec = util.promisify(require("node:child_process").exec); - -const downloadAria2 = async () => { - if (fs.existsSync("aria2")) { - console.log("Aria2 already exists, skipping download..."); - return; - } - - const file = - process.platform === "win32" - ? "aria2-1.37.0-win-64bit-build1.zip" - : "aria2-1.37.0-1-x86_64.pkg.tar.zst"; - - const downloadUrl = - process.platform === "win32" - ? `https://github.com/aria2/aria2/releases/download/release-1.37.0/${file}` - : "https://archlinux.org/packages/extra/x86_64/aria2/download/"; - - console.log(`Downloading ${file}...`); - - const response = await axios.get(downloadUrl, { responseType: "stream" }); - - const stream = response.data.pipe(fs.createWriteStream(file)); - - stream.on("finish", async () => { - console.log(`Downloaded ${file}, extracting...`); - - if (process.platform === "win32") { - await exec(`npx extract-zip ${file}`); - console.log("Extracted. Renaming folder..."); - - fs.renameSync(file.replace(".zip", ""), "aria2"); - } else { - await exec(`tar --zstd -xvf ${file} usr/bin/aria2c`); - console.log("Extracted. Copying binary file..."); - fs.mkdirSync("aria2"); - fs.copyFileSync("usr/bin/aria2c", "aria2/aria2c"); - fs.rmSync("usr", { recursive: true }); - } - - console.log(`Extracted ${file}, removing compressed downloaded file...`); - fs.rmSync(file); - }); -}; - -downloadAria2(); 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/es/translation.json b/src/locales/es/translation.json index fcb2b099..0800b0c9 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -250,6 +250,17 @@ "friend_request_sent": "Solicitud de amistad enviada", "friends": "Amigos", "friends_list": "Lista de amigos", - "user_not_found": "Usuario no encontrado" + "user_not_found": "Usuario no encontrado", + "block_user": "Bloquear usuario", + "add_friend": "Añadir amigo", + "request_sent": "Solicitud enviada", + "request_received": "Solicitud recibida", + "accept_request": "Aceptar solicitud", + "ignore_request": "Ignorar solicitud", + "cancel_request": "Cancelar solicitud", + "undo_friendship": "Eliminar amistad", + "request_accepted": "Solicitud aceptada", + "user_blocked_successfully": "Usuario bloqueado exitosamente", + "user_block_modal_text": "Esto va a bloquear a {{displayName}}" } } 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..a88a8883 100644 --- a/src/main/data-source.ts +++ b/src/main/data-source.ts @@ -6,12 +6,12 @@ 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"; export const createDataSource = ( options: Partial diff --git a/src/main/declaration.d.ts b/src/main/declaration.d.ts deleted file mode 100644 index ac2675a3..00000000 --- a/src/main/declaration.d.ts +++ /dev/null @@ -1,80 +0,0 @@ -declare module "aria2" { - export type Aria2Status = - | "active" - | "waiting" - | "paused" - | "error" - | "complete" - | "removed"; - - export interface StatusResponse { - gid: string; - status: Aria2Status; - totalLength: string; - completedLength: string; - uploadLength: string; - bitfield: string; - downloadSpeed: string; - uploadSpeed: string; - infoHash?: string; - numSeeders?: string; - seeder?: boolean; - pieceLength: string; - numPieces: string; - connections: string; - errorCode?: string; - errorMessage?: string; - followedBy?: string[]; - following: string; - belongsTo: string; - dir: string; - files: { - path: string; - length: string; - completedLength: string; - selected: string; - }[]; - bittorrent?: { - announceList: string[][]; - comment: string; - creationDate: string; - mode: "single" | "multi"; - info: { - name: string; - verifiedLength: string; - verifyIntegrityPending: string; - }; - }; - } - - export default class Aria2 { - constructor(options: any); - open: () => Promise; - call( - method: "addUri", - uris: string[], - options: { dir: string } - ): Promise; - call( - method: "tellStatus", - gid: string, - keys?: string[] - ): Promise; - call(method: "pause", gid: string): Promise; - call(method: "forcePause", gid: string): Promise; - call(method: "unpause", gid: string): Promise; - call(method: "remove", gid: string): Promise; - call(method: "forceRemove", gid: string): Promise; - call(method: "pauseAll"): Promise; - call(method: "forcePauseAll"): Promise; - listNotifications: () => [ - "onDownloadStart", - "onDownloadPause", - "onDownloadStop", - "onDownloadComplete", - "onDownloadError", - "onBtDownloadComplete", - ]; - on: (event: string, callback: (params: any) => void) => void; - } -} 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/auth/sign-out.ts b/src/main/events/auth/sign-out.ts index fe640b9d..9998c733 100644 --- a/src/main/events/auth/sign-out.ts +++ b/src/main/events/auth/sign-out.ts @@ -26,6 +26,8 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => { /* Disconnects libtorrent */ PythonInstance.killTorrent(); + HydraApi.handleSignOut(); + await Promise.all([ databaseOperations, HydraApi.post("/auth/logout").catch(() => {}), 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/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/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/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/main.ts b/src/main/main.ts index fbabc56c..af594e20 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -22,8 +22,9 @@ const loadState = async (userPreferences: UserPreferences | null) => { import("./events"); - if (userPreferences?.realDebridApiToken) + if (userPreferences?.realDebridApiToken) { RealDebridClient.authorize(userPreferences?.realDebridApiToken); + } HydraApi.setupApi().then(() => { uploadGamesBatch(); diff --git a/src/main/services/aria2c.ts b/src/main/services/aria2c.ts deleted file mode 100644 index b1b1da76..00000000 --- a/src/main/services/aria2c.ts +++ /dev/null @@ -1,20 +0,0 @@ -import path from "node:path"; -import { spawn } from "node:child_process"; -import { app } from "electron"; - -export const startAria2 = () => { - const binaryPath = app.isPackaged - ? path.join(process.resourcesPath, "aria2", "aria2c") - : path.join(__dirname, "..", "..", "aria2", "aria2c"); - - return spawn( - binaryPath, - [ - "--enable-rpc", - "--rpc-listen-all", - "--file-allocation=none", - "--allow-overwrite=true", - ], - { stdio: "inherit", windowsHide: true } - ); -}; diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 31f28992..d4733a32 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -6,6 +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, QiwiApi } from "../hosters"; +import { GenericHttpDownloader } from "./generic-http-downloader"; export class DownloadManager { private static currentDownloader: Downloader | null = null; @@ -13,10 +15,12 @@ export class DownloadManager { public static async watchDownloads() { let status: DownloadProgress | null = null; - if (this.currentDownloader === Downloader.RealDebrid) { + if (this.currentDownloader === Downloader.Torrent) { + status = await PythonInstance.getStatus(); + } else if (this.currentDownloader === Downloader.RealDebrid) { status = await RealDebridDownloader.getStatus(); } else { - status = await PythonInstance.getStatus(); + status = await GenericHttpDownloader.getStatus(); } if (status) { @@ -62,10 +66,12 @@ export class DownloadManager { } static async pauseDownload() { - if (this.currentDownloader === Downloader.RealDebrid) { + if (this.currentDownloader === Downloader.Torrent) { + await PythonInstance.pauseDownload(); + } else if (this.currentDownloader === Downloader.RealDebrid) { await RealDebridDownloader.pauseDownload(); } else { - await PythonInstance.pauseDownload(); + await GenericHttpDownloader.pauseDownload(); } WindowManager.mainWindow?.setProgressBar(-1); @@ -73,20 +79,16 @@ export class DownloadManager { } static async resumeDownload(game: Game) { - if (game.downloader === Downloader.RealDebrid) { - RealDebridDownloader.startDownload(game); - this.currentDownloader = Downloader.RealDebrid; - } else { - PythonInstance.startDownload(game); - this.currentDownloader = Downloader.Torrent; - } + return this.startDownload(game); } static async cancelDownload(gameId: number) { - if (this.currentDownloader === Downloader.RealDebrid) { + if (this.currentDownloader === Downloader.Torrent) { + PythonInstance.cancelDownload(gameId); + } else if (this.currentDownloader === Downloader.RealDebrid) { RealDebridDownloader.cancelDownload(gameId); } else { - PythonInstance.cancelDownload(gameId); + GenericHttpDownloader.cancelDownload(gameId); } WindowManager.mainWindow?.setProgressBar(-1); @@ -94,12 +96,40 @@ export class DownloadManager { } static async startDownload(game: Game) { - if (game.downloader === Downloader.RealDebrid) { - RealDebridDownloader.startDownload(game); - this.currentDownloader = Downloader.RealDebrid; - } else { - PythonInstance.startDownload(game); - this.currentDownloader = Downloader.Torrent; + 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, { + Cookie: `accountToken=${token}`, + }); + break; + } + case Downloader.PixelDrain: { + const id = game!.uri!.split("/").pop(); + + 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 new file mode 100644 index 00000000..055c8561 --- /dev/null +++ b/src/main/services/download/generic-http-downloader.ts @@ -0,0 +1,109 @@ +import { Game } from "@main/entity"; +import { gameRepository } from "@main/repository"; +import { calculateETA } from "./helpers"; +import { DownloadProgress } from "@types"; +import { HttpDownload } from "./http-download"; + +export class GenericHttpDownloader { + public static downloads = new Map(); + public static downloadingGame: Game | null = null; + + public static async getStatus() { + if (this.downloadingGame) { + const download = this.downloads.get(this.downloadingGame.id)!; + const status = download.getStatus(); + + 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: status.downloadSpeed, + timeRemaining: calculateETA( + status.totalLength, + status.completedLength, + status.downloadSpeed + ), + isDownloadingMetadata: false, + isCheckingFiles: false, + progress, + gameId: this.downloadingGame!.id, + } as DownloadProgress; + + if (progress === 1) { + this.downloads.delete(this.downloadingGame.id); + this.downloadingGame = null; + } + + return result; + } + } + + return null; + } + + static async pauseDownload() { + if (this.downloadingGame) { + const httpDownload = this.downloads.get(this.downloadingGame!.id!); + + if (httpDownload) { + await httpDownload.pauseDownload(); + } + + this.downloadingGame = null; + } + } + + static async startDownload( + game: Game, + downloadUrl: string, + headers?: Record + ) { + this.downloadingGame = game; + + if (this.downloads.has(game.id)) { + await this.resumeDownload(game.id!); + return; + } + + const httpDownload = new HttpDownload( + game.downloadPath!, + downloadUrl, + headers + ); + + httpDownload.startDownload(); + + this.downloads.set(game.id!, httpDownload); + } + + static async cancelDownload(gameId: number) { + const httpDownload = this.downloads.get(gameId); + + if (httpDownload) { + await httpDownload.cancelDownload(); + this.downloads.delete(gameId); + } + } + + static async resumeDownload(gameId: number) { + const httpDownload = this.downloads.get(gameId); + + if (httpDownload) { + await httpDownload.resumeDownload(); + } + } +} diff --git a/src/main/services/download/http-download.ts b/src/main/services/download/http-download.ts index 4553a6cb..4f6c31a9 100644 --- a/src/main/services/download/http-download.ts +++ b/src/main/services/download/http-download.ts @@ -1,68 +1,54 @@ -import type { ChildProcess } from "node:child_process"; -import { logger } from "../logger"; -import { sleep } from "@main/helpers"; -import { startAria2 } from "../aria2c"; -import Aria2 from "aria2"; +import { WindowManager } from "../window-manager"; +import path from "node:path"; export class HttpDownload { - private static connected = false; - private static aria2c: ChildProcess | null = null; + private downloadItem: Electron.DownloadItem; - private static aria2 = new Aria2({}); + constructor( + private downloadPath: string, + private downloadUrl: string, + private headers?: Record + ) {} - private static async connect() { - this.aria2c = startAria2(); - - let retries = 0; - - while (retries < 4 && !this.connected) { - try { - await this.aria2.open(); - logger.log("Connected to aria2"); - - this.connected = true; - } catch (err) { - await sleep(100); - logger.log("Failed to connect to aria2, retrying..."); - retries++; - } - } - } - - public static getStatus(gid: string) { - if (this.connected) { - return this.aria2.call("tellStatus", gid); - } - - return null; - } - - public static disconnect() { - if (this.aria2c) { - this.aria2c.kill(); - this.connected = false; - } - } - - static async cancelDownload(gid: string) { - await this.aria2.call("forceRemove", gid); - } - - static async pauseDownload(gid: string) { - await this.aria2.call("forcePause", gid); - } - - static async resumeDownload(gid: string) { - await this.aria2.call("unpause", gid); - } - - static async startDownload(downloadPath: string, downloadUrl: string) { - if (!this.connected) await this.connect(); - - const options = { - dir: downloadPath, + public getStatus() { + return { + completedLength: this.downloadItem.getReceivedBytes(), + totalLength: this.downloadItem.getTotalBytes(), + downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(), + folderName: this.downloadItem.getFilename(), }; + } - return this.aria2.call("addUri", [downloadUrl], options); + async cancelDownload() { + this.downloadItem.cancel(); + } + + async pauseDownload() { + this.downloadItem.pause(); + } + + async resumeDownload() { + this.downloadItem.resume(); + } + + async startDownload() { + return new Promise((resolve) => { + const options = this.headers ? { headers: this.headers } : {}; + WindowManager.mainWindow?.webContents.downloadURL( + this.downloadUrl, + options + ); + + WindowManager.mainWindow?.webContents.session.once( + "will-download", + (_event, item, _webContents) => { + this.downloadItem = item; + + item.setSavePath(path.join(this.downloadPath, item.getFilename())); + + resolve(null); + } + ); + }); } } diff --git a/src/main/services/download/real-debrid-downloader.ts b/src/main/services/download/real-debrid-downloader.ts index 8ead0067..2818644a 100644 --- a/src/main/services/download/real-debrid-downloader.ts +++ b/src/main/services/download/real-debrid-downloader.ts @@ -1,162 +1,72 @@ 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() { if (this.realDebridTorrentId) { - const torrentInfo = await RealDebridClient.getTorrentInfo( + let torrentInfo = await RealDebridClient.getTorrentInfo( this.realDebridTorrentId ); - const { status, links } = torrentInfo; - - if (status === "waiting_files_selection") { + if (torrentInfo.status === "waiting_files_selection") { await RealDebridClient.selectAllFiles(this.realDebridTorrentId); - return null; + + torrentInfo = await RealDebridClient.getTorrentInfo( + this.realDebridTorrentId + ); } + const { links, status } = torrentInfo; + if (status === "downloaded") { const [link] = links; + const { download } = await RealDebridClient.unrestrictLink(link); return decodeURIComponent(download); } + + return null; } - return null; - } - - public static async getStatus() { - if (this.downloadingGame) { - const gid = this.downloads.get(this.downloadingGame.id)!; - const status = await 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", - } - ); - - 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; - } - } - - if (this.realDebridTorrentId && this.downloadingGame) { - const torrentInfo = await RealDebridClient.getTorrentInfo( - this.realDebridTorrentId + if (this.downloadingGame?.uri) { + const { download } = await RealDebridClient.unrestrictLink( + this.downloadingGame?.uri ); - const { status } = torrentInfo; - - if (status === "downloaded") { - this.startDownload(this.downloadingGame); - } - - const progress = torrentInfo.progress / 100; - const totalDownloaded = progress * torrentInfo.bytes; - - return { - numPeers: 0, - numSeeds: torrentInfo.seeders, - downloadSpeed: torrentInfo.speed, - timeRemaining: calculateETA( - torrentInfo.bytes, - totalDownloaded, - torrentInfo.speed - ), - isDownloadingMetadata: status === "magnet_conversion", - } as DownloadProgress; + return decodeURIComponent(download); } return null; } - static async pauseDownload() { - 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) { - this.downloadingGame = game; - if (this.downloads.has(game.id)) { await this.resumeDownload(game.id!); - + this.downloadingGame = game; return; } - this.realDebridTorrentId = await RealDebridClient.getTorrentId(game!.uri!); + if (game.uri?.startsWith("magnet:")) { + this.realDebridTorrentId = await RealDebridClient.getTorrentId( + game!.uri! + ); + } + + this.downloadingGame = game; const downloadUrl = await this.getRealDebridDownloadUrl(); 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); - } - } - - 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/hosters/gofile.ts b/src/main/services/hosters/gofile.ts new file mode 100644 index 00000000..2c23556f --- /dev/null +++ b/src/main/services/hosters/gofile.ts @@ -0,0 +1,63 @@ +import axios from "axios"; + +export interface GofileAccountsReponse { + id: string; + token: string; +} + +export interface GofileContentChild { + id: string; + link: string; +} + +export interface GofileContentsResponse { + id: string; + type: string; + children: Record; +} + +export const WT = "4fd6sg89d7s6"; + +export class GofileApi { + private static token: string; + + public static async authorize() { + const response = await axios.post<{ + status: string; + data: GofileAccountsReponse; + }>("https://api.gofile.io/accounts"); + + if (response.data.status === "ok") { + this.token = response.data.data.token; + return this.token; + } + + throw new Error("Failed to authorize"); + } + + public static async getDownloadLink(id: string) { + const searchParams = new URLSearchParams({ + wt: WT, + }); + + const response = await axios.get<{ + status: string; + data: GofileContentsResponse; + }>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, { + headers: { + Authorization: `Bearer ${this.token}`, + }, + }); + + if (response.data.status === "ok") { + if (response.data.data.type !== "folder") { + throw new Error("Only folders are supported"); + } + + const [firstChild] = Object.values(response.data.data.children); + return firstChild.link; + } + + throw new Error("Failed to get download link"); + } +} diff --git a/src/main/services/hosters/index.ts b/src/main/services/hosters/index.ts new file mode 100644 index 00000000..4c5b1803 --- /dev/null +++ b/src/main/services/hosters/index.ts @@ -0,0 +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/hydra-api.ts b/src/main/services/hydra-api.ts index 5365bd9e..6f0e1905 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -64,59 +64,67 @@ export class HydraApi { } } + static handleSignOut() { + this.userAuth = { + authToken: "", + refreshToken: "", + expirationTimestamp: 0, + }; + } + static async setupApi() { this.instance = axios.create({ baseURL: import.meta.env.MAIN_VITE_API_URL, }); - this.instance.interceptors.request.use( - (request) => { - logger.log(" ---- REQUEST -----"); - logger.log(request.method, request.url, request.params, request.data); - return request; - }, - (error) => { - logger.error("request error", error); - return Promise.reject(error); - } - ); + // this.instance.interceptors.request.use( + // (request) => { + // logger.log(" ---- REQUEST -----"); + // logger.log(request.method, request.url, request.params, request.data); + // return request; + // }, + // (error) => { + // logger.error("request error", error); + // return Promise.reject(error); + // } + // ); - this.instance.interceptors.response.use( - (response) => { - logger.log(" ---- RESPONSE -----"); - logger.log( - response.status, - response.config.method, - response.config.url, - response.data - ); - return response; - }, - (error) => { - logger.error(" ---- RESPONSE ERROR -----"); + // this.instance.interceptors.response.use( + // (response) => { + // logger.log(" ---- RESPONSE -----"); + // logger.log( + // response.status, + // response.config.method, + // response.config.url, + // response.data + // ); + // return response; + // }, + // (error) => { + // logger.error(" ---- RESPONSE ERROR -----"); - const { config } = error; + // const { config } = error; - logger.error( - config.method, - config.baseURL, - config.url, - config.headers, - config.data - ); + // logger.error( + // config.method, + // config.baseURL, + // config.url, + // config.headers, + // config.data + // ); - if (error.response) { - logger.error("Response", error.response.status, error.response.data); - } else if (error.request) { - logger.error("Request", error.request); - } else { - logger.error("Error", error.message); - } + // if (error.response) { + // logger.error("Response", error.response.status, error.response.data); + // } else if (error.request) { + // logger.error("Request", error.request); + // } else { + // logger.error("Error", error.message); + // } - logger.error(" ----- END RESPONSE ERROR -------"); - return Promise.reject(error); - } - ); + // logger.error(" ----- END RESPONSE ERROR -------"); + // return Promise.reject(error); + // } + // ); const userAuth = await userAuthRepository.findOne({ where: { id: 1 }, diff --git a/src/main/services/real-debrid.ts b/src/main/services/real-debrid.ts index 2e0debe6..26ba4c79 100644 --- a/src/main/services/real-debrid.ts +++ b/src/main/services/real-debrid.ts @@ -46,7 +46,7 @@ export class RealDebridClient { static async selectAllFiles(id: string) { const searchParams = new URLSearchParams({ files: "all" }); - await this.instance.post( + return this.instance.post( `/torrents/selectFiles/${id}`, searchParams.toString() ); 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/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.css.ts b/src/renderer/src/app.css.ts index a5f9394b..c829021a 100644 --- a/src/renderer/src/app.css.ts +++ b/src/renderer/src/app.css.ts @@ -26,7 +26,7 @@ globalStyle("html, body, #root, main", { globalStyle("body", { overflow: "hidden", userSelect: "none", - fontFamily: "'Fira Mono', monospace", + fontFamily: "Noto Sans, sans-serif", fontSize: vars.size.body, background: vars.color.background, color: vars.color.body, 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/hero/hero.css.ts b/src/renderer/src/components/hero/hero.css.ts index cdb36ee2..eaf0a101 100644 --- a/src/renderer/src/components/hero/hero.css.ts +++ b/src/renderer/src/components/hero/hero.css.ts @@ -45,7 +45,6 @@ export const description = style({ maxWidth: "700px", color: vars.color.muted, textAlign: "left", - fontFamily: "'Fira Sans', sans-serif", lineHeight: "20px", marginTop: `${SPACING_UNIT * 2}px`, }); diff --git a/src/renderer/src/components/modal/modal.css.ts b/src/renderer/src/components/modal/modal.css.ts index 45154015..d9d14fda 100644 --- a/src/renderer/src/components/modal/modal.css.ts +++ b/src/renderer/src/components/modal/modal.css.ts @@ -24,6 +24,7 @@ export const modal = recipe({ animation: `${scaleFadeIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`, backgroundColor: vars.color.background, borderRadius: "4px", + minWidth: "400px", maxWidth: "600px", color: vars.color.body, maxHeight: "100%", diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index 6186bb85..63368c88 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -5,4 +5,7 @@ export const VERSION_CODENAME = "Leviticus"; export const DOWNLOADER_NAME = { [Downloader.RealDebrid]: "Real-Debrid", [Downloader.Torrent]: "Torrent", + [Downloader.Gofile]: "Gofile", + [Downloader.PixelDrain]: "PixelDrain", + [Downloader.Qiwi]: "Qiwi", }; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index e022cffe..29e4dcbb 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -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; + 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/main.tsx b/src/renderer/src/main.tsx index f87d66bf..b88348f0 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -8,12 +8,10 @@ import { HashRouter, Route, Routes } from "react-router-dom"; import * as Sentry from "@sentry/electron/renderer"; -import "@fontsource/fira-mono/400.css"; -import "@fontsource/fira-mono/500.css"; -import "@fontsource/fira-mono/700.css"; -import "@fontsource/fira-sans/400.css"; -import "@fontsource/fira-sans/500.css"; -import "@fontsource/fira-sans/700.css"; +import "@fontsource/noto-sans/400.css"; +import "@fontsource/noto-sans/500.css"; +import "@fontsource/noto-sans/700.css"; + import "react-loading-skeleton/dist/skeleton.css"; import { App } from "./app"; diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index 531bc526..5a9c228c 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -132,9 +132,7 @@ export function Downloads() {

{t("no_downloads_title")}

-

- {t("no_downloads_description")} -

+

{t("no_downloads_description")}

)} diff --git a/src/renderer/src/pages/game-details/description-header/description-header.tsx b/src/renderer/src/pages/game-details/description-header/description-header.tsx index e4272534..cd73c52a 100644 --- a/src/renderer/src/pages/game-details/description-header/description-header.tsx +++ b/src/renderer/src/pages/game-details/description-header/description-header.tsx @@ -19,7 +19,10 @@ export function DescriptionHeader() { date: shopDetails?.release_date.date, })}

-

{t("publisher", { publisher: shopDetails.publishers[0] })}

+ + {Array.isArray(shopDetails.publishers) && ( +

{t("publisher", { publisher: shopDetails.publishers[0] })}

+ )} ); diff --git a/src/renderer/src/pages/game-details/game-details.css.ts b/src/renderer/src/pages/game-details/game-details.css.ts index f0bbfd2e..3dc0ec94 100644 --- a/src/renderer/src/pages/game-details/game-details.css.ts +++ b/src/renderer/src/pages/game-details/game-details.css.ts @@ -101,7 +101,6 @@ export const descriptionContent = style({ export const description = style({ userSelect: "text", lineHeight: "22px", - fontFamily: "'Fira Sans', sans-serif", fontSize: "16px", padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`, "@media": { 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/hero/hero-panel.css.ts b/src/renderer/src/pages/game-details/hero/hero-panel.css.ts index e10d55a5..c379c1c3 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.css.ts +++ b/src/renderer/src/pages/game-details/hero/hero-panel.css.ts @@ -9,6 +9,7 @@ export const panel = recipe({ height: "72px", minHeight: "72px", padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`, + backgroundColor: vars.color.darkBackground, display: "flex", alignItems: "center", justifyContent: "space-between", 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 ef4ba040..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 @@ -1,11 +1,11 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; 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 } from "@shared"; +import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; import type { GameRepack } from "@types"; import { SPACING_UNIT } from "@renderer/theme.css"; @@ -23,8 +23,6 @@ export interface DownloadSettingsModalProps { repack: GameRepack | null; } -const downloaders = [Downloader.Torrent, Downloader.RealDebrid]; - export function DownloadSettingsModal({ visible, onClose, @@ -36,9 +34,8 @@ export function DownloadSettingsModal({ const [diskFreeSpace, setDiskFreeSpace] = useState(null); const [selectedPath, setSelectedPath] = useState(""); const [downloadStarting, setDownloadStarting] = useState(false); - const [selectedDownloader, setSelectedDownloader] = useState( - Downloader.Torrent - ); + const [selectedDownloader, setSelectedDownloader] = + useState(null); const userPreferences = useAppSelector( (state) => state.userPreferences.value @@ -50,6 +47,10 @@ export function DownloadSettingsModal({ } }, [visible, selectedPath]); + const downloaders = useMemo(() => { + return getDownloadersForUris(repack?.uris ?? []); + }, [repack?.uris]); + useEffect(() => { if (userPreferences?.downloadsPath) { setSelectedPath(userPreferences.downloadsPath); @@ -59,9 +60,27 @@ export function DownloadSettingsModal({ .then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath)); } - if (userPreferences?.realDebridApiToken) - setSelectedDownloader(Downloader.RealDebrid); - }, [userPreferences?.downloadsPath, userPreferences?.realDebridApiToken]); + const filteredDownloaders = downloaders.filter((downloader) => { + if (downloader === Downloader.RealDebrid) + return userPreferences?.realDebridApiToken; + return true; + }); + + /* Gives preference to Real Debrid */ + const selectedDownloader = filteredDownloaders.includes( + Downloader.RealDebrid + ) + ? Downloader.RealDebrid + : filteredDownloaders[0]; + + setSelectedDownloader( + selectedDownloader === undefined ? null : selectedDownloader + ); + }, [ + userPreferences?.downloadsPath, + downloaders, + userPreferences?.realDebridApiToken, + ]); const getDiskFreeSpace = (path: string) => { window.electron.getDiskFreeSpace(path).then((result) => { @@ -85,7 +104,7 @@ export function DownloadSettingsModal({ if (repack) { setDownloadStarting(true); - startDownload(repack, selectedDownloader, selectedPath).finally(() => { + startDownload(repack, selectedDownloader!, selectedPath).finally(() => { setDownloadStarting(false); onClose(); }); @@ -167,7 +186,10 @@ export function DownloadSettingsModal({

- diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.css.ts b/src/renderer/src/pages/game-details/modals/game-options-modal.css.ts index 8bf0ae7f..f844a686 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.css.ts +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.css.ts @@ -15,7 +15,6 @@ export const gameOptionHeader = style({ }); export const gameOptionHeaderDescription = style({ - fontFamily: "'Fira Sans', sans-serif", fontWeight: "400", }); 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 d2d8f5d9..0d1b9c1d 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -44,8 +44,10 @@ export function RepacksModal({ }, [repacks]); const getInfoHash = useCallback(async () => { - const torrent = await parseTorrent(game?.uri ?? ""); - if (torrent.infoHash) setInfoHash(torrent.infoHash); + if (game?.uri?.startsWith("magnet:")) { + const torrent = await parseTorrent(game?.uri ?? ""); + if (torrent.infoHash) setInfoHash(torrent.infoHash); + } }, [game]); useEffect(() => { @@ -74,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-block-modal.tsx b/src/renderer/src/pages/user/user-block-modal.tsx index e179e4da..311eb060 100644 --- a/src/renderer/src/pages/user/user-block-modal.tsx +++ b/src/renderer/src/pages/user/user-block-modal.tsx @@ -25,9 +25,7 @@ export const UserBlockModal = ({ onClose={onClose} >
-

- {t("user_block_modal_text", { displayName })} -

+

{t("user_block_modal_text", { displayName })}

-
-
-

{t("activity")}

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

{t("no_recent_activity_title")}

- {isMe && ( -

- {t("no_recent_activity_description")} -

- )} -
- ) : ( -
- {userProfile.recentGames.map((game) => ( - - ))} -
- )} -
- -
+ {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..c062eabb --- /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(() => { + listContainer.current?.addEventListener("scroll", handleScroll); + return () => + listContainer.current?.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-sign-out-modal.tsx b/src/renderer/src/pages/user/user-sign-out-modal.tsx index b9565a1d..afc5561b 100644 --- a/src/renderer/src/pages/user/user-sign-out-modal.tsx +++ b/src/renderer/src/pages/user/user-sign-out-modal.tsx @@ -23,7 +23,7 @@ export const UserSignOutModal = ({ onClose={onClose} >
-

{t("sign_out_modal_text")}

+

{t("sign_out_modal_text")}