From a5e00e4850c58ae2f7aa4def417e75e1da1cb175 Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 16 Oct 2024 02:27:59 +0100 Subject: [PATCH] add leaderboard embed image --- projects/backend/package.json | 1 + .../src/controller/image.controller.ts | 12 +++- .../src/controller/player.controller.ts | 10 --- .../src/controller/replay.controller.ts | 21 ++++++ projects/backend/src/index.ts | 3 +- .../backend/src/service/image.service.tsx | 66 ++++++++++++++++++- .../backend/src/service/replay.service.ts | 27 ++++++++ .../(pages)/leaderboard/[...slug]/page.tsx | 12 ++-- 8 files changed, 131 insertions(+), 21 deletions(-) create mode 100644 projects/backend/src/controller/replay.controller.ts create mode 100644 projects/backend/src/service/replay.service.ts diff --git a/projects/backend/package.json b/projects/backend/package.json index dfbcb6e..8f1a9d2 100644 --- a/projects/backend/package.json +++ b/projects/backend/package.json @@ -20,6 +20,7 @@ "elysia-decorators": "^1.0.2", "elysia-helmet": "^2.0.0", "elysia-rate-limit": "^4.1.0", + "ky": "^1.7.2", "mongoose": "^8.7.0", "react": "^18.3.1" }, diff --git a/projects/backend/src/controller/image.controller.ts b/projects/backend/src/controller/image.controller.ts index 8ed58c6..c94ed3a 100644 --- a/projects/backend/src/controller/image.controller.ts +++ b/projects/backend/src/controller/image.controller.ts @@ -10,7 +10,17 @@ export default class ImageController { 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); } + + @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); + } } diff --git a/projects/backend/src/controller/player.controller.ts b/projects/backend/src/controller/player.controller.ts index 4bfe294..9e09b0b 100644 --- a/projects/backend/src/controller/player.controller.ts +++ b/projects/backend/src/controller/player.controller.ts @@ -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); - } } diff --git a/projects/backend/src/controller/replay.controller.ts b/projects/backend/src/controller/replay.controller.ts new file mode 100644 index 0000000..fb57984 --- /dev/null +++ b/projects/backend/src/controller/replay.controller.ts @@ -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); + } +} diff --git a/projects/backend/src/index.ts b/projects/backend/src/index.ts index 503199d..ed1c7b1 100644 --- a/projects/backend/src/index.ts +++ b/projects/backend/src/index.ts @@ -20,6 +20,7 @@ import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { delay } from "@ssr/common/utils/utils"; import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket"; import ImageController from "./controller/image.controller"; +import ReplayController from "./controller/replay.controller"; // Load .env file dotenv.config({ @@ -152,7 +153,7 @@ app.use( */ app.use( decorators({ - controllers: [AppController, PlayerController, ImageController], + controllers: [AppController, PlayerController, ImageController, ReplayController], }) ); diff --git a/projects/backend/src/service/image.service.tsx b/projects/backend/src/service/image.service.tsx index e16661b..58b75d2 100644 --- a/projects/backend/src/service/image.service.tsx +++ b/projects/backend/src/service/image.service.tsx @@ -2,6 +2,7 @@ import { ImageResponse } from "@vercel/og"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import React from "react"; import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils"; +import { getDifficultyFromScoreSaberDifficulty } from "website/src/common/scoresaber-utils"; 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))", }} > - Player's Avatar + Player's Avatar

{player.name}

{formatPp(player.pp)}pp

@@ -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( + ( +
+ Player's Avatar +

+ {leaderboard.songName} {leaderboard.songSubName} +

+
+ {ranked && ( +
+

{leaderboard.stars}

+ + + +
+ )} +

+ {getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty)} +

+
+

Mapped by {leaderboard.levelAuthorName}

+
+ ), + { + width: 1200, + height: 630, + emoji: "twemoji", + } + ); + } } diff --git a/projects/backend/src/service/replay.service.ts b/projects/backend/src/service/replay.service.ts new file mode 100644 index 0000000..89f29d1 --- /dev/null +++ b/projects/backend/src/service/replay.service.ts @@ -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; + } +} diff --git a/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx b/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx index 67584c1..fbe76a8 100644 --- a/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx +++ b/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx @@ -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 NodeCache from "node-cache"; import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; +import { config } from "../../../../../config"; const UNKNOWN_LEADERBOARD = { title: "ScoreSaber Reloaded - Unknown Leaderboard", @@ -80,20 +81,15 @@ export async function generateMetadata(props: Props): Promise { title: `${leaderboard.songName} ${leaderboard.songSubName} by ${leaderboard.songAuthorName}`, openGraph: { title: `ScoreSaber Reloaded - ${leaderboard.songName} ${leaderboard.songSubName}`, - description: ` - Mapper: ${leaderboard.levelAuthorName} - Plays: ${leaderboard.plays} (${leaderboard.dailyPlays} today) - Status: ${leaderboard.stars > 0 ? "Ranked" : "Unranked"} - - View the scores for ${leaderboard.songName} by ${leaderboard.songAuthorName}!`, + description: `View the scores for ${leaderboard.songName} by ${leaderboard.songAuthorName}!`, images: [ { - url: leaderboard.coverImage, + url: `${config.siteApi}/image/leaderboard/${leaderboard.id}`, }, ], }, twitter: { - card: "summary", + card: "summary_large_image", }, }; }