add leaderboard embed image
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
Deploy Website / deploy (push) Failing after 2m19s

This commit is contained in:
Lee 2024-10-16 02:27:59 +01:00
parent 3b691dae3c
commit a5e00e4850
8 changed files with 131 additions and 21 deletions

@ -20,6 +20,7 @@
"elysia-decorators": "^1.0.2", "elysia-decorators": "^1.0.2",
"elysia-helmet": "^2.0.0", "elysia-helmet": "^2.0.0",
"elysia-rate-limit": "^4.1.0", "elysia-rate-limit": "^4.1.0",
"ky": "^1.7.2",
"mongoose": "^8.7.0", "mongoose": "^8.7.0",
"react": "^18.3.1" "react": "^18.3.1"
}, },

@ -10,7 +10,17 @@ export default class ImageController {
id: t.String({ required: true }), id: t.String({ required: true }),
}), }),
}) })
public async getOpenGraphImage({ params: { id } }: { params: { id: string } }) { public async getPlayerImage({ params: { id } }: { params: { id: string } }) {
return await ImageService.generatePlayerImage(id); return await ImageService.generatePlayerImage(id);
} }
@Get("/leaderboard/:id", {
config: {},
params: t.Object({
id: t.String({ required: true }),
}),
})
public async getLeaderboardImage({ params: { id } }: { params: { id: string } }) {
return await ImageService.generateLeaderboardImage(id);
}
} }

@ -51,14 +51,4 @@ export default class PlayerController {
}; };
} }
} }
@Get("/og/:id", {
config: {},
params: t.Object({
id: t.String({ required: true }),
}),
})
public async getOpenGraphImage({ params: { id } }: { params: { id: string } }) {
return await PlayerService.generateOpenGraphImage(id);
}
} }

@ -0,0 +1,21 @@
import { Controller, Get } from "elysia-decorators";
import { t } from "elysia";
import { ReplayService } from "../service/replay.service";
@Controller("/replay")
export default class ReplayController {
@Get("/:playerId/:leaderboardId", {
config: {},
params: t.Object({
playerId: t.String({ required: true }),
leaderboardId: t.String({ required: true }),
}),
})
public async getOpenGraphImage({
params: { playerId, leaderboardId },
}: {
params: { playerId: string; leaderboardId: string };
}) {
return ReplayService.getReplay(playerId, leaderboardId);
}
}

