feat: separate game assets from game stats

This commit is contained in:
Zamitto
2025-10-11 11:26:05 -03:00
parent 6146a1fbf1
commit 9bada771df
14 changed files with 111 additions and 84 deletions

View File

@@ -0,0 +1,51 @@
import type { GameShop, ShopAssets } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 30; // 30 minutes
export const getGameAssets = async (objectId: string, shop: GameShop) => {
const cachedAssets = await gamesShopAssetsSublevel.get(
levelKeys.game(shop, objectId)
);
if (
cachedAssets &&
cachedAssets.updatedAt + LOCAL_CACHE_EXPIRATION > Date.now()
) {
return cachedAssets;
}
return HydraApi.get<ShopAssets | null>(
`/games/${shop}/${objectId}/assets`,
null,
{
needsAuth: false,
}
).then(async (assets) => {
if (!assets) return null;
// Preserve existing title if it differs from the incoming title (indicating it was customized)
const shouldPreserveTitle =
cachedAssets?.title && cachedAssets.title !== assets.title;
await gamesShopAssetsSublevel.put(levelKeys.game(shop, objectId), {
...assets,
title: shouldPreserveTitle ? cachedAssets.title : assets.title,
updatedAt: Date.now(),
});
return assets;
});
};
const getGameAssetsEvent = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
return getGameAssets(objectId, shop);
};
registerEvent("getGameAssets", getGameAssetsEvent);

View File

@@ -1,25 +0,0 @@
import type { GameShop, ShopAssets } from "@types";
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
const saveGameShopAssets = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
assets: ShopAssets
): Promise<void> => {
const key = levelKeys.game(shop, objectId);
const existingAssets = await gamesShopAssetsSublevel.get(key);
// Preserve existing title if it differs from the incoming title (indicating it was customized)
const shouldPreserveTitle =
existingAssets?.title && existingAssets.title !== assets.title;
return gamesShopAssetsSublevel.put(key, {
...existingAssets,
...assets,
title: shouldPreserveTitle ? existingAssets.title : assets.title,
});
};
registerEvent("saveGameShopAssets", saveGameShopAssets);

View File

