feat: displaying recent achievements on profile

This commit is contained in:
Moyasee
2025-10-20 01:42:50 +03:00
parent 97b27a1785
commit cc9d98c360
21 changed files with 1104 additions and 25 deletions

View File

@@ -0,0 +1,158 @@
import fs from "fs";
import path from "path";
import axios from "axios";
import { fileTypeFromFile } from "file-type";
import { HydraApi } from "@main/services/hydra-api";
import { registerEvent } from "../register-event";
import { gameAchievementsSublevel, levelKeys, db } from "@main/level";
import { logger } from "@main/services/logger";
import type { GameShop, User } from "@types";
/**
* Uploads an achievement image to CDN using presigned URL
*/
const uploadImageToCDN = async (imagePath: string): Promise<string> => {
const stat = fs.statSync(imagePath);
const fileBuffer = fs.readFileSync(imagePath);
const fileSizeInBytes = stat.size;
// Get presigned URL for achievement image
const response = await HydraApi.post<{
presignedUrl: string;
achievementImageUrl: string;
}>("/presigned-urls/achievement-image", {
imageExt: path.extname(imagePath).slice(1),
imageLength: fileSizeInBytes,
});
const mimeType = await fileTypeFromFile(imagePath);
// Upload to CDN
await axios.put(response.presignedUrl, fileBuffer, {
headers: {
"Content-Type": mimeType?.mime,
},
});
return response.achievementImageUrl;
};
/**
* Stores achievement image locally in the database
*/
const storeImageLocally = async (imagePath: string): Promise<string> => {
const fileBuffer = fs.readFileSync(imagePath);
const base64Image = fileBuffer.toString('base64');
const mimeType = await fileTypeFromFile(imagePath);
// Create a data URL for local storage
return `data:${mimeType?.mime || 'image/jpeg'};base64,${base64Image}`;
};
/**
* Updates the achievement with the image URL via API
*/
const updateAchievementWithImageUrl = async (
shop: GameShop,
gameId: string,
achievementName: string,
imageUrl: string
): Promise<void> => {
await HydraApi.patch(
`/profile/games/achievements/${shop}/${gameId}/${achievementName}/image`,
{ achievementImageUrl: imageUrl }
);
};
/**
* Main function for uploading achievement images (called from mergeAchievements)
*/
export const uploadAchievementImage = async (
gameId: string,
achievementName: string,
imagePath: string,
shop?: GameShop
): Promise<{ success: boolean; imageUrl: string }> => {
try {
let imageUrl: string;
// Check if user has active subscription
const hasSubscription = await db
.get<string, User>(levelKeys.user, { valueEncoding: "json" })
.then((user) => {
const expiresAt = new Date(user?.subscription?.expiresAt ?? 0);
return expiresAt > new Date();
})
.catch(() => false);
if (hasSubscription) {
// Upload to CDN and update via API
imageUrl = await uploadImageToCDN(imagePath);
if (shop) {
await updateAchievementWithImageUrl(shop, gameId, achievementName, imageUrl);
}
logger.log(`Achievement image uploaded to CDN for ${gameId}:${achievementName}`);
} else {
// Store locally
imageUrl = await storeImageLocally(imagePath);
logger.log(`Achievement image stored locally for ${gameId}:${achievementName}`);
}
return { success: true, imageUrl };
} catch (error) {
logger.error(`Failed to upload achievement image for ${gameId}:${achievementName}:`, error);
throw error;
}
};
/**
* IPC event handler for uploading achievement images
*/
const uploadAchievementImageEvent = async (
_event: Electron.IpcMainInvokeEvent,
params: {
imagePath: string;
gameId: string;
achievementName: string;
shop: GameShop;
}
) => {
const { imagePath, gameId, achievementName, shop } = params;
try {
const result = await uploadAchievementImage(gameId, achievementName, imagePath, shop);
// Update local database with image URL
const achievementKey = levelKeys.game(shop, gameId);
const existingData = await gameAchievementsSublevel.get(achievementKey).catch(() => null);
if (existingData) {
await gameAchievementsSublevel.put(achievementKey, {
...existingData,
achievementImageUrl: result.imageUrl,
});
}
// Clean up the temporary screenshot file
try {
fs.unlinkSync(imagePath);
} catch (error) {
logger.error(`Failed to cleanup screenshot file ${imagePath}:`, error);
}
return result;
} catch (error) {
// Clean up the temporary screenshot file even on error
try {
fs.unlinkSync(imagePath);
} catch (cleanupError) {
logger.error(`Failed to cleanup screenshot file ${imagePath}:`, cleanupError);
}
throw error;
}
};
registerEvent("uploadAchievementImage", uploadAchievementImageEvent);

