diff --git a/package.json b/package.json index 89fb8c42..09fe2cff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "1.2.4", + "version": "2.0.0", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts index 49ad2716..b8607b73 100644 --- a/src/main/entity/game.entity.ts +++ b/src/main/entity/game.entity.ts @@ -22,6 +22,9 @@ export class Game { @Column("text", { unique: true }) objectID: string; + @Column("text", { unique: true, nullable: true }) + remoteId: string | null; + @Column("text") title: string; diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 1c7447e5..8187d41e 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -6,6 +6,7 @@ import type { GameShop } from "@types"; import { getFileBase64, getSteamAppAsset } from "@main/helpers"; import { steamGamesWorker } from "@main/workers"; +import { createGame } from "@main/services/library-sync"; const addGameToLibrary = async ( _event: Electron.IpcMainInvokeEvent, @@ -49,6 +50,21 @@ const addGameToLibrary = async ( } }); } + + const game = await gameRepository.findOne({ where: { objectID } }); + + createGame(game!).then((response) => { + const { + id: remoteId, + playTimeInMilliseconds, + lastTimePlayed, + } = response.data; + + gameRepository.update( + { objectID }, + { remoteId, playTimeInMilliseconds, lastTimePlayed } + ); + }); }); }; diff --git a/src/main/events/library/remove-game-from-library.ts b/src/main/events/library/remove-game-from-library.ts index 29a7a635..5d154aae 100644 --- a/src/main/events/library/remove-game-from-library.ts +++ b/src/main/events/library/remove-game-from-library.ts @@ -1,5 +1,7 @@ import { registerEvent } from "../register-event"; import { gameRepository } from "../../repository"; +import { HydraApi } from "@main/services/hydra-api"; +import { logger } from "@main/services"; const removeGameFromLibrary = async ( _event: Electron.IpcMainInvokeEvent, @@ -9,6 +11,18 @@ const removeGameFromLibrary = async ( { id: gameId }, { isDeleted: true, executablePath: null } ); + + removeRemoveGameFromLibrary(gameId).catch((err) => { + logger.error("removeRemoveGameFromLibrary", err); + }); +}; + +const removeRemoveGameFromLibrary = async (gameId: number) => { + const game = await gameRepository.findOne({ where: { id: gameId } }); + + if (game?.remoteId) { + HydraApi.delete(`/games/${game.remoteId}`); + } }; registerEvent("removeGameFromLibrary", removeGameFromLibrary); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index e2115349..0aa01fc9 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -12,6 +12,7 @@ import { DownloadManager } from "@main/services"; import { Not } from "typeorm"; import { steamGamesWorker } from "@main/workers"; +import { createGame } from "@main/services/library-sync"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -94,6 +95,19 @@ const startGameDownload = async ( }, }); + createGame(updatedGame!).then((response) => { + const { + id: remoteId, + playTimeInMilliseconds, + lastTimePlayed, + } = response.data; + + gameRepository.update( + { objectID }, + { remoteId, playTimeInMilliseconds, lastTimePlayed } + ); + }); + await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); diff --git a/src/main/main.ts b/src/main/main.ts index 7e5692d5..1abd8c2f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -10,6 +10,7 @@ import { fetchDownloadSourcesAndUpdate } from "./helpers"; import { publishNewRepacksNotifications } from "./services/notifications"; import { MoreThan } from "typeorm"; import { HydraApi } from "./services/hydra-api"; +import { uploadGamesBatch } from "./services/library-sync"; startMainLoop(); @@ -21,7 +22,9 @@ const loadState = async (userPreferences: UserPreferences | null) => { if (userPreferences?.realDebridApiToken) RealDebridClient.authorize(userPreferences?.realDebridApiToken); - HydraApi.setupApi(); + HydraApi.setupApi().then(async () => { + if (HydraApi.isLoggedIn()) uploadGamesBatch(); + }); const [nextQueueItem] = await downloadQueueRepository.find({ order: { diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 31e69a8f..8e50e646 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -2,6 +2,9 @@ import { userAuthRepository } from "@main/repository"; import axios, { AxiosError, AxiosInstance } from "axios"; import { WindowManager } from "./window-manager"; import url from "url"; +import { uploadGamesBatch } from "./library-sync"; +import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id"; +import { logger } from "./logger"; export class HydraApi { private static instance: AxiosInstance; @@ -53,6 +56,8 @@ export class HydraApi { if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-signin"); + await clearGamesRemoteIds(); + uploadGamesBatch(); } } @@ -63,20 +68,20 @@ export class HydraApi { this.instance.interceptors.request.use( (request) => { - console.log(" ---- REQUEST -----"); - console.log(request.method, request.url, request.data); + logger.log(" ---- REQUEST -----"); + logger.log(request.method, request.url, request.data); return request; }, (error) => { - console.log("request error", error); + logger.log("request error", error); return Promise.reject(error); } ); this.instance.interceptors.response.use( (response) => { - console.log(" ---- RESPONSE -----"); - console.log( + logger.log(" ---- RESPONSE -----"); + logger.log( response.status, response.config.method, response.config.url, @@ -85,7 +90,7 @@ export class HydraApi { return response; }, (error) => { - console.log("response error", error); + logger.error("response error", error); return Promise.reject(error); } ); @@ -102,7 +107,11 @@ export class HydraApi { } private static async revalidateAccessTokenIfExpired() { - if (!this.userAuth.authToken) throw new Error("user is not logged in"); + if (!this.userAuth.authToken) { + userAuthRepository.delete({ id: 1 }); + logger.error("user is not logged in"); + throw new Error("user is not logged in"); + } const now = new Date(); if (this.userAuth.expirationTimestamp < now.getTime()) { @@ -145,6 +154,8 @@ export class HydraApi { if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-signout"); } + + logger.log("user refresh token expired"); } throw err; @@ -179,4 +190,9 @@ export class HydraApi { await this.revalidateAccessTokenIfExpired(); return this.instance.patch(url, data, this.getAxiosConfig()); } + + static async delete(url: string) { + await this.revalidateAccessTokenIfExpired(); + return this.instance.delete(url, this.getAxiosConfig()); + } } diff --git a/src/main/services/library-sync/clear-games-remote-id.ts b/src/main/services/library-sync/clear-games-remote-id.ts new file mode 100644 index 00000000..f26d65f1 --- /dev/null +++ b/src/main/services/library-sync/clear-games-remote-id.ts @@ -0,0 +1,5 @@ +import { gameRepository } from "@main/repository"; + +export const clearGamesRemoteIds = () => { + return gameRepository.update({}, { remoteId: null }); +}; diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts new file mode 100644 index 00000000..6e56a3de --- /dev/null +++ b/src/main/services/library-sync/create-game.ts @@ -0,0 +1,11 @@ +import { Game } from "@main/entity"; +import { HydraApi } from "../hydra-api"; + +export const createGame = async (game: Game) => { + return HydraApi.post(`/games`, { + objectId: game.objectID, + playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), + shop: game.shop, + lastTimePlayed: game.lastTimePlayed, + }); +}; diff --git a/src/main/services/library-sync/index.ts b/src/main/services/library-sync/index.ts new file mode 100644 index 00000000..24fafc03 --- /dev/null +++ b/src/main/services/library-sync/index.ts @@ -0,0 +1,4 @@ +export * from "./merge-with-remote-games"; +export * from "./upload-games-batch"; +export * from "./update-game-playtime"; +export * from "./create-game"; diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts new file mode 100644 index 00000000..e31310fc --- /dev/null +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -0,0 +1,69 @@ +import { gameRepository } from "@main/repository"; +import { HydraApi } from "../hydra-api"; +import { steamGamesWorker } from "@main/workers"; +import { getSteamAppAsset } from "@main/helpers"; +import { logger } from "../logger"; +import { AxiosError } from "axios"; + +export const mergeWithRemoteGames = async () => { + try { + const games = await HydraApi.get("/games"); + + for (const game of games.data) { + const localGame = await gameRepository.findOne({ + where: { + objectID: game.objectId, + }, + }); + + if (localGame) { + const updatedLastTimePlayed = + localGame.lastTimePlayed == null || + new Date(game.lastTimePlayed) > localGame.lastTimePlayed + ? new Date(game.lastTimePlayed) + : localGame.lastTimePlayed; + + const updatedPlayTime = + localGame.playTimeInMilliseconds < game.playTimeInMilliseconds + ? game.playTimeInMilliseconds + : localGame.playTimeInMilliseconds; + + gameRepository.update( + { + objectID: game.objectId, + shop: "steam", + lastTimePlayed: updatedLastTimePlayed, + playTimeInMilliseconds: updatedPlayTime, + }, + { remoteId: game.id } + ); + } else { + const steamGame = await steamGamesWorker.run(Number(game.objectId), { + name: "getById", + }); + + if (steamGame) { + const iconUrl = steamGame?.clientIcon + ? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon) + : null; + + gameRepository.insert({ + objectID: game.objectId, + title: steamGame?.name, + remoteId: game.id, + shop: game.shop, + iconUrl, + lastTimePlayed: game.lastTimePlayed, + playTimeInMilliseconds: game.playTimeInMilliseconds, + }); + } + } + } + } catch (err) { + if (err instanceof AxiosError) { + logger.error("getRemoteGames", err.response, err.message); + } else { + logger.error("getRemoteGames", err); + } + } +}; diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts new file mode 100644 index 00000000..efedf47c --- /dev/null +++ b/src/main/services/library-sync/update-game-playtime.ts @@ -0,0 +1,13 @@ +import { Game } from "@main/entity"; +import { HydraApi } from "../hydra-api"; + +export const updateGamePlaytime = async ( + game: Game, + delta: number, + lastTimePlayed: Date +) => { + return HydraApi.put(`/games/${game.remoteId}`, { + playTimeDeltaInSeconds: Math.trunc(delta), + lastTimePlayed, + }); +}; diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts new file mode 100644 index 00000000..63eee82a --- /dev/null +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -0,0 +1,44 @@ +import { gameRepository } from "@main/repository"; +import { chunk } from "lodash-es"; +import { IsNull } from "typeorm"; +import { HydraApi } from "../hydra-api"; +import { logger } from "../logger"; +import { AxiosError } from "axios"; + +import { mergeWithRemoteGames } from "./merge-with-remote-games"; +import { WindowManager } from "../window-manager"; + +export const uploadGamesBatch = async () => { + try { + const games = await gameRepository.find({ + where: { remoteId: IsNull(), isDeleted: false }, + }); + + const gamesChunks = chunk(games, 200); + + for (const chunk of gamesChunks) { + await HydraApi.post( + "/games/batch", + chunk.map((game) => { + return { + objectId: game.objectID, + playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), + shop: game.shop, + lastTimePlayed: game.lastTimePlayed, + }; + }) + ); + } + + await mergeWithRemoteGames(); + + if (WindowManager.mainWindow) + WindowManager.mainWindow.webContents.send("on-library-batch-complete"); + } catch (err) { + if (err instanceof AxiosError) { + logger.error("uploadGamesBatch", err.response, err.message); + } else { + logger.error("uploadGamesBatch", err); + } + } +}; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index ea1b6355..de3af727 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -4,8 +4,12 @@ import { IsNull, Not } from "typeorm"; import { gameRepository } from "@main/repository"; import { getProcesses } from "@main/helpers"; import { WindowManager } from "./window-manager"; +import { createGame, updateGamePlaytime } from "./library-sync"; -const gamesPlaytime = new Map(); +const gamesPlaytime = new Map< + number, + { lastTick: number; firstTick: number } +>(); export const watchProcesses = async () => { const games = await gameRepository.find({ @@ -37,7 +41,9 @@ export const watchProcesses = async () => { if (gameProcess) { if (gamesPlaytime.has(game.id)) { - const zero = gamesPlaytime.get(game.id) ?? 0; + const gamePlaytime = gamesPlaytime.get(game.id)!; + + const zero = gamePlaytime.lastTick; const delta = performance.now() - zero; if (WindowManager.mainWindow) { @@ -48,12 +54,45 @@ export const watchProcesses = async () => { playTimeInMilliseconds: game.playTimeInMilliseconds + delta, lastTimePlayed: new Date(), }); - } - gamesPlaytime.set(game.id, performance.now()); + gamesPlaytime.set(game.id, { + ...gamePlaytime, + lastTick: performance.now(), + }); + } else { + if (game.remoteId) { + updateGamePlaytime(game, 0, new Date()); + } else { + createGame({ ...game, lastTimePlayed: new Date() }).then( + (response) => { + const { id: remoteId } = response.data; + gameRepository.update({ objectID: game.objectID }, { remoteId }); + } + ); + } + + gamesPlaytime.set(game.id, { + lastTick: performance.now(), + firstTick: performance.now(), + }); + } } 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).then((response) => { + const { id: remoteId } = response.data; + gameRepository.update({ objectID: game.objectID }, { remoteId }); + }); + } + if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-game-close", game.id); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 1fe259c8..dd03f114 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -96,6 +96,12 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.on("on-game-close", listener); return () => ipcRenderer.removeListener("on-game-close", listener); }, + onLibraryBatchComplete: (cb: () => void) => { + const listener = (_event: Electron.IpcRendererEvent) => cb(); + ipcRenderer.on("on-library-batch-complete", listener); + return () => + ipcRenderer.removeListener("on-library-batch-complete", listener); + }, /* Hardware */ getDiskFreeSpace: (path: string) => diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 71065545..332a846b 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -102,6 +102,9 @@ export function App() { if (response) updateUserDetails(response); }); }), + window.electron.onLibraryBatchComplete(() => { + updateLibrary(); + }), window.electron.onSignOut(() => { clearUserDetails(); }), diff --git a/src/renderer/src/components/hero/hero.tsx b/src/renderer/src/components/hero/hero.tsx index 0cfdbea5..79d9b586 100644 --- a/src/renderer/src/components/hero/hero.tsx +++ b/src/renderer/src/components/hero/hero.tsx @@ -9,8 +9,8 @@ import { } from "@renderer/helpers"; import { useTranslation } from "react-i18next"; -const FEATURED_GAME_TITLE = "Horizon Forbidden Westâ„¢ Complete Edition"; -const FEATURED_GAME_ID = "2420110"; +const FEATURED_GAME_TITLE = "Ghost of Tsushima DIRECTOR'S CUT"; +const FEATURED_GAME_ID = "2215430"; export function Hero() { const [featuredGameDetails, setFeaturedGameDetails] = diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index 97bb0b57..6186bb85 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -1,6 +1,6 @@ import { Downloader } from "@shared"; -export const VERSION_CODENAME = "Exodus"; +export const VERSION_CODENAME = "Leviticus"; export const DOWNLOADER_NAME = { [Downloader.RealDebrid]: "Real-Debrid", diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 968a7a72..6a0160c9 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -73,6 +73,7 @@ declare global { getGameByObjectID: (objectID: string) => Promise; onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer; onGameClose: (cb: (gameId: number) => void) => () => Electron.IpcRenderer; + onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer; /* User preferences */ getUserPreferences: () => Promise; @@ -112,7 +113,7 @@ declare global { checkForUpdates: () => Promise; restartAndInstallUpdate: () => Promise; - /* Authg */ + /* Auth */ signOut: () => Promise; onSignIn: (cb: () => void) => () => Electron.IpcRenderer; onSignOut: (cb: () => void) => () => Electron.IpcRenderer;