cleanup images and add daily change and join date to the player image
All checks were successful
Deploy Backend / deploy (push) Successful in 2m35s
All checks were successful
Deploy Backend / deploy (push) Successful in 2m35s
This commit is contained in:
parent
74385252a4
commit
7bacc30f33
2
projects/backend/.env-example
Normal file
2
projects/backend/.env-example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
MONGO_URI=mongodb://localhost:27017
|
||||||
|
API_URL=http://localhost:8080
|
@ -1,3 +1,4 @@
|
|||||||
export const Config = {
|
export const Config = {
|
||||||
mongoUri: process.env.MONGO_URI,
|
mongoUri: process.env.MONGO_URI,
|
||||||
}
|
apiUrl: process.env.API_URL || "https://ssr.fascinated.cc/api",
|
||||||
|
};
|
||||||
|
@ -6,25 +6,45 @@ import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresa
|
|||||||
import { StarIcon } from "../../components/star-icon";
|
import { StarIcon } from "../../components/star-icon";
|
||||||
import { GlobeIcon } from "../../components/globe-icon";
|
import { GlobeIcon } from "../../components/globe-icon";
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
|
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player";
|
||||||
|
import { Config } from "../common/config";
|
||||||
|
|
||||||
const cache = new NodeCache({
|
const cache = new NodeCache({ stdTTL: 60 * 60, checkperiod: 120 });
|
||||||
stdTTL: 60 * 60, // 1 hour
|
const imageOptions = { width: 1200, height: 630 };
|
||||||
checkperiod: 120,
|
|
||||||
});
|
|
||||||
|
|
||||||
export class ImageService {
|
export class ImageService {
|
||||||
|
/**
|
||||||
|
* Fetches data with caching.
|
||||||
|
*
|
||||||
|
* @param cacheKey The key used for caching.
|
||||||
|
* @param fetchFn The function to fetch data if it's not in cache.
|
||||||
|
*/
|
||||||
|
private static async fetchWithCache<T>(
|
||||||
|
cacheKey: string,
|
||||||
|
fetchFn: () => Promise<T | undefined>
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
if (cache.has(cacheKey)) {
|
||||||
|
return cache.get<T>(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchFn();
|
||||||
|
if (data) {
|
||||||
|
cache.set(cacheKey, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The base of the OpenGraph image
|
* The base of the OpenGraph image
|
||||||
*
|
*
|
||||||
* @param children the content of the image
|
* @param children the content of the image
|
||||||
* @private
|
|
||||||
*/
|
*/
|
||||||
public static BaseImage({ children }: { children: React.ReactNode }) {
|
public static BaseImage({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
tw="w-full h-full flex flex-col text-white text-3xl p-3 justify-center items-center"
|
tw="w-full h-full flex flex-col text-white text-3xl p-3 justify-center items-center relative"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#0a0a0a",
|
backgroundColor: "#0a0a0a",
|
||||||
background: "radial-gradient(ellipse 60% 60% at 50% -20%, rgba(120,119,198,0.15), rgba(255,255,255,0))",
|
background: "radial-gradient(ellipse 60% 60% at 50% -20%, rgba(120,119,198,0.15), rgba(255,255,255,0))",
|
||||||
@ -36,55 +56,21 @@ export class ImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the OpenGraph image for the player
|
* Renders the change for a stat.
|
||||||
*
|
*
|
||||||
* @param id the player's id
|
* @param change the amount of change
|
||||||
|
* @param format the function to format the value
|
||||||
*/
|
*/
|
||||||
public static async generatePlayerImage(id: string) {
|
private static renderDailyChange(change: number, format: (value: number) => string = formatNumberWithCommas) {
|
||||||
const cacheKey = `player-${id}`;
|
if (change === 0) {
|
||||||
let player: undefined | ScoreSaberPlayerToken;
|
return null;
|
||||||
if (cache.has(cacheKey)) {
|
|
||||||
player = cache.get<ScoreSaberPlayerToken>(cacheKey);
|
|
||||||
} else {
|
|
||||||
player = await scoresaberService.lookupPlayer(id);
|
|
||||||
if (player != undefined) {
|
|
||||||
cache.set(cacheKey, player);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (player == undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ImageResponse(
|
return (
|
||||||
(
|
<p tw={`text-[23px] pl-1 m-0 ${change > 0 ? "text-green-400" : "text-red-400"}`}>
|
||||||
<ImageService.BaseImage>
|
{change > 0 ? "+" : ""}
|
||||||
<img src={player.profilePicture} width={256} height={256} alt="Player's Avatar" tw="rounded-full mb-3" />
|
{format(change)}
|
||||||
<div tw="flex flex-col pl-3 items-center">
|
</p>
|
||||||
<p tw="font-bold text-6xl m-0">{player.name}</p>
|
|
||||||
<p tw="text-[#606fff] m-0">{formatPp(player.pp)}pp</p>
|
|
||||||
<div tw="flex">
|
|
||||||
<div tw="flex px-2 justify-center items-center">
|
|
||||||
<GlobeIcon />
|
|
||||||
<p tw="m-0">#{formatNumberWithCommas(player.rank)}</p>
|
|
||||||
</div>
|
|
||||||
<div tw="flex items-center px-2 justify-center items-center">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={`https://ssr.fascinated.cc/assets/flags/${player.country.toLowerCase()}.png`}
|
|
||||||
height={20}
|
|
||||||
alt="Player's Country"
|
|
||||||
/>
|
|
||||||
<p tw="pl-1 m-0">#{formatNumberWithCommas(player.countryRank)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ImageService.BaseImage>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
emoji: "twemoji",
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,48 +79,121 @@ export class ImageService {
|
|||||||
*
|
*
|
||||||
* @param id the player's id
|
* @param id the player's id
|
||||||
*/
|
*/
|
||||||
public static async generateLeaderboardImage(id: string) {
|
public static async generatePlayerImage(id: string) {
|
||||||
const cacheKey = `leaderboard-${id}`;
|
const player = await this.fetchWithCache<ScoreSaberPlayer>(`player-${id}`, async () => {
|
||||||
let leaderboard: undefined | ScoreSaberLeaderboardToken;
|
const token = await scoresaberService.lookupPlayer(id);
|
||||||
if (cache.has(cacheKey)) {
|
return token ? await getScoreSaberPlayerFromToken(token, Config.apiUrl) : undefined;
|
||||||
leaderboard = cache.get(cacheKey) as ScoreSaberLeaderboardToken;
|
});
|
||||||
} else {
|
if (!player) {
|
||||||
leaderboard = await scoresaberService.lookupLeaderboard(id);
|
return undefined;
|
||||||
if (leaderboard != undefined) {
|
|
||||||
cache.set(cacheKey, leaderboard);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (leaderboard == undefined) {
|
|
||||||
|
const { statisticChange } = player;
|
||||||
|
const { daily } = statisticChange ?? {};
|
||||||
|
const rankChange = daily?.countryRank ?? 0;
|
||||||
|
const countryRankChange = daily?.rank ?? 0;
|
||||||
|
const ppChange = daily?.pp ?? 0;
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<ImageService.BaseImage>
|
||||||
|
{/* Player Avatar */}
|
||||||
|
<img src={player.avatar} width={256} height={256} alt="Player's Avatar" tw="rounded-full mb-3" />
|
||||||
|
|
||||||
|
{/* Player Stats */}
|
||||||
|
<div tw="flex flex-col pl-3 items-center">
|
||||||
|
{/* Player Name */}
|
||||||
|
<p tw="font-bold text-6xl m-0">{player.name}</p>
|
||||||
|
|
||||||
|
{/* Player PP */}
|
||||||
|
<div tw="flex justify-center items-center text-[33px]">
|
||||||
|
<p tw="text-[#606fff] m-0">{formatPp(player.pp)}pp</p>
|
||||||
|
{this.renderDailyChange(ppChange)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Stats */}
|
||||||
|
<div tw="flex">
|
||||||
|
{/* Player Rank */}
|
||||||
|
<div tw="flex px-2 justify-center items-center">
|
||||||
|
<GlobeIcon />
|
||||||
|
<p tw="m-0">#{formatNumberWithCommas(player.rank)}</p>
|
||||||
|
{this.renderDailyChange(rankChange)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Country Rank */}
|
||||||
|
<div tw="flex px-2 justify-center items-center">
|
||||||
|
<img
|
||||||
|
src={`https://ssr.fascinated.cc/assets/flags/${player.country.toLowerCase()}.png`}
|
||||||
|
height={20}
|
||||||
|
alt="Player's Country"
|
||||||
|
/>
|
||||||
|
<p tw="pl-1 m-0">#{formatNumberWithCommas(player.countryRank)}</p>
|
||||||
|
{this.renderDailyChange(countryRankChange)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Joined Date */}
|
||||||
|
<p tw="m-0 text-gray-400 mt-2">
|
||||||
|
Joined ScoreSaber in{" "}
|
||||||
|
{player.joinedDate.toLocaleString("en-US", {
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ImageService.BaseImage>
|
||||||
|
),
|
||||||
|
imageOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the OpenGraph image for the leaderboard
|
||||||
|
*
|
||||||
|
* @param id the leaderboard's id
|
||||||
|
*/
|
||||||
|
public static async generateLeaderboardImage(id: string) {
|
||||||
|
const leaderboard = await this.fetchWithCache<ScoreSaberLeaderboardToken>(`leaderboard-${id}`, () =>
|
||||||
|
scoresaberService.lookupLeaderboard(id)
|
||||||
|
);
|
||||||
|
if (!leaderboard) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ranked = leaderboard.stars > 0;
|
const ranked = leaderboard.stars > 0;
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
(
|
||||||
<ImageService.BaseImage>
|
<ImageService.BaseImage>
|
||||||
<img src={leaderboard.coverImage} width={256} height={256} alt="Player's Avatar" tw="rounded-full mb-3" />
|
{/* Leaderboard Cover Image */}
|
||||||
|
<img src={leaderboard.coverImage} width={256} height={256} alt="Leaderboard Cover" tw="rounded-full mb-3" />
|
||||||
|
|
||||||
|
{/* Leaderboard Name */}
|
||||||
<p tw="font-bold text-6xl m-0">
|
<p tw="font-bold text-6xl m-0">
|
||||||
{leaderboard.songName} {leaderboard.songSubName}
|
{leaderboard.songName} {leaderboard.songSubName}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div tw="flex justify-center items-center text-center">
|
<div tw="flex justify-center items-center text-center">
|
||||||
|
{/* Leaderboard Stars */}
|
||||||
{ranked && (
|
{ranked && (
|
||||||
<div tw="flex justify-center items-center text-4xl">
|
<div tw="flex justify-center items-center text-4xl">
|
||||||
<p tw="font-bold m-0">{leaderboard.stars}</p>
|
<p tw="font-bold m-0">{leaderboard.stars}</p>
|
||||||
<StarIcon />
|
<StarIcon />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Leaderboard Difficulty */}
|
||||||
<p tw={"font-bold m-0 text-4xl" + (ranked ? " pl-3" : "")}>
|
<p tw={"font-bold m-0 text-4xl" + (ranked ? " pl-3" : "")}>
|
||||||
{getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty)}
|
{getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p tw="font-bold text-2xl text-gray-400 m-0">Mapped by {leaderboard.levelAuthorName}</p>
|
|
||||||
|
{/* Leaderboard Author */}
|
||||||
|
<p tw="font-bold text-2xl text-gray-400 m-0 mt-2">Mapped by {leaderboard.levelAuthorName}</p>
|
||||||
</ImageService.BaseImage>
|
</ImageService.BaseImage>
|
||||||
),
|
),
|
||||||
{
|
imageOptions
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
emoji: "twemoji",
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user