@ -20,6 +20,7 @@ import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { delay } from "@ssr/common/utils/utils"; import { delay } from "@ssr/common/utils/utils";
import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket"; import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket";
import ImageController from "./controller/image.controller"; import ImageController from "./controller/image.controller";
import ReplayController from "./controller/replay.controller";
// Load .env file // Load .env file
dotenv.config({ dotenv.config({
@ -152,7 +153,7 @@ app.use(
*/ */
app.use( app.use(
decorators({ decorators({
controllers: [AppController, PlayerController, ImageController], controllers: [AppController, PlayerController, ImageController, ReplayController],
}) })
); );

@ -2,6 +2,7 @@ import { ImageResponse } from "@vercel/og";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import React from "react"; import React from "react";
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils"; import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
import { getDifficultyFromScoreSaberDifficulty } from "website/src/common/scoresaber-utils";
export class ImageService { export class ImageService {
/** /**
@ -23,7 +24,7 @@ export class ImageService {
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))",
}} }}
> >
<img src={player.profilePicture} width={256} height={256} alt="Player's Avatar" tw="rounded-full" /> <img src={player.profilePicture} width={256} height={256} alt="Player's Avatar" tw="rounded-full mb-3" />
<div tw="flex flex-col pl-3 items-center"> <div tw="flex flex-col pl-3 items-center">
<p tw="font-bold text-6xl m-0">{player.name}</p> <p tw="font-bold text-6xl m-0">{player.name}</p>
<p tw="text-[#606fff] m-0">{formatPp(player.pp)}pp</p> <p tw="text-[#606fff] m-0">{formatPp(player.pp)}pp</p>
@ -67,4 +68,67 @@ export class ImageService {
} }
); );
} }
/**
* Generates the OpenGraph image for the player
*
* @param id the player's id
*/
public static async generateLeaderboardImage(id: string) {
const leaderboard = await scoresaberService.lookupLeaderboard(id);
if (leaderboard == undefined) {
return undefined;
}
const ranked = leaderboard.stars > 0;
return new ImageResponse(
(
<div
tw="w-full h-full flex flex-col text-white text-3xl p-3 justify-center items-center"
style={{
backgroundColor: "#0a0a0a",
background: "radial-gradient(ellipse 60% 60% at 50% -20%, rgba(120,119,198,0.15), rgba(255,255,255,0))",
}}
>
<img src={leaderboard.coverImage} width={256} height={256} alt="Player's Avatar" tw="rounded-full mb-3" />
<p tw="font-bold text-6xl m-0">
{leaderboard.songName} {leaderboard.songSubName}
</p>
<div tw="flex justify-center items-center text-center">
{ranked && (
<div tw="flex justify-center items-center text-4xl">
<p tw="font-bold m-0">{leaderboard.stars}</p>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
style={{
width: "40px",
height: "40px",
paddingRight: "3px",
}}
>
<path
fillRule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
clipRule="evenodd"
/>
</svg>
</div>
)}
<p tw={"font-bold m-0 text-4xl" + (ranked ? " pl-3" : "")}>
{getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty)}
</p>
</div>
<p tw="font-bold text-2xl text-gray-400 m-0">Mapped by {leaderboard.levelAuthorName}</p>
</div>
),
{
width: 1200,
height: 630,
emoji: "twemoji",
}
);
}
} }

@ -0,0 +1,27 @@
import ky from "ky";
import { NotFoundError } from "../error/not-found-error";
const SCORESABER_REPLAY_ENDPOINT = "https://scoresaber.com/api/game/telemetry/downloadReplay";
export class ReplayService {
/**
* Gets the app statistics.
*/
public static async getReplay(playerId: string, leaderboardId: string) {
const response = await ky.get(SCORESABER_REPLAY_ENDPOINT, {
searchParams: {
playerId,
leaderboardId,
},
headers: {
"User-Agent": "ScoreSaber-PC/3.3.13",
},
});
const replayData = await response.arrayBuffer();
if (replayData === undefined) {
throw new NotFoundError(`Replay for player "${playerId}" and leaderboard "${leaderboardId}" not found`);
}
return replayData;
}
}

@ -7,6 +7,7 @@ import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token"; import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
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 { config } from "../../../../../config";
const UNKNOWN_LEADERBOARD = { const UNKNOWN_LEADERBOARD = {
title: "ScoreSaber Reloaded - Unknown Leaderboard", title: "ScoreSaber Reloaded - Unknown Leaderboard",
@ -80,20 +81,15 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
title: `${leaderboard.songName} ${leaderboard.songSubName} by ${leaderboard.songAuthorName}`, title: `${leaderboard.songName} ${leaderboard.songSubName} by ${leaderboard.songAuthorName}`,
openGraph: { openGraph: {
title: `ScoreSaber Reloaded - ${leaderboard.songName} ${leaderboard.songSubName}`, title: `ScoreSaber Reloaded - ${leaderboard.songName} ${leaderboard.songSubName}`,
description: ` description: `View the scores for ${leaderboard.songName} by ${leaderboard.songAuthorName}!`,
Mapper: ${leaderboard.levelAuthorName}
Plays: ${leaderboard.plays} (${leaderboard.dailyPlays} today)
Status: ${leaderboard.stars > 0 ? "Ranked" : "Unranked"}
View the scores for ${leaderboard.songName} by ${leaderboard.songAuthorName}!`,
images: [ images: [
{ {
url: leaderboard.coverImage, url: `${config.siteApi}/image/leaderboard/${leaderboard.id}`,
}, },
], ],
}, },
twitter: { twitter: {
card: "summary", card: "summary_large_image",
}, },
}; };
} }