Change all CRLF to LF and standardise on LF

This commit is contained in:
zkldi
2021-11-16 12:44:28 +00:00
parent 6aeaf4b853
commit b9be948290
49 changed files with 7248 additions and 7248 deletions
+53 -53
View File
@@ -1,53 +1,53 @@
import db from "external/mongo/db";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { DecrementCounterValue, GetNextCounterValue } from "./db";
t.test("#GetNextCounterValue", (t) => {
t.beforeEach(ResetDBState);
t.test("Increments on valid counter hit", async (t) => {
const response = await GetNextCounterValue("real-counter");
// database starts with this at one
t.equal(response, 2, "Counter should return the current number stored");
const dbData = await db.counters.findOne({
counterName: "real-counter",
});
t.equal(dbData!.value, 3, "Counter should increment after being hit");
});
t.rejects(
() => GetNextCounterValue("fake-counter"),
"Could not find sequence document for fake-counter."
);
t.end();
});
t.test("#DecrementCounterValue", (t) => {
t.beforeEach(ResetDBState);
t.test("Should decrease a counter.", async (t) => {
await db.counters.insert({ counterName: "foo", value: 3 });
const res = await DecrementCounterValue("foo");
t.equal(res, 2);
const doc = await db.counters.findOne({ counterName: "foo" });
t.equal(doc?.value, 2);
t.end();
});
t.rejects(
() => GetNextCounterValue("fake-counter"),
"Could not find sequence document for fake-counter."
);
t.end();
});
import db from "external/mongo/db";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { DecrementCounterValue, GetNextCounterValue } from "./db";
t.test("#GetNextCounterValue", (t) => {
t.beforeEach(ResetDBState);
t.test("Increments on valid counter hit", async (t) => {
const response = await GetNextCounterValue("real-counter");
// database starts with this at one
t.equal(response, 2, "Counter should return the current number stored");
const dbData = await db.counters.findOne({
counterName: "real-counter",
});
t.equal(dbData!.value, 3, "Counter should increment after being hit");
});
t.rejects(
() => GetNextCounterValue("fake-counter"),
"Could not find sequence document for fake-counter."
);
t.end();
});
t.test("#DecrementCounterValue", (t) => {
t.beforeEach(ResetDBState);
t.test("Should decrease a counter.", async (t) => {
await db.counters.insert({ counterName: "foo", value: 3 });
const res = await DecrementCounterValue("foo");
t.equal(res, 2);
const doc = await db.counters.findOne({ counterName: "foo" });
t.equal(doc?.value, 2);
t.end();
});
t.rejects(
() => GetNextCounterValue("fake-counter"),
"Could not find sequence document for fake-counter."
);
t.end();
});
+70 -70
View File
@@ -1,70 +1,70 @@
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
const logger = CreateLogCtx(__filename);
import { integer, Game, ScoreDocument, PBScoreDocument } from "tachi-common";
export async function GetNextCounterValue(counterName: string): Promise<integer> {
const sequenceDoc = await db.counters.findOneAndUpdate(
{
counterName,
},
{
$inc: {
value: 1,
},
},
{
// this is marked as deprecated, but it shouldn't be, as returnDocument: "before"
// does nothing.
returnOriginal: true,
}
);
if (!sequenceDoc) {
logger.error(`Could not find sequence document for ${counterName}`);
throw new Error(`Could not find sequence document for ${counterName}.`);
}
return sequenceDoc.value;
}
export async function DecrementCounterValue(counterName: string): Promise<integer> {
logger.verbose(`Decrementing Counter Value ${counterName}.`);
const sequenceDoc = await db.counters.findOneAndUpdate(
{
counterName,
},
{
$inc: {
value: -1,
},
},
{
returnOriginal: false,
}
);
if (!sequenceDoc) {
logger.error(`Could not find sequence document for ${counterName}`);
throw new Error(`Could not find sequence document for ${counterName}.`);
}
return sequenceDoc.value;
}
export async function GetRelevantSongsAndCharts(
scores: (ScoreDocument | PBScoreDocument)[],
game: Game
) {
const [songs, charts] = await Promise.all([
db.songs[game].find({
id: { $in: scores.map((e) => e.songID) },
}),
db.charts[game].find({
chartID: { $in: scores.map((e) => e.chartID) },
}),
]);
return { songs, charts };
}
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
const logger = CreateLogCtx(__filename);
import { integer, Game, ScoreDocument, PBScoreDocument } from "tachi-common";
export async function GetNextCounterValue(counterName: string): Promise<integer> {
const sequenceDoc = await db.counters.findOneAndUpdate(
{
counterName,
},
{
$inc: {
value: 1,
},
},
{
// this is marked as deprecated, but it shouldn't be, as returnDocument: "before"
// does nothing.
returnOriginal: true,
}
);
if (!sequenceDoc) {
logger.error(`Could not find sequence document for ${counterName}`);
throw new Error(`Could not find sequence document for ${counterName}.`);
}
return sequenceDoc.value;
}
export async function DecrementCounterValue(counterName: string): Promise<integer> {
logger.verbose(`Decrementing Counter Value ${counterName}.`);
const sequenceDoc = await db.counters.findOneAndUpdate(
{
counterName,
},
{
$inc: {
value: -1,
},
},
{
returnOriginal: false,
}
);
if (!sequenceDoc) {
logger.error(`Could not find sequence document for ${counterName}`);
throw new Error(`Could not find sequence document for ${counterName}.`);
}
return sequenceDoc.value;
}
export async function GetRelevantSongsAndCharts(
scores: (ScoreDocument | PBScoreDocument)[],
game: Game
) {
const [songs, charts] = await Promise.all([
db.songs[game].find({
id: { $in: scores.map((e) => e.songID) },
}),
db.charts[game].find({
chartID: { $in: scores.map((e) => e.chartID) },
}),
]);
return { songs, charts };
}
+13 -13
View File
@@ -1,13 +1,13 @@
import { Environment } from "lib/setup/config";
import nodeFetch from "node-fetch";
const fetch =
Environment.nodeEnv === "test"
? () => {
throw new Error("Cannot use real fetch inside testing env!");
}
: nodeFetch;
export type NodeFetch = (url: RequestInfo, init?: RequestInit | undefined) => Promise<Response>;
export default fetch as unknown as NodeFetch;
import { Environment } from "lib/setup/config";
import nodeFetch from "node-fetch";
const fetch =
Environment.nodeEnv === "test"
? () => {
throw new Error("Cannot use real fetch inside testing env!");
}
: nodeFetch;
export type NodeFetch = (url: RequestInfo, init?: RequestInit | undefined) => Promise<Response>;
export default fetch as unknown as NodeFetch;
+319 -319
View File
@@ -1,319 +1,319 @@
import db from "external/mongo/db";
import { FilterQuery } from "mongodb";
import {
ChartDocument,
Difficulties,
Game,
GPTSupportedVersions,
IDStrings,
integer,
Playtypes,
} from "tachi-common";
export function FindChartWithChartID(game: Game, chartID: string) {
return db.charts[game].findOne({ chartID });
}
/**
* Find chart with PlaytypeDifficulty. This only finds charts that have `isPrimary` set to true.
* If you want to find charts that are not primary, you need to use PTDFVersion.
* @see FindChartWithPTDFVersion
*/
export function FindChartWithPTDF<
G extends Game = Game,
P extends Playtypes[G] = Playtypes[G],
I extends IDStrings = IDStrings
>(game: G, songID: integer, playtype: P, difficulty: Difficulties[I]) {
return db.charts[game].findOne({
songID: songID,
playtype: playtype,
difficulty: difficulty,
isPrimary: true,
});
}
/**
* Find chart with Playtype, Difficulty and a given version. This does not necessarily return a chart that has
* `isPrimary` set.
*/
export function FindChartWithPTDFVersion<
G extends Game = Game,
P extends Playtypes[G] = Playtypes[G],
I extends IDStrings = IDStrings
>(
game: G,
songID: integer,
playtype: P,
difficulty: Difficulties[I],
version: GPTSupportedVersions[I]
) {
return db.charts[game].findOne({
songID: songID,
playtype: playtype,
difficulty: difficulty,
versions: version,
});
}
/**
* Finds a DDR Chart based on its "song hash".
* Songs in DDR have a consistent checksum-like identifier used on the e-amusement website.
* We can use this to locate a chart by combining it with a playtype and difficulty.
*
* Despite the potentially confusing name of "songHash", songs are NOT meant to store lookup-like tokens.
* This is just for simplification reasons.
* @param songHash The identifier for the song.
* @param playtype The playtype for the chart.
* @param difficulty The difficulty for the chart.
*/
export function FindDDRChartOnSongHash(
songHash: string,
// Technically both of these should be "ddr" instead of Game, but it proves hard to work with.
playtype: Playtypes[Game],
difficulty: Difficulties[IDStrings]
) {
// note: this only works on accident because monk
// allows any strings like "foo.bar".
// We *should* normally cast this to ChartDocument<"ddr:SP" | "ddr:DP">
return db.charts.ddr.findOne({
"data.songHash": songHash,
playtype,
difficulty,
isPrimary: true,
});
}
/**
* Find a BMS chart on either its md5sum or its sha256sum.
* @param hash The md5 or sha256 hash to look for.
*/
export function FindBMSChartOnHash(hash: string) {
return db.charts.bms.findOne({
$or: [{ "data.hashMD5": hash }, { "data.hashSHA256": hash }],
});
}
/**
* Find a chart on its in-game-ID, playtype and difficulty.
*/
export function FindChartOnInGameID(
game: Game,
inGameID: number,
playtype: Playtypes[Game],
difficulty: Difficulties[IDStrings]
) {
if (game === "bms" || game === "usc") {
throw new Error(`Cannot call FindChartOnInGameID for game ${game}.`);
}
return db.charts[game].findOne({
"data.inGameID": inGameID,
playtype,
difficulty,
});
}
/**
* Finds a non-custom chart on its in-game-ID, playtype and difficulty.
* This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate.
*/
export function FindIIDXChartOnInGameID(
inGameID: number,
playtype: Playtypes[Game],
difficulty: Difficulties[IDStrings]
) {
return db.charts.iidx.findOne({
"data.inGameID": inGameID,
"data.2dxtraSet": null,
isPrimary: true,
playtype,
difficulty,
});
}
/**
* Finds a non-custom chart on its in-game-ID, playtype and difficulty.
* This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate.
*/
export function FindIIDXChartOnInGameIDVersion(
inGameID: number,
playtype: Playtypes[Game],
difficulty: Difficulties[IDStrings],
version: GPTSupportedVersions[IDStrings]
) {
return db.charts.iidx.findOne({
"data.inGameID": inGameID,
"data.2dxtraSet": null,
playtype,
difficulty,
versions: version,
});
}
/**
* Find a chart on its in-game-ID, playtype, difficulty and version.
*/
export function FindChartOnInGameIDVersion<I extends IDStrings = IDStrings>(
game: Game,
inGameID: number,
playtype: Playtypes[Game],
difficulty: Difficulties[I],
version: GPTSupportedVersions[I]
) {
return db.charts[game].findOne({
"data.inGameID": inGameID,
versions: version,
playtype,
difficulty,
});
}
/**
* Finds an IIDX chart on its 2dxtra hash, which is the sha256 of the .1 buffer.
*/
export function FindIIDXChartWith2DXtraHash(hash: string) {
return db.charts.iidx.findOne({
"data.hashSHA256": hash,
});
}
/**
* Find an SDVX Chart on its in game ID. This exists to handle
* oddities with SDVX difficulties - If "ANY_INF" is sent, it actually
* refers to any of INF, GRV, HVN or VVD. This is because some services treat
* all of those as the same difficulty, but we do not.
*/
export function FindSDVXChartOnInGameID(
inGameID: number,
difficulty: Difficulties["sdvx:Single"] | "ANY_INF"
) {
const diffQuery =
difficulty === "ANY_INF"
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
: difficulty;
return db.charts.sdvx.findOne({
"data.inGameID": inGameID,
difficulty: diffQuery,
isPrimary: true,
});
}
export function FindSDVXChartOnInGameIDVersion(
inGameID: number,
difficulty: "NOV" | "ADV" | "EXH" | "MXM" | "ANY_INF",
version: GPTSupportedVersions["sdvx:Single"]
) {
const diffQuery =
difficulty === "ANY_INF"
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
: difficulty;
return db.charts.sdvx.findOne({
"data.inGameID": inGameID,
difficulty: diffQuery,
versions: version,
});
}
export function FindSDVXChartOnDFVersion(
songID: integer,
difficulty: "NOV" | "ADV" | "EXH" | "MXM" | "ANY_INF",
version: GPTSupportedVersions["sdvx:Single"]
) {
const diffQuery =
difficulty === "ANY_INF"
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
: difficulty;
return db.charts.sdvx.findOne({
songID,
difficulty: diffQuery,
versions: version,
});
}
export function FindChartOnSHA256(game: Game, hash: string) {
if (game !== "bms" && game !== "usc" && game !== "iidx") {
throw new Error(`Cannot call FindChartOnSHA256 for game ${game}.`);
}
return db.charts[game].findOne({
"data.hashSHA256": hash,
});
}
export function FindChartOnARCID(game: "iidx" | "ddr" | "jubeat" | "sdvx", arcID: string) {
return db.charts[game].findOne({
"data.arcChartID": arcID,
});
}
/**
* Returns the N most popular charts for this game + playtype.
* Popularity is determined by how many scores match in the score
* collection.
*/
export async function FindChartsOnPopularity(
game: Game,
playtype: Playtypes[Game],
songIDs?: integer[],
skip = 0,
limit = 100,
scoreCollection: "personal-bests" | "scores" = "personal-bests"
): Promise<(ChartDocument & { __playcount: integer })[]> {
const matchQuery: FilterQuery<ChartDocument> = {
playtype,
};
if (songIDs) {
matchQuery.songID = { $in: songIDs };
}
// MongoDB is a hard beast to wield.
// This code might look very inefficient, but originally this *was*
// a single aggregate pipeline.
//
// We've split it up into multiple queries as this is an order of
// magnitude faster.
// Not entirely sure why, but $lookup is incredibly inefficient,
// and you should just avoid it.
const charts = (await db.charts[game].find(matchQuery)) as unknown as (ChartDocument & {
__playcount: integer;
})[];
const scoreCounts = (await db[scoreCollection].aggregate([
{
$match: { chartID: { $in: charts.map((e) => e.chartID) } },
},
{
$group: {
_id: "$chartID",
count: { $sum: 1 },
},
},
{
$sort: {
count: -1,
},
},
{
$skip: skip,
},
{
$limit: limit,
},
])) as { _id: string; count: integer }[];
const scoreCountMap = new Map();
for (const sc of scoreCounts) {
scoreCountMap.set(sc._id, sc.count);
}
for (const chart of charts) {
chart.__playcount = scoreCountMap.get(chart.chartID) ?? 0;
}
return charts.sort((a, b) => b.__playcount - a.__playcount).slice(skip, skip + limit);
}
import db from "external/mongo/db";
import { FilterQuery } from "mongodb";
import {
ChartDocument,
Difficulties,
Game,
GPTSupportedVersions,
IDStrings,
integer,
Playtypes,
} from "tachi-common";
export function FindChartWithChartID(game: Game, chartID: string) {
return db.charts[game].findOne({ chartID });
}
/**
* Find chart with PlaytypeDifficulty. This only finds charts that have `isPrimary` set to true.
* If you want to find charts that are not primary, you need to use PTDFVersion.
* @see FindChartWithPTDFVersion
*/
export function FindChartWithPTDF<
G extends Game = Game,
P extends Playtypes[G] = Playtypes[G],
I extends IDStrings = IDStrings
>(game: G, songID: integer, playtype: P, difficulty: Difficulties[I]) {
return db.charts[game].findOne({
songID: songID,
playtype: playtype,
difficulty: difficulty,
isPrimary: true,
});
}
/**
* Find chart with Playtype, Difficulty and a given version. This does not necessarily return a chart that has
* `isPrimary` set.
*/
export function FindChartWithPTDFVersion<
G extends Game = Game,
P extends Playtypes[G] = Playtypes[G],
I extends IDStrings = IDStrings
>(
game: G,
songID: integer,
playtype: P,
difficulty: Difficulties[I],
version: GPTSupportedVersions[I]
) {
return db.charts[game].findOne({
songID: songID,
playtype: playtype,
difficulty: difficulty,
versions: version,
});
}
/**
* Finds a DDR Chart based on its "song hash".
* Songs in DDR have a consistent checksum-like identifier used on the e-amusement website.
* We can use this to locate a chart by combining it with a playtype and difficulty.
*
* Despite the potentially confusing name of "songHash", songs are NOT meant to store lookup-like tokens.
* This is just for simplification reasons.
* @param songHash The identifier for the song.
* @param playtype The playtype for the chart.
* @param difficulty The difficulty for the chart.
*/
export function FindDDRChartOnSongHash(
songHash: string,
// Technically both of these should be "ddr" instead of Game, but it proves hard to work with.
playtype: Playtypes[Game],
difficulty: Difficulties[IDStrings]
) {
// note: this only works on accident because monk
// allows any strings like "foo.bar".
// We *should* normally cast this to ChartDocument<"ddr:SP" | "ddr:DP">
return db.charts.ddr.findOne({
"data.songHash": songHash,
playtype,
difficulty,
isPrimary: true,
});
}
/**
* Find a BMS chart on either its md5sum or its sha256sum.
* @param hash The md5 or sha256 hash to look for.
*/
export function FindBMSChartOnHash(hash: string) {
return db.charts.bms.findOne({
$or: [{ "data.hashMD5": hash }, { "data.hashSHA256": hash }],
});
}
/**
* Find a chart on its in-game-ID, playtype and difficulty.
*/
export function FindChartOnInGameID(
game: Game,
inGameID: number,
playtype: Playtypes[Game],
difficulty: Difficulties[IDStrings]
) {
if (game === "bms" || game === "usc") {
throw new Error(`Cannot call FindChartOnInGameID for game ${game}.`);
}
return db.charts[game].findOne({
"data.inGameID": inGameID,
playtype,
difficulty,
});
}
/**
* Finds a non-custom chart on its in-game-ID, playtype and difficulty.
* This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate.
*/
export function FindIIDXChartOnInGameID(
inGameID: number,
playtype: Playtypes[Game],
difficulty: Difficulties[IDStrings]
) {
return db.charts.iidx.findOne({
"data.inGameID": inGameID,
"data.2dxtraSet": null,
isPrimary: true,
playtype,
difficulty,
});
}
/**
* Finds a non-custom chart on its in-game-ID, playtype and difficulty.
* This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate.
*/
export function FindIIDXChartOnInGameIDVersion(
inGameID: number,
playtype: Playtypes[Game],
difficulty: Difficulties[IDStrings],
version: GPTSupportedVersions[IDStrings]
) {
return db.charts.iidx.findOne({
"data.inGameID": inGameID,
"data.2dxtraSet": null,
playtype,
difficulty,
versions: version,
});
}
/**
* Find a chart on its in-game-ID, playtype, difficulty and version.
*/
export function FindChartOnInGameIDVersion<I extends IDStrings = IDStrings>(
game: Game,
inGameID: number,
playtype: Playtypes[Game],
difficulty: Difficulties[I],
version: GPTSupportedVersions[I]
) {
return db.charts[game].findOne({
"data.inGameID": inGameID,
versions: version,
playtype,
difficulty,
});
}
/**
* Finds an IIDX chart on its 2dxtra hash, which is the sha256 of the .1 buffer.
*/
export function FindIIDXChartWith2DXtraHash(hash: string) {
return db.charts.iidx.findOne({
"data.hashSHA256": hash,
});
}
/**
* Find an SDVX Chart on its in game ID. This exists to handle
* oddities with SDVX difficulties - If "ANY_INF" is sent, it actually
* refers to any of INF, GRV, HVN or VVD. This is because some services treat
* all of those as the same difficulty, but we do not.
*/
export function FindSDVXChartOnInGameID(
inGameID: number,
difficulty: Difficulties["sdvx:Single"] | "ANY_INF"
) {
const diffQuery =
difficulty === "ANY_INF"
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
: difficulty;
return db.charts.sdvx.findOne({
"data.inGameID": inGameID,
difficulty: diffQuery,
isPrimary: true,
});
}
export function FindSDVXChartOnInGameIDVersion(
inGameID: number,
difficulty: "NOV" | "ADV" | "EXH" | "MXM" | "ANY_INF",
version: GPTSupportedVersions["sdvx:Single"]
) {
const diffQuery =
difficulty === "ANY_INF"
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
: difficulty;
return db.charts.sdvx.findOne({
"data.inGameID": inGameID,
difficulty: diffQuery,
versions: version,
});
}
export function FindSDVXChartOnDFVersion(
songID: integer,
difficulty: "NOV" | "ADV" | "EXH" | "MXM" | "ANY_INF",
version: GPTSupportedVersions["sdvx:Single"]
) {
const diffQuery =
difficulty === "ANY_INF"
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
: difficulty;
return db.charts.sdvx.findOne({
songID,
difficulty: diffQuery,
versions: version,
});
}
export function FindChartOnSHA256(game: Game, hash: string) {
if (game !== "bms" && game !== "usc" && game !== "iidx") {
throw new Error(`Cannot call FindChartOnSHA256 for game ${game}.`);
}
return db.charts[game].findOne({
"data.hashSHA256": hash,
});
}
export function FindChartOnARCID(game: "iidx" | "ddr" | "jubeat" | "sdvx", arcID: string) {
return db.charts[game].findOne({
"data.arcChartID": arcID,
});
}
/**
* Returns the N most popular charts for this game + playtype.
* Popularity is determined by how many scores match in the score
* collection.
*/
export async function FindChartsOnPopularity(
game: Game,
playtype: Playtypes[Game],
songIDs?: integer[],
skip = 0,
limit = 100,
scoreCollection: "personal-bests" | "scores" = "personal-bests"
): Promise<(ChartDocument & { __playcount: integer })[]> {
const matchQuery: FilterQuery<ChartDocument> = {
playtype,
};
if (songIDs) {
matchQuery.songID = { $in: songIDs };
}
// MongoDB is a hard beast to wield.
// This code might look very inefficient, but originally this *was*
// a single aggregate pipeline.
//
// We've split it up into multiple queries as this is an order of
// magnitude faster.
// Not entirely sure why, but $lookup is incredibly inefficient,
// and you should just avoid it.
const charts = (await db.charts[game].find(matchQuery)) as unknown as (ChartDocument & {
__playcount: integer;
})[];
const scoreCounts = (await db[scoreCollection].aggregate([
{
$match: { chartID: { $in: charts.map((e) => e.chartID) } },
},
{
$group: {
_id: "$chartID",
count: { $sum: 1 },
},
},
{
$sort: {
count: -1,
},
},
{
$skip: skip,
},
{
$limit: limit,
},
])) as { _id: string; count: integer }[];
const scoreCountMap = new Map();
for (const sc of scoreCounts) {
scoreCountMap.set(sc._id, sc.count);
}
for (const chart of charts) {
chart.__playcount = scoreCountMap.get(chart.chartID) ?? 0;
}
return charts.sort((a, b) => b.__playcount - a.__playcount).slice(skip, skip + limit);
}
+75 -75
View File
@@ -1,75 +1,75 @@
import db from "external/mongo/db";
import { KtLogger } from "lib/logger/logger";
import { InternalFailure } from "lib/score-import/framework/common/converter-failures";
import { FindOneResult } from "monk";
import { Game, integer, SongDocument } from "tachi-common";
import { EscapeStringRegexp } from "../misc";
/**
* Finds a song document for the given game with the given title (or alt-title).
* This is NOT the preferred way to find a song, as encodings, and typos, make this
* rather difficult. Prefer other functions!
* @param game - The game to search upon.
* @param title - The song title to match.
* @returns SongDocument
*/
export function FindSongOnTitle(game: Game, title: string): Promise<FindOneResult<SongDocument>> {
// @optimisable: Performance should be tested here by having a utility field for all-titles.
return db.songs[game].findOne({
$or: [
{
title: title,
},
{
altTitles: title,
},
],
});
}
/**
* Finds a song on a song title case-insensitively.
* This is needed for services that provide horrifically mutated string titles.
*/
export function FindSongOnTitleInsensitive(
game: Game,
title: string
): Promise<FindOneResult<SongDocument>> {
// @optimisable: Performance should be tested here by having a utility field for all-titles.
const regex = new RegExp(`^${EscapeStringRegexp(title)}$`, "iu");
return db.songs[game].findOne({
$or: [
{
title: { $regex: regex },
},
{
altTitles: { $regex: regex },
},
],
});
}
/**
* Finds a song document based on the Tachi songID. Depending on the database this might
* also be the in-game-ID.
* @param game - The game to search upon.
* @param songID - The song ID to match.
* @returns SongDocument
*/
export function FindSongOnID(game: Game, songID: integer): Promise<FindOneResult<SongDocument>> {
return db.songs[game].findOne({
id: songID,
});
}
export async function FindSongOnIDGuaranteed(game: Game, songID: integer, logger: KtLogger) {
const song = await FindSongOnID(game, songID);
if (!song) {
logger.severe(`Song-Chart desync for ${songID}. Has charts, but no song.`);
throw new InternalFailure(`Song-Chart desync for ${songID}. Has charts, but no song.`);
}
return song;
}
import db from "external/mongo/db";
import { KtLogger } from "lib/logger/logger";
import { InternalFailure } from "lib/score-import/framework/common/converter-failures";
import { FindOneResult } from "monk";
import { Game, integer, SongDocument } from "tachi-common";
import { EscapeStringRegexp } from "../misc";
/**
* Finds a song document for the given game with the given title (or alt-title).
* This is NOT the preferred way to find a song, as encodings, and typos, make this
* rather difficult. Prefer other functions!
* @param game - The game to search upon.
* @param title - The song title to match.
* @returns SongDocument
*/
export function FindSongOnTitle(game: Game, title: string): Promise<FindOneResult<SongDocument>> {
// @optimisable: Performance should be tested here by having a utility field for all-titles.
return db.songs[game].findOne({
$or: [
{
title: title,
},
{
altTitles: title,
},
],
});
}
/**
* Finds a song on a song title case-insensitively.
* This is needed for services that provide horrifically mutated string titles.
*/
export function FindSongOnTitleInsensitive(
game: Game,
title: string
): Promise<FindOneResult<SongDocument>> {
// @optimisable: Performance should be tested here by having a utility field for all-titles.
const regex = new RegExp(`^${EscapeStringRegexp(title)}$`, "iu");
return db.songs[game].findOne({
$or: [
{
title: { $regex: regex },
},
{
altTitles: { $regex: regex },
},
],
});
}
/**
* Finds a song document based on the Tachi songID. Depending on the database this might
* also be the in-game-ID.
* @param game - The game to search upon.
* @param songID - The song ID to match.
* @returns SongDocument
*/
export function FindSongOnID(game: Game, songID: integer): Promise<FindOneResult<SongDocument>> {
return db.songs[game].findOne({
id: songID,
});
}
export async function FindSongOnIDGuaranteed(game: Game, songID: integer, logger: KtLogger) {
const song = await FindSongOnID(game, songID);
if (!song) {
logger.severe(`Song-Chart desync for ${songID}. Has charts, but no song.`);
throw new InternalFailure(`Song-Chart desync for ${songID}. Has charts, but no song.`);
}
return song;
}
+67 -67
View File
@@ -1,67 +1,67 @@
import {
FolderDocument,
TableDocument,
SessionDocument,
ScoreDocument,
ChartDocument,
PublicUserDocument,
UserGameStats,
Game,
Playtypes,
SongDocument,
UserSettings,
TachiAPIClientDocument,
} from "tachi-common";
declare module "express-session" {
// Inject additional properties on express-session
interface SessionData {
tachi: TachiSessionData;
}
}
export interface TachiSessionData {
user: PublicUserDocument;
settings: UserSettings;
}
export interface TachiAPIFailResponse {
success: false;
description: string;
}
export interface TachiAPISuccessResponse {
success: true;
description: string;
body: Record<string, unknown>;
}
export type TachiAPIReponse = TachiAPIFailResponse | TachiAPISuccessResponse;
/**
* Clarity type for empty objects - such as in context.
*/
export type EmptyObject = Record<string, never>;
/**
* Data that may be monkey-patched onto req.tachi. This holds things such as middleware results.
*/
export interface TachiRequestData {
uscChartDoc?: ChartDocument<"usc:Controller" | "usc:Keyboard">;
beatorajaChartDoc?: ChartDocument<"bms:7K" | "bms:14K">;
requestedUser?: PublicUserDocument;
requestedUserGameStats?: UserGameStats;
game?: Game;
playtype?: Playtypes[Game];
chartDoc?: ChartDocument;
songDoc?: SongDocument;
scoreDoc?: ScoreDocument;
sessionDoc?: SessionDocument;
tableDoc?: TableDocument;
folderDoc?: FolderDocument;
apiClientDoc: Omit<TachiAPIClientDocument, "clientSecret">;
}
import {
FolderDocument,
TableDocument,
SessionDocument,
ScoreDocument,
ChartDocument,
PublicUserDocument,
UserGameStats,
Game,
Playtypes,
SongDocument,
UserSettings,
TachiAPIClientDocument,
} from "tachi-common";
declare module "express-session" {
// Inject additional properties on express-session
interface SessionData {
tachi: TachiSessionData;
}
}
export interface TachiSessionData {
user: PublicUserDocument;
settings: UserSettings;
}
export interface TachiAPIFailResponse {
success: false;
description: string;
}
export interface TachiAPISuccessResponse {
success: true;
description: string;
body: Record<string, unknown>;
}
export type TachiAPIReponse = TachiAPIFailResponse | TachiAPISuccessResponse;
/**
* Clarity type for empty objects - such as in context.
*/
export type EmptyObject = Record<string, never>;
/**
* Data that may be monkey-patched onto req.tachi. This holds things such as middleware results.
*/
export interface TachiRequestData {
uscChartDoc?: ChartDocument<"usc:Controller" | "usc:Keyboard">;
beatorajaChartDoc?: ChartDocument<"bms:7K" | "bms:14K">;
requestedUser?: PublicUserDocument;
requestedUserGameStats?: UserGameStats;
game?: Game;
playtype?: Playtypes[Game];
chartDoc?: ChartDocument;
songDoc?: SongDocument;
scoreDoc?: ScoreDocument;
sessionDoc?: SessionDocument;
tableDoc?: TableDocument;
folderDoc?: FolderDocument;
apiClientDoc: Omit<TachiAPIClientDocument, "clientSecret">;
}
+59 -59
View File
@@ -1,59 +1,59 @@
import t from "tap";
import { DatabaseSchemas } from "external/mongo/schemas";
import { prAssert } from "test-utils/asserts";
import { GetUserCaseInsensitive, FormatUserDoc } from "./user";
import { PublicUserDocument } from "tachi-common";
import ResetDBState from "test-utils/resets";
t.test("#GetUserCaseInsensitive", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return the user for an exact username", async (t) => {
const result = await GetUserCaseInsensitive("test_zkldi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
t.ok(DatabaseSchemas.users(result), "Should return a conforming PublicUserDocument");
// @ts-expect-error yeah
t.equal(result.password, undefined, "Should not return password");
// @ts-expect-error yeah
t.equal(result.email, undefined, "Should not return email");
});
t.test("Should return the user for an incorrectly cased username", async (t) => {
const result = await GetUserCaseInsensitive("tesT_ZkLdi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
t.ok(DatabaseSchemas.users(result), "Should return a conforming PublicUserDocument");
// @ts-expect-error yeah
t.equal(result.password, undefined, "Should not return password");
// @ts-expect-error yeah
t.equal(result.email, undefined, "Should not return email");
});
t.test("Should not return the user for a username that does not exist", async (t) => {
const result = await GetUserCaseInsensitive("foobar");
t.equal(result, null, "Should return null");
});
t.end();
});
t.test("#FormatUserDoc", (t) => {
t.equal(
FormatUserDoc({ username: "zkldi", id: 123 } as PublicUserDocument),
"zkldi (#123)",
"Should format a user document into username #id format."
);
t.end();
});
import t from "tap";
import { DatabaseSchemas } from "external/mongo/schemas";
import { prAssert } from "test-utils/asserts";
import { GetUserCaseInsensitive, FormatUserDoc } from "./user";
import { PublicUserDocument } from "tachi-common";
import ResetDBState from "test-utils/resets";
t.test("#GetUserCaseInsensitive", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return the user for an exact username", async (t) => {
const result = await GetUserCaseInsensitive("test_zkldi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
t.ok(DatabaseSchemas.users(result), "Should return a conforming PublicUserDocument");
// @ts-expect-error yeah
t.equal(result.password, undefined, "Should not return password");
// @ts-expect-error yeah
t.equal(result.email, undefined, "Should not return email");
});
t.test("Should return the user for an incorrectly cased username", async (t) => {
const result = await GetUserCaseInsensitive("tesT_ZkLdi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
t.ok(DatabaseSchemas.users(result), "Should return a conforming PublicUserDocument");
// @ts-expect-error yeah
t.equal(result.password, undefined, "Should not return password");
// @ts-expect-error yeah
t.equal(result.email, undefined, "Should not return email");
});
t.test("Should not return the user for a username that does not exist", async (t) => {
const result = await GetUserCaseInsensitive("foobar");
t.equal(result, null, "Should return null");
});
t.end();
});
t.test("#FormatUserDoc", (t) => {
t.equal(
FormatUserDoc({ username: "zkldi", id: 123 } as PublicUserDocument),
"zkldi (#123)",
"Should format a user document into username #id format."
);
t.end();
});
+224 -224
View File
@@ -1,224 +1,224 @@
import {
integer,
PublicUserDocument,
UserGameStats,
GetGamePTConfig,
Game,
Playtypes,
UGSRatingsLookup,
IDStrings,
} from "tachi-common";
import { FindOneResult } from "monk";
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
const logger = CreateLogCtx(__filename);
/**
* Returns a user's username from their ID. Throws if no user with that ID exists.
*/
export async function GetUsernameFromUserID(userID: integer): Promise<string> {
const partialDoc = await db.users.findOne(
{
id: userID,
},
{
projection: {
username: 1,
},
}
);
if (!partialDoc) {
throw new Error(`Could not find username for userID ${userID}.`);
}
return partialDoc.username;
}
/**
* Gets a user based on their username case-insensitively.
*/
export function GetUserCaseInsensitive(
username: string
): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne({
usernameLowercase: username.toLowerCase(),
}) as Promise<FindOneResult<PublicUserDocument>>;
}
export async function CheckIfEmailInUse(email: string) {
const doc = await db["user-private-information"].findOne({ email });
return !!doc;
}
export function GetUserPrivateInfo(userID: integer) {
return db["user-private-information"].findOne({ userID });
}
/**
* Gets a user from their userID.
*/
export function GetUserWithID(userID: integer): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne({
id: userID,
}) as Promise<FindOneResult<PublicUserDocument>>;
}
export function GetSettingsForUser(userID: integer) {
return db["user-settings"].findOne({
userID: userID,
});
}
/**
* Gets the users for these user IDs.
*/
export function GetUsersWithIDs(userIDs: integer[]) {
return db.users.find({
id: { $in: userIDs },
});
}
/**
* Retrieve a user document that is expected to exist.
* If the user document is not found, a severe error is logged, and this
* function throws.
*/
export async function GetUserWithIDGuaranteed(userID: integer): Promise<PublicUserDocument> {
const userDoc = await GetUserWithID(userID);
if (!userDoc) {
logger.severe(
`User ${userID} does not have an associated user document, but one was expected.`
);
throw new Error(
`User ${userID} does not have an associated user document, but one was expected.`
);
}
return userDoc;
}
/**
* Gets a user based on either their username case-insensitively, or a direct lookup of their ID.
* This is used in URLs to resolve the passed user.
*/
export function ResolveUser(usernameOrID: string) {
// user ID passed
if (usernameOrID.match(/^[0-9]+$/u)) {
const intID = Number(usernameOrID);
return GetUserWithID(intID);
}
return GetUserCaseInsensitive(usernameOrID);
}
/**
* Returns a formatted string indicating the user. This is used for logging.
*/
export function FormatUserDoc(userdoc: PublicUserDocument) {
return `${userdoc.username} (#${userdoc.id})`;
}
export async function GetUsersRanking(stats: UserGameStats) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const aggRes = await db["game-stats"].aggregate([
{
$match: {
game: stats.game,
playtype: stats.playtype,
},
},
{
$group: {
_id: null,
ranking: {
$sum: {
$cond: {
if: {
$gt: [
`$ratings.${gptConfig.defaultProfileRatingAlg}`,
stats.ratings[gptConfig.defaultProfileRatingAlg],
],
},
then: 1,
else: 0,
},
},
},
},
},
]);
return (aggRes[0].ranking + 1) as integer;
}
export function GetUGPTPlaycount(userID: integer, game: Game, playtype: Playtypes[Game]) {
return db.scores.count({ userID, game, playtype });
}
export async function GetAllRankings(stats: UserGameStats) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const entries = await Promise.all(
gptConfig.profileRatingAlgs.map((k) =>
GetUsersRankingAndOutOf(stats, k).then((r) => [k, r])
)
);
return Object.fromEntries(entries);
}
export async function GetUsersRankingAndOutOf(
stats: UserGameStats,
alg?: UGSRatingsLookup[IDStrings]
) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const ratingAlg = alg ?? gptConfig.defaultProfileRatingAlg;
const aggRes = await db["game-stats"].aggregate([
{
$match: {
game: stats.game,
playtype: stats.playtype,
},
},
{
$group: {
_id: null,
outOf: { $sum: 1 },
ranking: {
$sum: {
$cond: {
if: {
$gt: [`$ratings.${ratingAlg}`, stats.ratings[ratingAlg]],
},
then: 1,
else: 0,
},
},
},
},
},
]);
return {
ranking: (aggRes[0].ranking + 1) as integer,
outOf: aggRes[0].outOf as integer,
};
}
const FIVE_MINUTES = 1000 * 60 * 5;
/**
* Returns the cutoff point for "being online" in tachi. This means the user
* has made any page request in the past 5 minutes.
*/
export function GetOnlineCutoff() {
return Date.now() - FIVE_MINUTES;
}
import {
integer,
PublicUserDocument,
UserGameStats,
GetGamePTConfig,
Game,
Playtypes,
UGSRatingsLookup,
IDStrings,
} from "tachi-common";
import { FindOneResult } from "monk";
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
const logger = CreateLogCtx(__filename);
/**
* Returns a user's username from their ID. Throws if no user with that ID exists.
*/
export async function GetUsernameFromUserID(userID: integer): Promise<string> {
const partialDoc = await db.users.findOne(
{
id: userID,
},
{
projection: {
username: 1,
},
}
);
if (!partialDoc) {
throw new Error(`Could not find username for userID ${userID}.`);
}
return partialDoc.username;
}
/**
* Gets a user based on their username case-insensitively.
*/
export function GetUserCaseInsensitive(
username: string
): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne({
usernameLowercase: username.toLowerCase(),
}) as Promise<FindOneResult<PublicUserDocument>>;
}
export async function CheckIfEmailInUse(email: string) {
const doc = await db["user-private-information"].findOne({ email });
return !!doc;
}
export function GetUserPrivateInfo(userID: integer) {
return db["user-private-information"].findOne({ userID });
}
/**
* Gets a user from their userID.
*/
export function GetUserWithID(userID: integer): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne({
id: userID,
}) as Promise<FindOneResult<PublicUserDocument>>;
}
export function GetSettingsForUser(userID: integer) {
return db["user-settings"].findOne({
userID: userID,
});
}
/**
* Gets the users for these user IDs.
*/
export function GetUsersWithIDs(userIDs: integer[]) {
return db.users.find({
id: { $in: userIDs },
});
}
/**
* Retrieve a user document that is expected to exist.
* If the user document is not found, a severe error is logged, and this
* function throws.
*/
export async function GetUserWithIDGuaranteed(userID: integer): Promise<PublicUserDocument> {
const userDoc = await GetUserWithID(userID);
if (!userDoc) {
logger.severe(
`User ${userID} does not have an associated user document, but one was expected.`
);
throw new Error(
`User ${userID} does not have an associated user document, but one was expected.`
);
}
return userDoc;
}
/**
* Gets a user based on either their username case-insensitively, or a direct lookup of their ID.
* This is used in URLs to resolve the passed user.
*/
export function ResolveUser(usernameOrID: string) {
// user ID passed
if (usernameOrID.match(/^[0-9]+$/u)) {
const intID = Number(usernameOrID);
return GetUserWithID(intID);
}
return GetUserCaseInsensitive(usernameOrID);
}
/**
* Returns a formatted string indicating the user. This is used for logging.
*/
export function FormatUserDoc(userdoc: PublicUserDocument) {
return `${userdoc.username} (#${userdoc.id})`;
}
export async function GetUsersRanking(stats: UserGameStats) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const aggRes = await db["game-stats"].aggregate([
{
$match: {
game: stats.game,
playtype: stats.playtype,
},
},
{
$group: {
_id: null,
ranking: {
$sum: {
$cond: {
if: {
$gt: [
`$ratings.${gptConfig.defaultProfileRatingAlg}`,
stats.ratings[gptConfig.defaultProfileRatingAlg],
],
},
then: 1,
else: 0,
},
},
},
},
},
]);
return (aggRes[0].ranking + 1) as integer;
}
export function GetUGPTPlaycount(userID: integer, game: Game, playtype: Playtypes[Game]) {
return db.scores.count({ userID, game, playtype });
}
export async function GetAllRankings(stats: UserGameStats) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const entries = await Promise.all(
gptConfig.profileRatingAlgs.map((k) =>
GetUsersRankingAndOutOf(stats, k).then((r) => [k, r])
)
);
return Object.fromEntries(entries);
}
export async function GetUsersRankingAndOutOf(
stats: UserGameStats,
alg?: UGSRatingsLookup[IDStrings]
) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const ratingAlg = alg ?? gptConfig.defaultProfileRatingAlg;
const aggRes = await db["game-stats"].aggregate([
{
$match: {
game: stats.game,
playtype: stats.playtype,
},
},
{
$group: {
_id: null,
outOf: { $sum: 1 },
ranking: {
$sum: {
$cond: {
if: {
$gt: [`$ratings.${ratingAlg}`, stats.ratings[ratingAlg]],
},
then: 1,
else: 0,
},
},
},
},
},
]);
return {
ranking: (aggRes[0].ranking + 1) as integer,
outOf: aggRes[0].outOf as integer,
};
}
const FIVE_MINUTES = 1000 * 60 * 5;
/**
* Returns the cutoff point for "being online" in tachi. This means the user
* has made any page request in the past 5 minutes.
*/
export function GetOnlineCutoff() {
return Date.now() - FIVE_MINUTES;
}