View File

@@ -3,6 +3,8 @@ import { registerEvent } from "../register-event";
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager";
import { HydraApi } from "@main/services";
import { UserNotLoggedInError } from "@shared";
export const getUnlockedAchievements = async (
objectId: string,
@@ -31,6 +33,30 @@ export const getUnlockedAchievements = async (
const unlockedAchievements = cachedAchievements?.unlockedAchievements ?? [];
// Try to get user achievements with image URLs from remote API
let remoteUserAchievements: UserAchievement[] = [];
try {
const userDetails = await db.get<string, any>(levelKeys.user, {
valueEncoding: "json",
});
if (userDetails?.id) {
remoteUserAchievements = await HydraApi.get<UserAchievement[]>(
`/users/${userDetails.id}/games/achievements`,
{
shop,
objectId,
language: userPreferences?.language ?? "en",
}
);
}
} catch (error) {
// If API call fails, continue with local data only
if (!(error instanceof UserNotLoggedInError)) {
console.warn("Failed to fetch remote user achievements:", error);
}
}
return achievementsData
.map((achievementData) => {
const unlockedAchievementData = unlockedAchievements.find(
@@ -42,6 +68,16 @@ export const getUnlockedAchievements = async (
}
);
// Find corresponding remote achievement data for image URL
const remoteAchievementData = remoteUserAchievements.find(
(remoteAchievement) => {
return (
remoteAchievement.name.toUpperCase() ==
achievementData.name.toUpperCase()
);
}
);
const icongray = achievementData.icongray.endsWith("/")
? achievementData.icon
: achievementData.icongray;
@@ -51,6 +87,7 @@ export const getUnlockedAchievements = async (
...achievementData,
unlocked: true,
unlockTime: unlockedAchievementData.unlockTime,
achievementImageUrl: remoteAchievementData?.achievementImageUrl || null,
};
}
@@ -63,6 +100,7 @@ export const getUnlockedAchievements = async (
!achievementData.hidden || showHiddenAchievementsDescription
? achievementData.description
: undefined,
achievementImageUrl: remoteAchievementData?.achievementImageUrl || null,
};
})
.sort((a, b) => {

View File

@@ -15,6 +15,7 @@ import { achievementsLogger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { getGameAchievementData } from "./get-game-achievement-data";
import { AchievementWatcherManager } from "./achievement-watcher-manager";
import { ScreenshotService } from "../screenshot";
const isRareAchievement = (points: number) => {
const rawPercentage = (50 - Math.sqrt(points)) * 2;
@@ -103,6 +104,8 @@ export const mergeAchievements = async (
publishNotification &&
userPreferences.achievementNotificationsEnabled !== false
) {
const filteredAchievements = newAchievements
.toSorted((a, b) => {
return a.unlockTime - b.unlockTime;
@@ -171,22 +174,57 @@ export const mergeAchievements = async (
},
{ needsSubscription: !newAchievements.length }
)
.then((response) => {
.then(async (response) => {
if (response) {
return saveAchievementsOnLocal(
await saveAchievementsOnLocal(
response.objectId,
response.shop,
response.achievements,
publishNotification
);
} else {
await saveAchievementsOnLocal(
game.objectId,
game.shop,
mergedLocalAchievements,
publishNotification
);
}
return saveAchievementsOnLocal(
game.objectId,
game.shop,
mergedLocalAchievements,
publishNotification
);
// Capture and upload screenshot AFTER achievements are synced to server
if (newAchievements.length && userPreferences.enableAchievementScreenshots === true) {
try {
// Import and trigger the upload process
const { uploadAchievementImage } = await import("@main/events/achievements/upload-achievement-image");
// Upload the screenshot for each new achievement
for (const achievement of newAchievements) {
try {
// Find the achievement data to get the display name
const achievementData = achievementsData.find((steamAchievement) => {
return (
achievement.name.toUpperCase() ===
steamAchievement.name.toUpperCase()
);
});
const achievementDisplayName = achievementData?.displayName || achievement.name;
// Capture screenshot with game title and achievement name
const screenshotPath = await ScreenshotService.captureDesktopScreenshot(
game.title,
achievementDisplayName
);
await uploadAchievementImage(game.objectId, achievement.name, screenshotPath, game.shop);
} catch (error) {
achievementsLogger.error("Failed to upload achievement image", error);
}
}
} catch (error) {
achievementsLogger.error("Failed to capture screenshot for achievement", error);
}
}
})
.catch((err) => {
if (err instanceof SubscriptionRequiredError) {

View File

@@ -13,6 +13,7 @@ export * from "./game-files-manager";
export * from "./common-redist-manager";
export * from "./aria2";
export * from "./ws";
export * from "./screenshot";
export * from "./system-path";
export * from "./library-sync";
export * from "./wine";

View File

@@ -0,0 +1,190 @@
import { desktopCapturer, nativeImage } from "electron";
import fs from "node:fs";
import path from "node:path";
import { app } from "electron";
import { logger } from "./logger";
export class ScreenshotService {
private static readonly SCREENSHOT_QUALITY = 60; // Reduced for better compression
private static readonly SCREENSHOT_FORMAT = "jpeg";
private static readonly MAX_WIDTH = 1280; // Maximum width for compression
private static readonly MAX_HEIGHT = 720; // Maximum height for compression
/**
* Compresses an image by resizing and adjusting quality
*/
private static compressImage(image: Electron.NativeImage): Electron.NativeImage {
const size = image.getSize();
// Calculate new dimensions while maintaining aspect ratio
let newWidth = size.width;
let newHeight = size.height;
if (newWidth > this.MAX_WIDTH || newHeight > this.MAX_HEIGHT) {
const aspectRatio = newWidth / newHeight;
if (newWidth > newHeight) {
newWidth = this.MAX_WIDTH;
newHeight = Math.round(newWidth / aspectRatio);
} else {
newHeight = this.MAX_HEIGHT;
newWidth = Math.round(newHeight * aspectRatio);
}
}
// Resize the image if dimensions changed
if (newWidth !== size.width || newHeight !== size.height) {
return image.resize({ width: newWidth, height: newHeight });
}
return image;
}
public static async captureDesktopScreenshot(gameTitle?: string, achievementName?: string): Promise<string> {
try {
// Get all available desktop sources
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: 1920, height: 1080 }
});
if (sources.length === 0) {
throw new Error("No desktop sources available for screenshot");
}
// Use the primary screen (first source)
const primaryScreen = sources[0];
// Convert the thumbnail to a higher quality image
const originalImage = nativeImage.createFromDataURL(primaryScreen.thumbnail.toDataURL());
// Compress the image to reduce file size
const compressedImage = this.compressImage(originalImage);
// Create screenshots directory structure
const userDataPath = app.getPath("userData");
const screenshotsDir = path.join(userDataPath, "screenshots");
let finalDir = screenshotsDir;
let filename: string;
if (gameTitle && achievementName) {
// Create game-specific directory
const sanitizedGameTitle = gameTitle.replace(/[<>:"/\\|?*]/g, '_');
const gameDir = path.join(screenshotsDir, sanitizedGameTitle);
finalDir = gameDir;
// Use achievement name as filename (sanitized)
const sanitizedAchievementName = achievementName.replace(/[<>:"/\\|?*]/g, '_');
filename = `${sanitizedAchievementName}.${this.SCREENSHOT_FORMAT}`;
} else {
// Fallback to timestamp-based naming
const timestamp = Date.now();
filename = `achievement_screenshot_${timestamp}.${this.SCREENSHOT_FORMAT}`;
}
// Ensure directory exists
if (!fs.existsSync(finalDir)) {
fs.mkdirSync(finalDir, { recursive: true });
}
const screenshotPath = path.join(finalDir, filename);
// Save the compressed screenshot as JPEG with specified quality
const jpegBuffer = compressedImage.toJPEG(this.SCREENSHOT_QUALITY);
fs.writeFileSync(screenshotPath, jpegBuffer);
logger.log(`Compressed screenshot saved to: ${screenshotPath}`);
return screenshotPath;
} catch (error) {
logger.error("Failed to capture desktop screenshot:", error);
throw error;
}
}
public static async cleanupOldScreenshots(): Promise<void> {
try {
const userDataPath = app.getPath("userData");
const screenshotsDir = path.join(userDataPath, "screenshots");
if (!fs.existsSync(screenshotsDir)) {
return;
}
// Get all files recursively from screenshots directory and subdirectories
const getAllFiles = (dir: string): Array<{name: string, path: string, mtime: Date}> => {
const files: Array<{name: string, path: string, mtime: Date}> = [];
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
// Recursively get files from subdirectories
files.push(...getAllFiles(itemPath));
} else if (item.endsWith(`.${this.SCREENSHOT_FORMAT}`)) {
files.push({
name: item,
path: itemPath,
mtime: stat.mtime
});
}
}
return files;
};
const allFiles = getAllFiles(screenshotsDir)
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
// Keep only the 50 most recent screenshots (increased from 10 to accommodate multiple games)
const filesToDelete = allFiles.slice(50);
for (const file of filesToDelete) {
try {
fs.unlinkSync(file.path);
logger.log(`Cleaned up old screenshot: ${file.name}`);
} catch (error) {
logger.error(`Failed to delete screenshot ${file.name}:`, error);
}
}
// Clean up empty directories
const cleanupEmptyDirs = (dir: string) => {
if (dir === screenshotsDir) return; // Don't delete the main screenshots directory
try {
const items = fs.readdirSync(dir);
if (items.length === 0) {
fs.rmdirSync(dir);
logger.log(`Cleaned up empty directory: ${dir}`);
}
} catch (error) {
// Directory might not be empty or might not exist, ignore
}
};
// Check for empty game directories and clean them up
const gameDirectories = fs.readdirSync(screenshotsDir)
.map(item => path.join(screenshotsDir, item))
.filter(itemPath => {
try {
return fs.statSync(itemPath).isDirectory();
} catch {
return false;
}
});
for (const gameDir of gameDirectories) {
cleanupEmptyDirs(gameDir);
}
} catch (error) {
logger.error("Failed to cleanup old screenshots:", error);
}
}
}

View File

@@ -1,4 +1,4 @@
import { User, type ProfileVisibility, type UserDetails } from "@types";
import { User, type ProfileVisibility, type UserDetails, type UserPreferences } from "@types";
import { HydraApi } from "../hydra-api";
import { UserNotLoggedInError } from "@shared";
import { logger } from "../logger";
@@ -6,7 +6,26 @@ import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
export const getUserData = async () => {
return HydraApi.get<UserDetails>(`/profile/me`)
// Get user language preference for API call
let language = "en"; // Default fallback
try {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
if (userPreferences?.language) {
// Map supported languages (pt, ru, es) or fallback to en
const supportedLanguages = ["pt", "ru", "es"];
const userLang = userPreferences.language.split("-")[0];
language = supportedLanguages.includes(userLang) ? userLang : "en";
}
} catch (error) {
logger.error("Failed to get user preferences for language", error);
}
const params = new URLSearchParams({ language });
return HydraApi.get<UserDetails>(`/profile/me?${params.toString()}`)
.then(async (me) => {
try {
const user = await db.get<string, User>(levelKeys.user, {