cleanup and track player history when creating the player instead of waiting for the cron job
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
This commit is contained in:
parent
de3768559f
commit
28e8561020
37
projects/backend/src/common/embds.ts
Normal file
37
projects/backend/src/common/embds.ts
Normal file
@ -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")
|
||||||
|
);
|
||||||
|
}
|
@ -4,89 +4,88 @@ import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-u
|
|||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||||
import { InternalServerError } from "@ssr/common/error/internal-server-error";
|
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 { 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 { AroundPlayer } from "@ssr/common/types/around-player";
|
||||||
import { ScoreSort } from "@ssr/common/score/score-sort";
|
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||||
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators";
|
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators";
|
||||||
import { ScoreService } from "./score.service";
|
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<PlayerDocument> } = {};
|
||||||
|
|
||||||
export class PlayerService {
|
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(
|
public static async getPlayer(
|
||||||
id: string,
|
id: string,
|
||||||
create: boolean = false,
|
create: boolean = false,
|
||||||
playerToken?: ScoreSaberPlayerToken
|
playerToken?: ScoreSaberPlayerToken
|
||||||
): Promise<PlayerDocument> {
|
): Promise<PlayerDocument> {
|
||||||
|
// 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);
|
let player: PlayerDocument | null = await PlayerModel.findById(id);
|
||||||
|
|
||||||
if (player === null) {
|
if (player === null) {
|
||||||
// If create is on, create the player, otherwise return unknown player
|
if (!create) {
|
||||||
playerToken = create ? (playerToken ? playerToken : await scoresaberService.lookupPlayer(id)) : undefined;
|
|
||||||
if (playerToken === undefined) {
|
|
||||||
throw new NotFoundError(`Player "${id}" not found`);
|
throw new NotFoundError(`Player "${id}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Creating player "${id}"...`);
|
playerToken = playerToken || (await scoresaberService.lookupPlayer(id));
|
||||||
try {
|
|
||||||
player = (await PlayerModel.create({ _id: id })) as PlayerDocument;
|
|
||||||
player.trackedSince = new Date();
|
|
||||||
await this.seedPlayerHistory(player, playerToken);
|
|
||||||
|
|
||||||
// Only notify in production
|
if (!playerToken) {
|
||||||
if (isProduction()) {
|
throw new NotFoundError(`Player "${id}" not found`);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
* Seeds the player's history using data from
|
||||||
* the ScoreSaber API.
|
* the ScoreSaber API.
|
||||||
*
|
*
|
||||||
* @param player the player to seed
|
* @param playerId the player id
|
||||||
* @param playerToken the SoreSaber player token
|
* @param playerToken the SoreSaber player token
|
||||||
*/
|
*/
|
||||||
public static async seedPlayerHistory(player: PlayerDocument, playerToken: ScoreSaberPlayerToken): Promise<void> {
|
public static async seedPlayerHistory(playerId: string, playerToken: ScoreSaberPlayerToken): Promise<void> {
|
||||||
|
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
|
// Loop through rankHistory in reverse, from current day backwards
|
||||||
const playerRankHistory = playerToken.histories.split(",").map((value: string) => {
|
const playerRankHistory = playerToken.histories.split(",").map((value: string) => {
|
||||||
return parseInt(value);
|
return parseInt(value);
|
||||||
@ -134,7 +133,7 @@ export class PlayerService {
|
|||||||
|
|
||||||
// Seed the history with ScoreSaber data if no history exists
|
// Seed the history with ScoreSaber data if no history exists
|
||||||
if (foundPlayer.getDaysTracked() === 0) {
|
if (foundPlayer.getDaysTracked() === 0) {
|
||||||
await this.seedPlayerHistory(foundPlayer, player);
|
await this.seedPlayerHistory(foundPlayer.id, player);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update current day's statistics
|
// Update current day's statistics
|
||||||
@ -247,66 +246,80 @@ export class PlayerService {
|
|||||||
return players.slice(start, end);
|
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.
|
* Ensures all player scores are up-to-date.
|
||||||
*/
|
*/
|
||||||
public static async refreshPlayerScores() {
|
public static async refreshPlayerScores() {
|
||||||
const cooldown = 60_000 / 250; // 250 requests per minute
|
|
||||||
console.log(`Refreshing player score data...`);
|
console.log(`Refreshing player score data...`);
|
||||||
|
|
||||||
const players = await PlayerModel.find({});
|
const players = await PlayerModel.find({});
|
||||||
console.log(`Found ${players.length} players to refresh.`);
|
console.log(`Found ${players.length} players to refresh.`);
|
||||||
|
|
||||||
for (const player of players) {
|
for (const player of players) {
|
||||||
console.log(`Refreshing scores for ${player.id}...`);
|
await this.trackPlayerScores(player.id, SCORESABER_REQUEST_COOLDOWN);
|
||||||
let page = 1;
|
await delay(SCORESABER_REQUEST_COOLDOWN); // Cooldown between players
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,7 +328,6 @@ export class PlayerService {
|
|||||||
*/
|
*/
|
||||||
public static async updatePlayerStatistics() {
|
public static async updatePlayerStatistics() {
|
||||||
const pages = 20; // top 1000 players
|
const pages = 20; // top 1000 players
|
||||||
const cooldown = 60_000 / 250; // 250 requests per minute
|
|
||||||
|
|
||||||
let toTrack: PlayerDocument[] = await PlayerModel.find({});
|
let toTrack: PlayerDocument[] = await PlayerModel.find({});
|
||||||
const toRemoveIds: string[] = [];
|
const toRemoveIds: string[] = [];
|
||||||
@ -328,7 +340,7 @@ export class PlayerService {
|
|||||||
const page = await scoresaberService.lookupPlayers(pageNumber);
|
const page = await scoresaberService.lookupPlayers(pageNumber);
|
||||||
if (page === undefined) {
|
if (page === undefined) {
|
||||||
console.log(`Failed to fetch players on page ${pageNumber}, skipping page...`);
|
console.log(`Failed to fetch players on page ${pageNumber}, skipping page...`);
|
||||||
await delay(cooldown);
|
await delay(SCORESABER_REQUEST_COOLDOWN);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const player of page.players) {
|
for (const player of page.players) {
|
||||||
@ -336,7 +348,7 @@ export class PlayerService {
|
|||||||
await PlayerService.trackScoreSaberPlayer(foundPlayer, player);
|
await PlayerService.trackScoreSaberPlayer(foundPlayer, player);
|
||||||
toRemoveIds.push(foundPlayer.id);
|
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.`);
|
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...`);
|
console.log(`Tracking ${toTrack.length} player statistics...`);
|
||||||
for (const player of toTrack) {
|
for (const player of toTrack) {
|
||||||
await PlayerService.trackScoreSaberPlayer(player);
|
await PlayerService.trackScoreSaberPlayer(player);
|
||||||
await delay(cooldown);
|
await delay(SCORESABER_REQUEST_COOLDOWN);
|
||||||
}
|
}
|
||||||
console.log("Finished tracking player statistics.");
|
console.log("Finished tracking player statistics.");
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user