diff --git a/.gitea/kubernetes/backend/sealed-secret.yaml b/.gitea/kubernetes/backend/sealed-secret.yaml index 3b51b19..5a642e6 100644 --- a/.gitea/kubernetes/backend/sealed-secret.yaml +++ b/.gitea/kubernetes/backend/sealed-secret.yaml @@ -7,8 +7,9 @@ metadata: namespace: public-services spec: encryptedData: - MONGO_URI: AgBZ4poRziaXwQLAspWQkgxQIKTYVq2w1fPBSbOBugso2kk5S6UpYo2MXD4F/ERDZM82p41yoNbwA5D8qPkAXlm7rdhiJI9PA410m/96eSFtE1UHNX0KXqrK/ay+ck9G2gjXv2osvrKphAgMy2k/PKbDDUE8p4iHVqDrzpTb5O1JvPg9Lcdv3kHZQL/wFWQDI1hzjL3W8ZkdeyNM81y20aG/is66rIcRukPPcg3rPfhLIcXmNlw6ows3SZqgN4QNWrUpT3pwaxySj4v1UbusH/HQGpdAcR0AjpTfIdG7o0+wBQPCeUMDMowvIyXQn/3rfeVG0mPSwEceJPhJ527YOcd91E693/c2QZKwG0slXnDSfKcrkiVeM7Mirjz6HHm5GQnFOUz7/i0Dttb8U56HGepqLJ8xcYFE5VWy/vzfmqD9bADHw9Ivk5lfXaKgrBWYQS7GNy8h9XISIszcQdkfz6cDhErl9iU6POYX2YnU1mxl3mSOPPM3efxJ/bm0PAZA8Ezyo6ww1P96QCbUCfZt/Ju1OYVvkwBGrJfo0bXx6x5yzd9Y22EKGoo3hTcoFbRzangVjf0/Vvu9EibC3UNEqeB/NwD5Xo8FvSbovr/wrmH12DUWVJYzKyWLPJObF8rIpn9OI1dsHk53jpfJyfToguy6ZQwsDU18OTqXPKyz86X/h2+jSUwuTGa+ktTIm78Ff8KrQ6sFSeqtskwdvLte5pclErdiRTPSCGxUu8jeqQM9q/ytsf2flWEXqLxoTuHWe9w+kylbimm3nQclViY3dX6ib7H6TYZkQE/GfFg9C+B5PfN6MaE83hSbQW8= - TRACKED_PLAYERS_WEBHOOK: AgAObYj3o8BC0xnSOSSKzXw6ChSaAfax/X04isNQNRppygIkACuli9u0ywvcG3q0uneiqzr2XLj1gKzF1nknufn3QnPsina6LcuGsvok7HbzjSy9MSHjq+/9O+S5GSYuY5rhQIlpXXzhmt5HJjbX2KSxoq6CMaJYKTzEL1mkjWHgXnECbFHlANwJFRfCI6lUblz6Kvb3yjKAR3HAqo7P4c5YoI3N7ZBW77y+vgZiM+n7Iwpq1liFLW68gg1t8kGY/y+OeUdTfcyf4svhPg4la+gUHCLwufxfSI3vQDZcndFGBA2MPJ/Eoc2itKoBMzA66w3CQPU8nAS5qBln51OASqCsvX6/Ipd0bFZyBrU7j8jPN1gpaWOIwMEhKT2t5nmLyzXN1aaYv8vl9hY6NpFN6T2QOBFsJ849KyoXdN6wfmCV5rPL9blSLSAuS0sy4LOvR5COgsZyCwUycRb8ZLq/gg+r97ySPYliQnuVbMcoce78+YyZGZn3+5T3tRQQ/E7qx7ZEgALKXGwL97LDuTXmuV8BNT0fwnqWqR5n0ZQvjo7FuxQAC4XufBAAdsxb429qflDfpZf5PYOoDUnTdmx2g7enmBb/WDa3Vxz4LTxollRf31HtkH9d02EVP0JQZ7u3fQkeEm3RKffeyNoIy/1K3+PdaVGre8PEuPRTF0RnLYQjyVka4sbJJwX1MvR/6uQX4iKF50dWfohcnt8z44chQKQQhQbzK6tVUIbfhN3XT4b1SWbbMxVXTxxtXjVVnE85yQ7XEo9f1RHbDi3WL2MMnFBnYxwjdHWcZhiHhppIVa+lsGCoO2i+HN4BEfwWP9oecgdLhSZ4OLC9Ps+z9QzF9jDEQuMML5Hpj7zZ + MONGO_URI: AgC1aNi0ISr4nYuMufqC0LK769TzVltSosJZXXJ2fxYwFNylIVUzolc1QrMmMBZLGDy1Jr7aOVKCz58LK0xUd2JIlejuzVHcwu7m6l0Qkqv8ghGgZ5CF7w2vlqWXnhBOffmUjvlrWB0UXSeY50M/0M5c8VcvbnEyoQ4+00cA/VJmzoWbZ0P573IQgRax6TZa7wTjLjKcxODFmtitxPZGAio1tEkqDmbvxbBGYHdDj/ZRfUH1FDbcMjlLhFu/l46zYAYW33372J1qTwL8/111XqJREvmsEna/CtpGoqBkPI0MOH6Tm5ggN8GnpmKbZby2eDgLAptu3rqQYAFrdrwAfUBoPrATYYTgyfe9quYJlZj8cxNVNH/y5fVdZZQWJLzqzPSjww6BV9SfTzU1Eo9/cdEKaGWjZsDsYYgicvkj1GLhiN/qPKMmdatF48x02oefT51toFIjb6lu+s0bqDVnk/w07w0ASN1VbJL5s6Z1/aqeZIWYGcRdpj500UI4zVQuI+3AYEBJJzGSYQNluNhhJBv9TAhh2TddTkENEijLJLjthke0yztDvNRrIXhziKOr8TWhQcIv5Hb0kg0J+Cvq/9Fu1BlDydHNcIc4/a3OhHPnqfhVlRwiCjJ8I56wYIQjkLoKK80qjBs86RCC/sKC9w65+deG4KSsclpDY8kNpf9MKg9OreLFMneN6CvQR19yEIaqCoA9moyeyY+EYvgioN4lTjlg0Kbn6D65yleQiO0LEYYutYAZRdJuLI/0oLHtRLsnr0+FL+SDAqGKPyNZ9JNpBCGPVml4Y2Czh4qCV24CxulbAIAiBmbcQos= + NUMBER_ONE_WEBHOOK: AgAsmhgyllPfA0/z+vFG/cwT7IOix/x7QG/5SR0xfCSOXwn/oD9Y2MpAVegjFlQ1nEh5o/pVFqPKY44VtoNXy+8R8rnRK+gvRraYliobHBJf8OBNgW8B24lbg8RfO+ll84VrdV4tmK1TTaliuHpb+CyP2i4VMD54Zgu2xqoJzWSS4TGkzaECSr9kEsmW0mPf1wlVxWq3QKZIjJrZn5B0qM8qoUS1Q78eN297Lz4y000Ncb7dR4FybrQdBBcFGkGrPvFyMSVASK8AwGooMSxAU6PcE7XHGUgN8KofQ94CSrtlngrdcq/5XapUU5my2EkWB1/qsEhBwHj1f7HiRxPbBosvc6beszif65c97mEc7GANdDN9H4ywo6Gba/pChnd2EVn+Rr41UlnkPlhjjIuLSe32LdWXZMkjohuuhbmTH38KtYd1rwA9rYVUHVnJW9mIvteA/eiEdvGzcdppxek/ywxv1lwB5eifX/e2ScaRtREot9q7N7XbyUOwKbVJLIpAlhEWREZmwtOdka6xJEWSHNVPKM1M3WO3KyaQKz01P0ADamCcQs5timHArizDFWK28FgVtNR480WBfd19xwYxh8hC3Z+a4LQfGUy/ZPIoan7cQ55S0bnROWTblXRSkrGAWdF5b3dc5ltiE1+OnE1gVx2/KMg17treDkbeG31KkmISfIZA7gUhP/vfagD0ljEb5cpiYo+cOqBrkomW3BKGHmJqebS+EF9+eGA/xAQopQrxQCOzp1CLq+rjW5UPBCICNpaUSMKkln1sRLamwoobc8By8DLcNks743gDOn2+igYj+twoD+mRwY2EK85tBMzuHali1iTHU3B4kxRsf0Z9YE1FJl2l+ugo4H3J + TRACKED_PLAYERS_WEBHOOK: AgCPeq3/S63zyqlzYuLncX5BcAagVwVu/nDhRl0e5NmBctZ2eZ2bq1z7rwz7LldVDIs/gAGWe4WzrxPzDTLu1xgTnvzCrSDuZQHsvHph8v9obz+8qSbogtRrPmRkPgIXFJ0KTN1B64aarPBzeAsUW/BMe2M+UeT44JVyVLT3Z1Pq/+a4g2Bc6FLlFiJMBFnGtYsKE4OMhpvKK31S6yG4+OEcYL+RLbSwGPHOsQDh9hYoOYekkAtcOp2+0Ee972dgBq3qbYMDRL29ETBuof4TxasHDwRE9L+HElL1RvsfU+wKfLuaq5Uhm6pNb+zhBtQd8M6XuRpNUQ2J2L0WAWBTdK98unej3ebUnsIIan0l0LfBa5ZqnG3YB9+aaAntx+l/ZQztHlUIOwmRQm0p1hjJoZXhNLWrzNtRLMGbvyJMeSPvuwxoHh3qlcgbC2opMpX6yYRzTIMy6ZVJDBnTIMDYXESIDW/t70v6SqBWens++XMm20tHAAEQF/0gVw3wYZ7Z+EOHH/By7YFGC79xakb8d6Fh98V/s4KeDpxPJSekZEo7ETHOsju5ApPW+6CS1weWGrQPP2+SU5vAysEcu+Mjvs+xWHgxML5YPBfjfpdCMtpA1P0Cdsr28WYWYdUTv8FZbSna+I4oqxn0sx5ONv5VMAUN0Cd4OdfCfiiHgWWGfGHB3QQHvQuPpwR2nl0mrQHEQkaCuUSDNDvrs/h6fXLgd5JsgEz1CeBx5A+j4h03TB1sRIN8KmcC3vgwEIyEoEoqTdJDbo07ttzs41f0i01GFwT/hO9KIxGwTW6yr/iRfu29MJL11xKhVPKx5BOZcSaK2fBq/jMW8rQJbHqMukrHzo+urNzY9iH+wcpq template: metadata: creationTimestamp: null diff --git a/bun.lockb b/bun.lockb index ac67934..9512872 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/projects/backend/Dockerfile b/projects/backend/Dockerfile index ecb4bbd..2a5ebb2 100644 --- a/projects/backend/Dockerfile +++ b/projects/backend/Dockerfile @@ -1,5 +1,18 @@ FROM oven/bun:1.1.30-alpine AS base +# Install system dependencies for node-canvas +RUN apk add --no-cache \ + build-base \ + cairo-dev \ + pango-dev \ + giflib-dev \ + libjpeg-turbo-dev \ + freetype-dev \ + fontconfig-dev \ + pixman-dev \ + python3 \ + pkgconfig + # Install dependencies FROM base AS depends WORKDIR /app @@ -24,4 +37,4 @@ RUN bun --filter '@ssr/common' build # Copy the backend project COPY --from=depends /app/projects/backend ./projects/backend -CMD ["bun", "run", "--filter", "backend", "start"] \ No newline at end of file +CMD ["bun", "run", "--filter", "backend", "start"] diff --git a/projects/backend/package.json b/projects/backend/package.json index 8e628b1..45399f1 100644 --- a/projects/backend/package.json +++ b/projects/backend/package.json @@ -15,12 +15,14 @@ "@tqman/nice-logger": "^1.0.1", "@typegoose/typegoose": "^12.8.0", "@vercel/og": "^0.6.3", + "canvas": "^3.0.0-rc2", "discord-webhook-node": "^1.1.8", "elysia": "latest", "elysia-autoroutes": "^0.5.0", "elysia-decorators": "^1.0.2", "elysia-helmet": "^2.0.0", "elysia-rate-limit": "^4.1.0", + "extract-colors": "^4.1.0", "ky": "^1.7.2", "mongoose": "^8.7.0", "node-cache": "^5.1.2", diff --git a/projects/backend/src/common/config.ts b/projects/backend/src/common/config.ts index 7077c4a..aeca6ca 100644 --- a/projects/backend/src/common/config.ts +++ b/projects/backend/src/common/config.ts @@ -2,4 +2,5 @@ export const Config = { mongoUri: process.env.MONGO_URI, apiUrl: process.env.API_URL || "https://ssr.fascinated.cc/api", trackedPlayerWebhook: process.env.TRACKED_PLAYERS_WEBHOOK, + numberOneWebhook: process.env.NUMBER_ONE_WEBHOOK, }; diff --git a/projects/backend/src/controller/image.controller.ts b/projects/backend/src/controller/image.controller.ts index c94ed3a..e695a39 100644 --- a/projects/backend/src/controller/image.controller.ts +++ b/projects/backend/src/controller/image.controller.ts @@ -4,6 +4,16 @@ import { ImageService } from "../service/image.service"; @Controller("/image") export default class ImageController { + @Get("/averagecolor/:url", { + config: {}, + params: t.Object({ + url: t.String({ required: true }), + }), + }) + public async getImageAverageColor({ params: { url } }: { params: { url: string } }) { + return await ImageService.getAverageImageColor(url); + } + @Get("/player/:id", { config: {}, params: t.Object({ diff --git a/projects/backend/src/index.ts b/projects/backend/src/index.ts index ed1c7b1..6c711f7 100644 --- a/projects/backend/src/index.ts +++ b/projects/backend/src/index.ts @@ -21,6 +21,10 @@ 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"; +// @ts-ignore +import { MessageBuilder, Webhook } from "discord-webhook-node"; +import { formatPp } from "@ssr/common/utils/number-utils"; +import { ScoreService } from "./service/score.service"; // Load .env file dotenv.config({ @@ -33,8 +37,9 @@ await mongoose.connect(Config.mongoUri!); // Connect to MongoDB setLogLevel("DEBUG"); connectScoreSaberWebSocket({ - onScore: async score => { - await PlayerService.trackScore(score); + onScore: async playerScore => { + await PlayerService.trackScore(playerScore); + await ScoreService.notifyNumberOne(playerScore); }, }); diff --git a/projects/backend/src/service/image.service.tsx b/projects/backend/src/service/image.service.tsx index a1e8225..24a96a6 100644 --- a/projects/backend/src/service/image.service.tsx +++ b/projects/backend/src/service/image.service.tsx @@ -9,11 +9,58 @@ import NodeCache from "node-cache"; 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"; +import ky from "ky"; +import { createCanvas, loadImage } from "canvas"; +import { extractColors } from "extract-colors"; const cache = new NodeCache({ stdTTL: 60 * 60, checkperiod: 120 }); const imageOptions = { width: 1200, height: 630 }; export class ImageService { + /** + * Gets the average color of an image + * + * @param src the image url + * @returns the average color + * @private + */ + public static async getAverageImageColor(src: string): Promise<{ color: string } | undefined> { + src = decodeURIComponent(src); + + return await this.fetchWithCache<{ color: string }>(`average_color-${src}`, async () => { + try { + const response = await ky.get(src); + if (response.status !== 200) { + throw new Error(`Failed to fetch image: ${src}`); + } + + const imageBuffer = await response.arrayBuffer(); + + // Create an image from the buffer using canvas + const img = await loadImage(Buffer.from(imageBuffer)); + const canvas = createCanvas(img.width, img.height); + const ctx = canvas.getContext("2d"); + + // Draw the image onto the canvas + ctx.drawImage(img, 0, 0); + + // Get the pixel data from the canvas + const imageData = ctx.getImageData(0, 0, img.width, img.height); + const { data, width, height } = imageData; + + // Extract the colors + const color = await extractColors({ data, width, height }); + return { + color: color[2].hex, + }; + } catch (error) { + return { + color: "#fff", + }; + } + }); + } + /** * Fetches data with caching. * diff --git a/projects/backend/src/service/score.service.ts b/projects/backend/src/service/score.service.ts new file mode 100644 index 0000000..ce6fbba --- /dev/null +++ b/projects/backend/src/service/score.service.ts @@ -0,0 +1,36 @@ +import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token"; +// @ts-ignore +import { MessageBuilder, Webhook } from "discord-webhook-node"; +import { Config } from "../common/config"; +import { formatPp } from "@ssr/common/utils/number-utils"; + +export class ScoreService { + public static async notifyNumberOne(playerScore: ScoreSaberPlayerScoreToken) { + const { score, leaderboard } = playerScore; + const player = score.leaderboardPlayerInfo; + + // Not ranked + if (leaderboard.stars <= 0) { + return; + } + // Not #1 rank + if (score.rank !== 1) { + return; + } + + const hook = new Webhook({ + url: Config.numberOneWebhook, + }); + hook.setUsername("Number One Feed"); + const embed = new MessageBuilder(); + embed.setTitle(`${player.name} set a #${score.rank} on ${leaderboard.songName} ${leaderboard.songSubName}`); + embed.setDescription(` + **Player:** https://ssr.fascinated.cc/player/${player.id} + **Leaderboard:** https://ssr.fascinated.cc/leaderboard/${leaderboard.id} + **PP:** ${formatPp(score.pp)} + `); + embed.setThumbnail(leaderboard.coverImage); + embed.setColor("#00ff00"); + await hook.send(embed); + } +} diff --git a/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx b/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx index fbe76a8..b8a10a8 100644 --- a/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx +++ b/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx @@ -103,14 +103,8 @@ export async function generateViewport(props: Props): Promise { } const color = await getAverageColor(leaderboard.coverImage); - if (color === undefined) { - return { - themeColor: Colors.primary, - }; - } - return { - themeColor: color?.hex, + themeColor: color.hex, }; } diff --git a/projects/website/src/app/(pages)/player/[...slug]/page.tsx b/projects/website/src/app/(pages)/player/[...slug]/page.tsx index ec33d48..8b20438 100644 --- a/projects/website/src/app/(pages)/player/[...slug]/page.tsx +++ b/projects/website/src/app/(pages)/player/[...slug]/page.tsx @@ -117,14 +117,8 @@ export async function generateViewport(props: Props): Promise { } const color = await getAverageColor(player.avatar); - if (color === undefined) { - return { - themeColor: Colors.primary, - }; - } - return { - themeColor: color?.hex, + themeColor: color.hex, }; } diff --git a/projects/website/src/common/image-utils.ts b/projects/website/src/common/image-utils.ts index 19060c0..46ee234 100644 --- a/projects/website/src/common/image-utils.ts +++ b/projects/website/src/common/image-utils.ts @@ -1,4 +1,6 @@ import { config } from "../../config"; +import ky from "ky"; +import { Colors } from "@/common/colors"; /** * Proxies all non-localhost images to make them load faster. @@ -17,7 +19,11 @@ export function getImageUrl(originalUrl: string) { * @returns the average color */ export const getAverageColor = async (src: string) => { - return { - hex: "#fff", - }; + try { + return await ky.get<{ hex: string }>(`${config.siteApi}/image/averagecolor/${encodeURIComponent(src)}`).json(); + } catch { + return { + hex: Colors.primary, + }; + } };