@@ -27,6 +27,7 @@ const addCustomGameToLibrary = async (
}
const assets = {
updatedAt: Date.now(),
objectId,
shop,
title,

View File

@@ -1,12 +1,11 @@
import { registerEvent } from "../register-event";
import type { GameShop, GameStats } from "@types";
import type { GameShop, ShopAssets } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
import {
composeSteamShortcut,
getSteamLocation,
getSteamShortcuts,
getSteamUsersIds,
HydraApi,
logger,
SystemPath,
writeSteamShortcuts,
@@ -15,6 +14,7 @@ import fs from "node:fs";
import axios from "axios";
import path from "node:path";
import { ASSETS_PATH } from "@main/constants";
import { getGameAssets } from "../catalogue/get-game-assets";
const downloadAsset = async (downloadPath: string, url?: string | null) => {
try {
@@ -41,7 +41,7 @@ const downloadAsset = async (downloadPath: string, url?: string | null) => {
const downloadAssetsFromSteam = async (
shop: GameShop,
objectId: string,
assets: GameStats["assets"]
assets: ShopAssets | null
) => {
const gameAssetsPath = path.join(ASSETS_PATH, `${shop}-${objectId}`);
@@ -86,9 +86,7 @@ const createSteamShortcut = async (
throw new Error("No executable path found for game");
}
const { assets } = await HydraApi.get<GameStats>(
`/games/${shop}/${objectId}/stats`
);
const assets = await getGameAssets(objectId, shop);
const steamUserIds = await getSteamUsersIds();

View File

@@ -3,9 +3,9 @@ import type { ShopAssets } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gamesShopAssetsSublevel = db.sublevel<string, ShopAssets>(
levelKeys.gameShopAssets,
{
valueEncoding: "json",
}
);
export const gamesShopAssetsSublevel = db.sublevel<
string,
ShopAssets & { updatedAt: number }
>(levelKeys.gameShopAssets, {
valueEncoding: "json",
});

View File

@@ -58,7 +58,11 @@ export const mergeWithRemoteGames = async () => {
});
}
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey);
await gamesShopAssetsSublevel.put(gameKey, {
updatedAt: Date.now(),
...localGameShopAsset,
shop: game.shop,
objectId: game.objectId,
title: localGame?.title || game.title, // Preserve local title if it exists

View File

@@ -10,7 +10,7 @@ import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
import { WindowManager } from "../window-manager";
import type { Game, GameStats, UserPreferences, UserProfile } from "@types";
import type { Game, UserPreferences, UserProfile } from "@types";
import { db, levelKeys } from "@main/level";
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
import { SystemPath } from "../system-path";
@@ -108,15 +108,14 @@ export const publishNewFriendRequestNotification = async (
};
export const publishFriendStartedPlayingGameNotification = async (
friend: UserProfile,
game: GameStats
friend: UserProfile
) => {
new Notification({
title: t("friend_started_playing_game", {
ns: "notifications",
displayName: friend.displayName,
}),
body: game.assets?.title,
body: friend?.currentGame?.title,
icon: friend?.profileImageUrl
? await downloadImage(friend.profileImageUrl)
: trayIcon,

View File

@@ -2,7 +2,7 @@ import type { FriendGameSession } from "@main/generated/envelope";
import { db, levelKeys } from "@main/level";
import { HydraApi } from "@main/services/hydra-api";
import { publishFriendStartedPlayingGameNotification } from "@main/services/notifications";
import type { GameStats, UserPreferences, UserProfile } from "@types";
import type { UserPreferences, UserProfile } from "@types";
export const friendGameSessionEvent = async (payload: FriendGameSession) => {
const userPreferences = await db.get<string, UserPreferences | null>(
@@ -14,12 +14,9 @@ export const friendGameSessionEvent = async (payload: FriendGameSession) => {
if (userPreferences?.friendStartGameNotificationsEnabled === false) return;
const [friend, gameStats] = await Promise.all([
HydraApi.get<UserProfile>(`/users/${payload.friendId}`),
HydraApi.get<GameStats>(`/games/steam/${payload.objectId}/stats`),
]).catch(() => [null, null]);
const friend = await HydraApi.get<UserProfile>(`/users/${payload.friendId}`);
if (friend && gameStats) {
publishFriendStartedPlayingGameNotification(friend, gameStats);
if (friend) {
publishFriendStartedPlayingGameNotification(friend);
}
};

View File

@@ -17,7 +17,6 @@ import type {
Theme,
FriendRequestSync,
ShortcutLocation,
ShopAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
@@ -67,8 +66,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("searchGames", payload, take, skip),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
saveGameShopAssets: (objectId: string, shop: GameShop, assets: ShopAssets) =>
ipcRenderer.invoke("saveGameShopAssets", objectId, shop, assets),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
@@ -76,6 +73,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getHowLongToBeat", objectId, shop),
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getGameAssets: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameAssets", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
createGameReview: (
shop: GameShop,

View File

@@ -142,29 +142,23 @@ export function GameDetailsContextProvider({
}
});
const statsPromise = window.electron
.getGameStats(objectId, shop)
.then((result) => {
if (abortController.signal.aborted) return null;
setStats(result);
return result;
});
window.electron.getGameStats(objectId, shop).then((result) => {
if (abortController.signal.aborted) return;
setStats(result);
});
Promise.all([shopDetailsPromise, statsPromise])
.then(([_, stats]) => {
if (stats) {
const assets = stats.assets;
if (assets) {
window.electron.saveGameShopAssets(objectId, shop, assets);
const assetsPromise = window.electron.getGameAssets(objectId, shop);
setShopDetails((prev) => {
if (!prev) return null;
return {
...prev,
assets,
};
});
}
Promise.all([shopDetailsPromise, assetsPromise])
.then(([_, assets]) => {
if (assets) {
setShopDetails((prev) => {
if (!prev) return null;
return {
...prev,
assets,
};
});
}
})
.finally(() => {
@@ -207,8 +201,8 @@ export function GameDetailsContextProvider({
setShowRepacksModal(true);
try {
window.history.replaceState({}, document.title, location.pathname);
} catch (_e) {
void _e;
} catch (e) {
console.error(e);
}
}
}, [location]);

View File

@@ -39,6 +39,7 @@ import type {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
UserLibraryResponse,
Game,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -77,11 +78,6 @@ declare global {
skip: number
) => Promise<{ edges: CatalogueSearchResult[]; count: number }>;
getCatalogue: (category: CatalogueCategory) => Promise<ShopAssets[]>;
saveGameShopAssets: (
objectId: string,
shop: GameShop,
assets: ShopAssets
) => Promise<void>;
getGameShopDetails: (
objectId: string,
shop: GameShop,
@@ -93,6 +89,10 @@ declare global {
shop: GameShop
) => Promise<HowLongToBeatCategory[] | null>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getGameAssets: (
objectId: string,
shop: GameShop
) => Promise<ShopAssets | null>;
getTrendingGames: () => Promise<TrendingGame[]>;
createGameReview: (
shop: GameShop,

View File

@@ -233,9 +233,18 @@ export function Sidebar() {
{t("rating_count")}
</p>
<StarRating
rating={stats?.averageScore === 0 ? null : stats?.averageScore ?? null}
rating={
stats?.averageScore === 0
? null
: (stats?.averageScore ?? null)
}
size={16}
showCalculating={!!(stats && (stats.averageScore === null || stats.averageScore === 0))}
showCalculating={
!!(
stats &&
(stats.averageScore === null || stats.averageScore === 0)
)
}
calculatingText={t("calculating", { ns: "game_card" })}
hideIcon={true}
/>

View File

@@ -234,7 +234,7 @@ export function UserLibraryGameCard({
</div>
<img
src={game.coverImageUrl}
src={game.coverImageUrl ?? undefined}
alt={game.title}
className="user-library-game__game-image"
/>

View File

@@ -45,7 +45,7 @@ export interface ShopAssets {
libraryImageUrl: string;
logoImageUrl: string;
logoPosition: string | null;
coverImageUrl: string;
coverImageUrl: string | null;
}
export type ShopDetails = SteamAppDetails & {
@@ -235,8 +235,8 @@ export interface DownloadSourceValidationResult {
export interface GameStats {
downloadCount: number;
playerCount: number;
assets: ShopAssets | null;
averageScore: number | null;
reviewCount: number;
}
export interface GameReview {