diff --git a/projects/backend/src/common/embds.ts b/projects/backend/src/common/embds.ts new file mode 100644 index 0000000..d1eef96 --- /dev/null +++ b/projects/backend/src/common/embds.ts @@ -0,0 +1,37 @@ +import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; +import { DiscordChannels, logToChannel } from "../bot/bot"; +import { EmbedBuilder } from "discord.js"; +import { formatPp } from "@ssr/common/utils/number-utils"; + +/** + * Logs that a new player is being tracked + * + * @param player the player being tracked + */ +export async function logNewTrackedPlayer(player: ScoreSaberPlayerToken) { + await logToChannel( + DiscordChannels.trackedPlayerLogs, + new EmbedBuilder() + .setTitle("New Player Tracked") + .setDescription(`https://ssr.fascinated.cc/player/${player.id}`) + .addFields([ + { + name: "Username", + value: player.name, + inline: true, + }, + { + name: "ID", + value: player.id, + inline: true, + }, + { + name: "PP", + value: formatPp(player.pp) + "pp", + inline: true, + }, + ]) + .setThumbnail(player.profilePicture) + .setColor("#00ff00") + ); +} diff --git a/projects/backend/src/service/player.service.ts b/projects/backend/src/service/player.service.ts index eb30083..9785c70 100644 --- a/projects/backend/src/service/player.service.ts +++ b/projects/backend/src/service/player.service.ts @@ -4,89 +4,88 @@ import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-u import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; import { InternalServerError } from "@ssr/common/error/internal-server-error"; -import { formatPp } from "@ssr/common/utils/number-utils"; import { delay, getPageFromRank, isProduction } from "@ssr/common/utils/utils"; -import { DiscordChannels, logToChannel } from "../bot/bot"; -import { EmbedBuilder } from "discord.js"; import { AroundPlayer } from "@ssr/common/types/around-player"; import { ScoreSort } from "@ssr/common/score/score-sort"; import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators"; import { ScoreService } from "./score.service"; +import { logNewTrackedPlayer } from "../common/embds"; + +const SCORESABER_REQUEST_COOLDOWN = 60_000 / 250; // 250 requests per minute +const accountCreationLock: { [id: string]: Promise } = {}; export class PlayerService { - /** - * Get a player from the database. - * - * @param id the player to fetch - * @param create if true, create the player if it doesn't exist - * @param playerToken an optional player token for the player - * @returns the player - * @throws NotFoundError if the player is not found - */ public static async getPlayer( id: string, create: boolean = false, playerToken?: ScoreSaberPlayerToken ): Promise { + // Wait for the existing lock if it's in progress + if (accountCreationLock[id] !== undefined) { + await accountCreationLock[id]; + } + let player: PlayerDocument | null = await PlayerModel.findById(id); + if (player === null) { - // If create is on, create the player, otherwise return unknown player - playerToken = create ? (playerToken ? playerToken : await scoresaberService.lookupPlayer(id)) : undefined; - if (playerToken === undefined) { + if (!create) { throw new NotFoundError(`Player "${id}" not found`); } - console.log(`Creating player "${id}"...`); - try { - player = (await PlayerModel.create({ _id: id })) as PlayerDocument; - player.trackedSince = new Date(); - await this.seedPlayerHistory(player, playerToken); + playerToken = playerToken || (await scoresaberService.lookupPlayer(id)); - // Only notify in production - if (isProduction()) { - await logToChannel( - DiscordChannels.trackedPlayerLogs, - new EmbedBuilder() - .setTitle("New Player Tracked") - .setDescription(`https://ssr.fascinated.cc/player/${playerToken.id}`) - .addFields([ - { - name: "Username", - value: playerToken.name, - inline: true, - }, - { - name: "ID", - value: playerToken.id, - inline: true, - }, - { - name: "PP", - value: formatPp(playerToken.pp) + "pp", - inline: true, - }, - ]) - .setThumbnail(playerToken.profilePicture) - .setColor("#00ff00") - ); - } - } catch (err) { - const message = `Failed to create player document for "${id}"`; - console.log(message, err); - throw new InternalServerError(message); + if (!playerToken) { + throw new NotFoundError(`Player "${id}" not found`); } + + // Create a new lock promise and assign it + accountCreationLock[id] = (async () => { + let newPlayer: PlayerDocument; + try { + console.log(`Creating player "${id}"...`); + newPlayer = (await PlayerModel.create({ _id: id })) as PlayerDocument; + newPlayer.trackedSince = new Date(); + await newPlayer.save(); + + await this.seedPlayerHistory(newPlayer.id, playerToken); + await this.trackPlayerScores(newPlayer.id, SCORESABER_REQUEST_COOLDOWN); + + // Notify in production + if (isProduction()) { + await logNewTrackedPlayer(playerToken); + } + } catch (err) { + console.log(`Failed to create player document for "${id}"`, err); + throw new InternalServerError(`Failed to create player document for "${id}"`); + } finally { + // Ensure the lock is always removed + delete accountCreationLock[id]; + } + + return newPlayer; + })(); + + // Wait for the player creation to complete + player = await accountCreationLock[id]; } - return player; + + // Ensure that the player is now of type PlayerDocument + return player as PlayerDocument; } /** * Seeds the player's history using data from * the ScoreSaber API. * - * @param player the player to seed + * @param playerId the player id * @param playerToken the SoreSaber player token */ - public static async seedPlayerHistory(player: PlayerDocument, playerToken: ScoreSaberPlayerToken): Promise { + public static async seedPlayerHistory(playerId: string, playerToken: ScoreSaberPlayerToken): Promise { + const player = await PlayerModel.findById(playerId); + if (player == null) { + throw new NotFoundError(`Player "${playerId}" not found`); + } + // Loop through rankHistory in reverse, from current day backwards const playerRankHistory = playerToken.histories.split(",").map((value: string) => { return parseInt(value); @@ -134,7 +133,7 @@ export class PlayerService { // Seed the history with ScoreSaber data if no history exists if (foundPlayer.getDaysTracked() === 0) { - await this.seedPlayerHistory(foundPlayer, player); + await this.seedPlayerHistory(foundPlayer.id, player); } // Update current day's statistics @@ -247,66 +246,80 @@ export class PlayerService { return players.slice(start, end); } + /** + * Tracks a player's scores from the ScoreSaber API. + * + * @param playerId the player's id + * @param perPageCooldown the cooldown between pages + */ + public static async trackPlayerScores(playerId: string, perPageCooldown: number) { + const player = await PlayerModel.findById(playerId); + if (player == null) { + throw new NotFoundError(`Player "${playerId}" not found`); + } + + console.log(`Refreshing scores for ${player.id}...`); + let page = 1; + let hasMorePages = true; + + while (hasMorePages) { + const scoresPage = await scoresaberService.lookupPlayerScores({ + playerId: player.id, + page: page, + limit: 100, + sort: ScoreSort.recent, + }); + + if (!scoresPage) { + console.warn(`Failed to fetch scores for ${player.id} on page ${page}.`); + break; + } + + let missingScores = 0; + for (const score of scoresPage.playerScores) { + const leaderboard = getScoreSaberLeaderboardFromToken(score.leaderboard); + const scoreSaberScore = await ScoreService.getScoreSaberScore( + player.id, + leaderboard.id + "", + leaderboard.difficulty.difficulty, + leaderboard.difficulty.characteristic, + score.score.baseScore + ); + + if (scoreSaberScore == null) { + missingScores++; + } + await ScoreService.trackScoreSaberScore(score.score, score.leaderboard, player.id); + } + + // Stop paginating if no scores are missing OR if player has seededScores marked true + if ((missingScores === 0 && player.seededScores) || page >= Math.ceil(scoresPage.metadata.total / 100)) { + hasMorePages = false; + } + + page++; + await delay(perPageCooldown); // Cooldown between page requests + } + + // Mark player as seeded + player.seededScores = true; + await player.save(); + + console.log(`Finished refreshing scores for ${player.id}, total pages refreshed: ${page - 1}.`); + } + /** * Ensures all player scores are up-to-date. */ public static async refreshPlayerScores() { - const cooldown = 60_000 / 250; // 250 requests per minute console.log(`Refreshing player score data...`); const players = await PlayerModel.find({}); console.log(`Found ${players.length} players to refresh.`); for (const player of players) { - console.log(`Refreshing scores for ${player.id}...`); - let page = 1; - let hasMorePages = true; - - while (hasMorePages) { - const scoresPage = await scoresaberService.lookupPlayerScores({ - playerId: player.id, - page: page, - limit: 100, - sort: ScoreSort.recent, - }); - - if (!scoresPage) { - console.warn(`Failed to fetch scores for ${player.id} on page ${page}.`); - break; - } - - let missingScores = 0; - for (const score of scoresPage.playerScores) { - const leaderboard = getScoreSaberLeaderboardFromToken(score.leaderboard); - const scoreSaberScore = await ScoreService.getScoreSaberScore( - player.id, - leaderboard.id + "", - leaderboard.difficulty.difficulty, - leaderboard.difficulty.characteristic, - score.score.baseScore - ); - - if (scoreSaberScore == null) { - missingScores++; - } - await ScoreService.trackScoreSaberScore(score.score, score.leaderboard, player.id); - } - - // Stop paginating if no scores are missing OR if player has seededScores marked true - if ((missingScores === 0 && player.seededScores) || page >= Math.ceil(scoresPage.metadata.total / 100)) { - hasMorePages = false; - } - - page++; - await delay(cooldown); // Cooldown between page requests - } - - // Mark player as seeded - player.seededScores = true; - await player.save(); - - console.log(`Finished refreshing scores for ${player.id}, total pages refreshed: ${page - 1}.`); - await delay(cooldown); // Cooldown between players + await this.trackPlayerScores(player.id, SCORESABER_REQUEST_COOLDOWN); + await delay(SCORESABER_REQUEST_COOLDOWN); // Cooldown between players } } @@ -315,7 +328,6 @@ export class PlayerService { */ public static async updatePlayerStatistics() { const pages = 20; // top 1000 players - const cooldown = 60_000 / 250; // 250 requests per minute let toTrack: PlayerDocument[] = await PlayerModel.find({}); const toRemoveIds: string[] = []; @@ -328,7 +340,7 @@ export class PlayerService { const page = await scoresaberService.lookupPlayers(pageNumber); if (page === undefined) { console.log(`Failed to fetch players on page ${pageNumber}, skipping page...`); - await delay(cooldown); + await delay(SCORESABER_REQUEST_COOLDOWN); continue; } for (const player of page.players) { @@ -336,7 +348,7 @@ export class PlayerService { await PlayerService.trackScoreSaberPlayer(foundPlayer, player); toRemoveIds.push(foundPlayer.id); } - await delay(cooldown); + await delay(SCORESABER_REQUEST_COOLDOWN); } console.log(`Finished tracking player statistics for ${pages} pages, found ${toRemoveIds.length} players.`); @@ -346,7 +358,7 @@ export class PlayerService { console.log(`Tracking ${toTrack.length} player statistics...`); for (const player of toTrack) { await PlayerService.trackScoreSaberPlayer(player); - await delay(cooldown); + await delay(SCORESABER_REQUEST_COOLDOWN); } console.log("Finished tracking player statistics."); }