add leaderboard embed image
This commit is contained in:
parent
3b691dae3c
commit
a5e00e4850
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
21
projects/backend/src/controller/replay.controller.ts
Normal file
21
projects/backend/src/controller/replay.controller.ts
Normal file
@ -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",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
27
projects/backend/src/service/replay.service.ts
Normal file
27
projects/backend/src/service/replay.service.ts
Normal file
@ -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",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user