add and run prettier

This commit is contained in:
Lee 2023-10-17 23:38:18 +01:00
parent 47a23f0484
commit f6f56aa09c
136 changed files with 8231 additions and 4493 deletions

@ -24,6 +24,7 @@
"json-stable-stringify": "^1.0.1", "json-stable-stringify": "^1.0.1",
"luxon": "^2.0.2", "luxon": "^2.0.2",
"p-queue": "^7.1.0", "p-queue": "^7.1.0",
"prettier": "3.0.3",
"rollup": "^2.3.4", "rollup": "^2.3.4",
"rollup-plugin-css-only": "^3.1.0", "rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-livereload": "^2.0.0", "rollup-plugin-livereload": "^2.0.0",

File diff suppressed because it is too large Load Diff

@ -54,7 +54,9 @@ select {
color: var(--decrease); color: var(--decrease);
} }
*[title]:not([title=""]):not(.clickable) {cursor: help;} *[title]:not([title=""]):not(.clickable) {
cursor: help;
}
.scoresaber-icon { .scoresaber-icon {
width: 100%; width: 100%;
@ -69,8 +71,7 @@ select {
height: 100%; height: 100%;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
background-image: url("data:image/svg+xml,%3Csvg xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns%23' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns%23' xmlns:svg='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' width='72.723976mm' height='63.291668mm' viewBox='0 0 72.723976 63.291668' version='1.1' id='svg3827' sodipodi:docname='bsicon_ter.svg' inkscape:version='0.92.4 (5da689c313, 2019-01-14)'%3E%3Cdefs id='defs3821' /%3E%3Csodipodi:namedview id='base' pagecolor='%23ffffff' bordercolor='%23666666' borderopacity='1.0' inkscape:pageopacity='0.0' inkscape:pageshadow='2' inkscape:zoom='1.4' inkscape:cx='40.905424' inkscape:cy='61.353566' inkscape:document-units='mm' inkscape:current-layer='layer1' showgrid='false' inkscape:window-width='1920' inkscape:window-height='1017' inkscape:window-x='-8' inkscape:window-y='-8' inkscape:window-maximized='1' /%3E%3Cmetadata id='metadata3824'%3E%3Crdf:RDF%3E%3Ccc:Work rdf:about=''%3E%3Cdc:format%3Eimage/svg+xml%3C/dc:format%3E%3Cdc:type rdf:resource='http://purl.org/dc/dcmitype/StillImage' /%3E%3Cdc:title%3E%3C/dc:title%3E%3C/cc:Work%3E%3C/rdf:RDF%3E%3C/metadata%3E%3Cg inkscape:label='Calque 1' inkscape:groupmode='layer' id='layer1' style='opacity:1' transform='translate(-0.72553574,-0.71711111)'%3E%3Crect style='fill:%23000200;fill-opacity:1;stroke:%23000000;stroke-width:1.98928511;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal' id='rect4531' width='63.5' height='10.583332' x='5.4550524' y='10.242103' inkscape:export-xdpi='81.844757' inkscape:export-ydpi='81.844757' /%3E%3Crect style='fill:%23ffffff;fill-opacity:1;stroke:none;stroke-width:3.35483217;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal' id='rect4533' width='6.6145835' height='6.6145835' x='25.298788' y='11.565023' inkscape:export-xdpi='81.844757' inkscape:export-ydpi='81.844757' /%3E%3Crect style='fill:%23ffffff;fill-opacity:1;stroke:none;stroke-width:3.92078424;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal' id='rect4533-5' width='6.6145835' height='6.6145835' x='42.496708' y='11.565023' inkscape:export-xdpi='81.844757' inkscape:export-ydpi='81.844757' /%3E%3Cg id='g4614' transform='rotate(-23.417079,-23.695385,307.31208)' inkscape:export-xdpi='81.844757' inkscape:export-ydpi='81.844757'%3E%3Crect y='86.127083' x='125.67709' height='1.3229166' width='50.270832' id='rect4610' style='fill:%230000ff;fill-opacity:1;stroke:%230000ff;stroke-width:2.64583325;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1' /%3E%3Crect y='85.333328' x='109.80208' height='2.6458333' width='15.875' id='rect4605' style='fill:%23000000;fill-opacity:1;stroke:%23000000;stroke-width:2.64583325;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1' /%3E%3C/g%3E%3Cg transform='rotate(-156.98422,82.908484,73.919009)' id='g4614-3' inkscape:export-xdpi='81.844757' inkscape:export-ydpi='81.844757'%3E%3Crect y='86.127083' x='125.67709' height='1.3229166' width='50.270832' id='rect4610-6' style='fill:%23ff0000;fill-opacity:1;stroke:%23ff0000;stroke-width:2.64583325;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1' /%3E%3Crect y='85.333328' x='109.80208' height='2.6458333' width='15.875' id='rect4605-7' style='fill:%23000000;fill-opacity:1;stroke:%23000000;stroke-width:2.64583325;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E%0A" background-image: url("data:image/svg+xml,%3Csvg xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns%23' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns%23' xmlns:svg='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' width='72.723976mm' height='63.291668mm' viewBox='0 0 72.723976 63.291668' version='1.1' id='svg3827' sodipodi:docname='bsicon_ter.svg' inkscape:version='0.92.4 (5da689c313, 2019-01-14)'%3E%3Cdefs id='defs3821' /%3E%3Csodipodi:namedview id='base' pagecolor='%23ffffff' bordercolor='%23666666' borderopacity='1.0' inkscape:pageopacity='0.0' inkscape:pageshadow='2' inkscape:zoom='1.4' inkscape:cx='40.905424' inkscape:cy='61.353566' inkscape:document-units='mm' inkscape:current-layer='layer1' showgrid='false' inkscape:window-width='1920' inkscape:window-height='1017' inkscape:window-x='-8' inkscape:window-y='-8' inkscape:window-maximized='1' /%3E%3Cmetadata id='metadata3824'%3E%3Crdf:RDF%3E%3Ccc:Work rdf:about=''%3E%3Cdc:format%3Eimage/svg+xml%3C/dc:format%3E%3Cdc:type rdf:resource='http://purl.org/dc/dcmitype/StillImage' /%3E%3Cdc:title%3E%3C/dc:title%3E%3C/cc:Work%3E%3C/rdf:RDF%3E%3C/metadata%3E%3Cg inkscape:label='Calque 1' inkscape:groupmode='layer' id='layer1' style='opacity:1' transform='translate(-0.72553574,-0.71711111)'%3E%3Crect style='fill:%23000200;fill-opacity:1;stroke:%23000000;stroke-width:1.98928511;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal' id='rect4531' width='63.5' height='10.583332' x='5.4550524' y='10.242103' inkscape:export-xdpi='81.844757' inkscape:export-ydpi='81.844757' /%3E%3Crect style='fill:%23ffffff;fill-opacity:1;stroke:none;stroke-width:3.35483217;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal' id='rect4533' width='6.6145835' height='6.6145835' x='25.298788' y='11.565023' inkscape:export-xdpi='81.844757' inkscape:export-ydpi='81.844757' /%3E%3Crect style='fill:%23ffffff;fill-opacity:1;stroke:none;stroke-width:3.92078424;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal' id='rect4533-5' width='6.6145835' height='6.6145835' x='42.496708' y='11.565023' inkscape:export-xdpi='81.844757' inkscape:export-ydpi='81.844757' /%3E%3Cg id='g4614' transform='rotate(-23.417079,-23.695385,307.31208)' inkscape:export-xdpi='81.844757' inkscape:export-ydpi='81.844757'%3E%3Crect y='86.127083' x='125.67709' height='1.3229166' width='50.270832' id='rect4610' style='fill:%230000ff;fill-opacity:1;stroke:%230000ff;stroke-width:2.64583325;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1' /%3E%3Crect y='85.333328' x='109.80208' height='2.6458333' width='15.875' id='rect4605' style='fill:%23000000;fill-opacity:1;stroke:%23000000;stroke-width:2.64583325;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1' /%3E%3C/g%3E%3Cg transform='rotate(-156.98422,82.908484,73.919009)' id='g4614-3' inkscape:export-xdpi='81.844757' inkscape:export-ydpi='81.844757'%3E%3Crect y='86.127083' x='125.67709' height='1.3229166' width='50.270832' id='rect4610-6' style='fill:%23ff0000;fill-opacity:1;stroke:%23ff0000;stroke-width:2.64583325;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1' /%3E%3Crect y='85.333328' x='109.80208' height='2.6458333' width='15.875' id='rect4605-7' style='fill:%23000000;fill-opacity:1;stroke:%23000000;stroke-width:2.64583325;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E%0A");
);
} }
.accsaber-icon { .accsaber-icon {

@ -6,4 +6,89 @@
* @author John Doherty <www.johndoherty.info> * @author John Doherty <www.johndoherty.info>
* @license MIT * @license MIT
*/ */
!function(t,e){"use strict";"function"!=typeof t.CustomEvent&&(t.CustomEvent=function(t,n){n=n||{bubbles:!1,cancelable:!1,detail:void 0};var a=e.createEvent("CustomEvent");return a.initCustomEvent(t,n.bubbles,n.cancelable,n.detail),a},t.CustomEvent.prototype=t.Event.prototype),e.addEventListener("touchstart",function(t){if("true"===t.target.getAttribute("data-swipe-ignore"))return;s=t.target,r=Date.now(),n=t.touches[0].clientX,a=t.touches[0].clientY,u=0,i=0},!1),e.addEventListener("touchmove",function(t){if(!n||!a)return;var e=t.touches[0].clientX,r=t.touches[0].clientY;u=n-e,i=a-r},!1),e.addEventListener("touchend",function(t){if(s!==t.target)return;var e=parseInt(l(s,"data-swipe-threshold","20"),10),o=parseInt(l(s,"data-swipe-timeout","500"),10),c=Date.now()-r,d="",p=t.changedTouches||t.touches||[];Math.abs(u)>Math.abs(i)?Math.abs(u)>e&&c<o&&(d=u>0?"swiped-left":"swiped-right"):Math.abs(i)>e&&c<o&&(d=i>0?"swiped-up":"swiped-down");if(""!==d){var b={dir:d.replace(/swiped-/,""),xStart:parseInt(n,10),xEnd:parseInt((p[0]||{}).clientX||-1,10),yStart:parseInt(a,10),yEnd:parseInt((p[0]||{}).clientY||-1,10)};s.dispatchEvent(new CustomEvent("swiped",{bubbles:!0,cancelable:!0,detail:b})),s.dispatchEvent(new CustomEvent(d,{bubbles:!0,cancelable:!0,detail:b}))}n=null,a=null,r=null},!1);var n=null,a=null,u=null,i=null,r=null,s=null;function l(t,n,a){for(;t&&t!==e.documentElement;){var u=t.getAttribute(n);if(u)return u;t=t.parentNode}return a}}(window,document); !(function (t, e) {
"use strict";
"function" != typeof t.CustomEvent &&
((t.CustomEvent = function (t, n) {
n = n || { bubbles: !1, cancelable: !1, detail: void 0 };
var a = e.createEvent("CustomEvent");
return a.initCustomEvent(t, n.bubbles, n.cancelable, n.detail), a;
}),
(t.CustomEvent.prototype = t.Event.prototype)),
e.addEventListener(
"touchstart",
function (t) {
if ("true" === t.target.getAttribute("data-swipe-ignore")) return;
(s = t.target),
(r = Date.now()),
(n = t.touches[0].clientX),
(a = t.touches[0].clientY),
(u = 0),
(i = 0);
},
!1,
),
e.addEventListener(
"touchmove",
function (t) {
if (!n || !a) return;
var e = t.touches[0].clientX,
r = t.touches[0].clientY;
(u = n - e), (i = a - r);
},
!1,
),
e.addEventListener(
"touchend",
function (t) {
if (s !== t.target) return;
var e = parseInt(l(s, "data-swipe-threshold", "20"), 10),
o = parseInt(l(s, "data-swipe-timeout", "500"), 10),
c = Date.now() - r,
d = "",
p = t.changedTouches || t.touches || [];
Math.abs(u) > Math.abs(i)
? Math.abs(u) > e &&
c < o &&
(d = u > 0 ? "swiped-left" : "swiped-right")
: Math.abs(i) > e &&
c < o &&
(d = i > 0 ? "swiped-up" : "swiped-down");
if ("" !== d) {
var b = {
dir: d.replace(/swiped-/, ""),
xStart: parseInt(n, 10),
xEnd: parseInt((p[0] || {}).clientX || -1, 10),
yStart: parseInt(a, 10),
yEnd: parseInt((p[0] || {}).clientY || -1, 10),
};
s.dispatchEvent(
new CustomEvent("swiped", {
bubbles: !0,
cancelable: !0,
detail: b,
}),
),
s.dispatchEvent(
new CustomEvent(d, { bubbles: !0, cancelable: !0, detail: b }),
);
}
(n = null), (a = null), (r = null);
},
!1,
);
var n = null,
a = null,
u = null,
i = null,
r = null,
s = null;
function l(t, n, a) {
for (; t && t !== e.documentElement; ) {
var u = t.getAttribute(n);
if (u) return u;
t = t.parentNode;
}
return a;
}
})(window, document);

@ -1,21 +1,23 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset='utf-8'> <meta charset="utf-8" />
<meta name='viewport' content='width=device-width,initial-scale=1'> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ScoreSaber Reloaded</title> <title>ScoreSaber Reloaded</title>
<link rel='icon' type='image/png' href='/assets/favicon.png' /> <link rel="icon" type="image/png" href="/assets/favicon.png" />
<link rel='stylesheet' href='/assets/ss-bulma.css' /> <link rel="stylesheet" href="/assets/ss-bulma.css" />
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" /> <link
rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.15.4/css/all.css"
/>
<link rel="stylesheet" href="/assets/ssr.css?20210925" /> <link rel="stylesheet" href="/assets/ssr.css?20210925" />
<link rel='stylesheet' href='/build/bundle.css' /> <link rel="stylesheet" href="/build/bundle.css" />
<script src="/assets/swiped-events.min.js"></script> <script src="/assets/swiped-events.min.js"></script>
<script defer src='/build/bundle.js'></script> <script defer src="/build/bundle.js"></script>
</head> </head>
<body> <body></body>
</body>
</html> </html>

@ -1,22 +1,27 @@
const fs = require('fs'); const fs = require("fs");
const path = require('path'); const path = require("path");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
import svelte from 'rollup-plugin-svelte'; import svelte from "rollup-plugin-svelte";
import commonjs from '@rollup/plugin-commonjs'; import commonjs from "@rollup/plugin-commonjs";
import resolve from '@rollup/plugin-node-resolve'; import resolve from "@rollup/plugin-node-resolve";
import livereload from 'rollup-plugin-livereload'; import livereload from "rollup-plugin-livereload";
import { terser } from 'rollup-plugin-terser'; import { terser } from "rollup-plugin-terser";
import sveltePreprocess from 'svelte-preprocess'; import sveltePreprocess from "svelte-preprocess";
import css from 'rollup-plugin-css-only'; import css from "rollup-plugin-css-only";
import svg from 'rollup-plugin-svg'; import svg from "rollup-plugin-svg";
const production = !process.env.ROLLUP_WATCH; const production = !process.env.ROLLUP_WATCH;
const buildVersion = execSync("git rev-parse --short HEAD").toString(); const buildVersion = execSync("git rev-parse --short HEAD").toString();
fs.writeFileSync('build-info.js', 'export default ' + JSON.stringify({ fs.writeFileSync(
buildDate: (new Date()).toISOString().substr(0, 19).replace('T', ' ') + ' UTC', "build-info.js",
buildVersion "export default " +
})) JSON.stringify({
buildDate:
new Date().toISOString().substr(0, 19).replace("T", " ") + " UTC",
buildVersion,
}),
);
function serve() { function serve() {
let server; let server;
@ -28,25 +33,29 @@ function serve() {
return { return {
writeBundle() { writeBundle() {
if (server) return; if (server) return;
server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], { server = require("child_process").spawn(
stdio: ['ignore', 'inherit', 'inherit'], "npm",
shell: true ["run", "start", "--", "--dev"],
}); {
stdio: ["ignore", "inherit", "inherit"],
shell: true,
},
);
process.on('SIGTERM', toExit); process.on("SIGTERM", toExit);
process.on('exit', toExit); process.on("exit", toExit);
} },
}; };
} }
export default [ export default [
{ {
input: 'src/main.js', input: "src/main.js",
output: { output: {
sourcemap: true, sourcemap: true,
format: 'iife', format: "iife",
name: 'app', name: "app",
file: 'public/build/bundle.js', file: "public/build/bundle.js",
}, },
plugins: [ plugins: [
svelte({ svelte({
@ -58,7 +67,7 @@ export default [
}), }),
// we'll extract any component CSS out into // we'll extract any component CSS out into
// a separate file - better for performance // a separate file - better for performance
css({output: 'bundle.css'}), css({ output: "bundle.css" }),
svg(), svg(),
@ -69,7 +78,7 @@ export default [
// https://github.com/rollup/plugins/tree/master/packages/commonjs // https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({ resolve({
browser: true, browser: true,
dedupe: ['svelte'], dedupe: ["svelte"],
}), }),
commonjs(), commonjs(),
@ -79,23 +88,23 @@ export default [
// Watch the `public` directory and refresh the // Watch the `public` directory and refresh the
// browser on changes when not in production // browser on changes when not in production
!production && livereload('public'), !production && livereload("public"),
// If we're building for production (npm run build // If we're building for production (npm run build
// instead of npm run dev), minify // instead of npm run dev), minify
production && terser(), production && terser(),
{ {
name: 'copy-comlink', name: "copy-comlink",
generateBundle() { generateBundle() {
const buildDir = './public/build' const buildDir = "./public/build";
if (!fs.existsSync(buildDir)) { if (!fs.existsSync(buildDir)) {
fs.mkdirSync(buildDir); fs.mkdirSync(buildDir);
} }
fs.copyFileSync( fs.copyFileSync(
path.resolve('./node_modules/comlink/dist/umd/comlink.min.js'), path.resolve("./node_modules/comlink/dist/umd/comlink.min.js"),
path.resolve('./public/build/comlink.min.js'), path.resolve("./public/build/comlink.min.js"),
); );
}, },
}, },
@ -106,12 +115,12 @@ export default [
}, },
{ {
input: 'src/workers/stats-worker.js', input: "src/workers/stats-worker.js",
output: { output: {
sourcemap: true, sourcemap: true,
format: 'iife', format: "iife",
name: 'app', name: "app",
file: 'public/build/stats-worker.js', file: "public/build/stats-worker.js",
}, },
plugins: [ plugins: [
// If you have external dependencies installed from // If you have external dependencies installed from
@ -121,7 +130,7 @@ export default [
// https://github.com/rollup/plugins/tree/master/packages/commonjs // https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({ resolve({
browser: true, browser: true,
dedupe: ['svelte'], dedupe: ["svelte"],
}), }),
commonjs(), commonjs(),
@ -129,19 +138,19 @@ export default [
// instead of npm run dev), minify // instead of npm run dev), minify
production && terser(), production && terser(),
{ {
name: 'copy-test-worker', name: "copy-test-worker",
load() { load() {
this.addWatchFile(path.resolve('./src/workers/stats-worker.js')); this.addWatchFile(path.resolve("./src/workers/stats-worker.js"));
}, },
generateBundle() { generateBundle() {
const buildDir = './public/build' const buildDir = "./public/build";
if (!fs.existsSync(buildDir)) { if (!fs.existsSync(buildDir)) {
fs.mkdirSync(buildDir); fs.mkdirSync(buildDir);
} }
fs.copyFileSync( fs.copyFileSync(
path.resolve('./src/workers/stats-worker.js'), path.resolve("./src/workers/stats-worker.js"),
path.resolve('./public/build/stats-worker.js'), path.resolve("./public/build/stats-worker.js"),
); );
}, },
}, },

@ -17,15 +17,23 @@ export const onLegendClick = (event, legendItem, legend) => {
if (legend?.chart) { if (legend?.chart) {
const yAxisIdsToShow = (legend?.legendItems ?? []) const yAxisIdsToShow = (legend?.legendItems ?? [])
.sort((a,b) => (ci?.config?.data?.datasets?.[a?.datasetIndex]?.axisOrder ?? a?.datasetIndex) - (ci?.config?.data?.datasets?.[b?.datasetIndex]?.axisOrder ?? b?.datasetIndex)) .sort(
.reduce((cum, legendItem) => { (a, b) =>
(ci?.config?.data?.datasets?.[a?.datasetIndex]?.axisOrder ??
a?.datasetIndex) -
(ci?.config?.data?.datasets?.[b?.datasetIndex]?.axisOrder ??
b?.datasetIndex),
)
.reduce(
(cum, legendItem) => {
// done // done
if (cum.second) return cum; if (cum.second) return cum;
// skip hidden legend items // skip hidden legend items
if (legendItem?.hidden) return cum; if (legendItem?.hidden) return cum;
const yAxisId = ci?.getDatasetMeta(legendItem?.datasetIndex)?.yAxisID ?? null; const yAxisId =
ci?.getDatasetMeta(legendItem?.datasetIndex)?.yAxisID ?? null;
if (!yAxisId) return cum; if (!yAxisId) return cum;
if (!cum.first) { if (!cum.first) {
@ -35,20 +43,26 @@ export const onLegendClick = (event, legendItem, legend) => {
} }
return cum; return cum;
}, {first: null, second: null}); },
{ first: null, second: null },
);
Object.keys(yAxes).forEach(currentAxisKey => { Object.keys(yAxes).forEach((currentAxisKey) => {
if (![yAxisIdsToShow.first, yAxisIdsToShow.second].includes(currentAxisKey)) { if (
![yAxisIdsToShow.first, yAxisIdsToShow.second].includes(currentAxisKey)
) {
yAxes[currentAxisKey].display = false; yAxes[currentAxisKey].display = false;
return; return;
} }
yAxes[currentAxisKey].display = true; yAxes[currentAxisKey].display = true;
if (yAxisIdsToShow.first === currentAxisKey) yAxes[currentAxisKey].position = 'left'; if (yAxisIdsToShow.first === currentAxisKey)
if (yAxisIdsToShow.second === currentAxisKey) yAxes[currentAxisKey].position = 'right'; yAxes[currentAxisKey].position = "left";
if (yAxisIdsToShow.second === currentAxisKey)
yAxes[currentAxisKey].position = "right";
}); });
legend.chart.options.scales = {x: xAxis, ...yAxes} legend.chart.options.scales = { x: xAxis, ...yAxes };
legend.chart.update(); legend.chart.update();
} }
} };

@ -1,9 +1,13 @@
export default { export default {
id: 'regions', id: "regions",
beforeDraw(chart, args, options) { beforeDraw(chart, args, options) {
if (!options?.regions || !Array.isArray(options.regions)) return; if (!options?.regions || !Array.isArray(options.regions)) return;
const {ctx, chartArea: {left, top, right, bottom}, scales: {y}} = chart; const {
ctx,
chartArea: { left, top, right, bottom },
scales: { y },
} = chart;
const width = right - left; const width = right - left;
let fontSize = parseInt(ctx.font, 10); let fontSize = parseInt(ctx.font, 10);
@ -11,12 +15,15 @@ export default {
ctx.save(); ctx.save();
options.regions.forEach(region => { options.regions.forEach((region) => {
if (y.min <= region.max && y.max >= region.min) { if (y.min <= region.max && y.max >= region.min) {
const minY = Math.max(region.min, y.min); const minY = Math.max(region.min, y.min);
const maxY = Math.min(region.max, y.max); const maxY = Math.min(region.max, y.max);
const top = y.getPixelForValue(maxY); const top = y.getPixelForValue(maxY);
const height = region.min === region.max ? 1 : y.getPixelForValue(minY) - y.getPixelForValue(maxY); const height =
region.min === region.max
? 1
: y.getPixelForValue(minY) - y.getPixelForValue(maxY);
ctx.fillStyle = region.color; ctx.fillStyle = region.color;
ctx.fillRect(left, top, width, height); ctx.fillRect(left, top, width, height);
@ -24,16 +31,20 @@ export default {
if (region.label) { if (region.label) {
const labelWidth = ctx.measureText(region.label)?.width ?? 0; const labelWidth = ctx.measureText(region.label)?.width ?? 0;
ctx.textBaseline = 'top'; ctx.textBaseline = "top";
ctx.fillText( ctx.fillText(
region.label, region.label,
region?.position?.horizontal === 'right' ? right - labelWidth - 3 : left + 3, region?.position?.horizontal === "right"
region?.position?.vertical === 'bottom' ? top + 2 : top - fontSize - 1 ? right - labelWidth - 3
: left + 3,
region?.position?.vertical === "bottom"
? top + 2
: top - fontSize - 1,
); );
} }
} }
}) });
ctx.restore(); ctx.restore();
} },
} };

@ -70,7 +70,7 @@ function updateScoresStats(playerData, playerStats) {
bgColor: "var(--ppColour)", bgColor: "var(--ppColour)",
}, },
] ]
: [] : [],
) )
.filter((s) => s && (!playerStats || s.label !== "Average")); .filter((s) => s && (!playerStats || s.label !== "Average"));
} }

@ -2,27 +2,34 @@ export default () => {
let currentService = null; let currentService = null;
let currentServiceParams = {}; let currentServiceParams = {};
const getAllServices = () => ['scoresaber', 'beatsavior', 'accsaber']; const getAllServices = () => ["scoresaber", "beatsavior", "accsaber"];
const get = () => ({ service: currentService, params: currentServiceParams }); const get = () => ({ service: currentService, params: currentServiceParams });
const getDefaultParams = service => { const getDefaultParams = (service) => {
switch (service) { switch (service) {
case 'beatsavior': case "beatsavior":
return {sort: 'recent', order: 'desc', page: 1, filters: {}}; return { sort: "recent", order: "desc", page: 1, filters: {} };
case 'accsaber': case "accsaber":
return {type: 'overall', order: 'desc', sort: 'ap', page: 1, filters: {}} return {
type: "overall",
order: "desc",
sort: "ap",
page: 1,
filters: {},
};
case 'scoresaber': case "scoresaber":
default: default:
return {sort: 'recent', order: 'desc', page: 1, filters: {}} return { sort: "recent", order: "desc", page: 1, filters: {} };
}
} }
};
const update = (serviceParams = {}, service = currentService) => { const update = (serviceParams = {}, service = currentService) => {
const availableServices = getAllServices(); const availableServices = getAllServices();
if (!availableServices.includes(service)) service = availableServices?.[0] ?? 'scoresaber'; if (!availableServices.includes(service))
service = availableServices?.[0] ?? "scoresaber";
const defaultServiceParams = getDefaultParams(service); const defaultServiceParams = getDefaultParams(service);
@ -32,84 +39,103 @@ export default () => {
} }
// preserve old filters // preserve old filters
serviceParams = {...serviceParams} serviceParams = { ...serviceParams };
serviceParams.filters = { serviceParams.filters = {
...(currentServiceParams?.filters ?? {}), ...(currentServiceParams?.filters ?? {}),
...(serviceParams?.filters ?? {}), ...(serviceParams?.filters ?? {}),
} };
currentService = service; currentService = service;
currentServiceParams = {...defaultServiceParams, ...currentServiceParams, ...serviceParams} currentServiceParams = {
...defaultServiceParams,
...currentServiceParams,
...serviceParams,
};
return get(); return get();
} };
const clearServiceParams = () => currentServiceParams = {} const clearServiceParams = () => (currentServiceParams = {});
const initFromUrl = (url = null) => { const initFromUrl = (url = null) => {
const availableServices = getAllServices(); const availableServices = getAllServices();
const defaultService = availableServices?.[0] ?? 'scoresaber'; const defaultService = availableServices?.[0] ?? "scoresaber";
const paramsArr = url ? url.split('/') : [defaultService]; const paramsArr = url ? url.split("/") : [defaultService];
const service = paramsArr[0] ?? 'scoresaber'; const service = paramsArr[0] ?? "scoresaber";
const serviceDefaultParams = getDefaultParams(service); const serviceDefaultParams = getDefaultParams(service);
switch (service) { switch (service) {
case 'beatsavior': case "beatsavior":
return update( return update(
{ {
sort: paramsArr[1] ?? serviceDefaultParams?.sort, sort: paramsArr[1] ?? serviceDefaultParams?.sort,
order: 'desc', order: "desc",
page: paramsArr[2] ?? serviceDefaultParams?.page, page: paramsArr[2] ?? serviceDefaultParams?.page,
}, },
service, service,
); );
case 'accsaber': case "accsaber":
return update( return update(
{ {
type: paramsArr[1] ?? serviceDefaultParams?.type, type: paramsArr[1] ?? serviceDefaultParams?.type,
sort: paramsArr[2] ?? serviceDefaultParams?.sort, sort: paramsArr[2] ?? serviceDefaultParams?.sort,
order: (paramsArr[2] ?? serviceDefaultParams?.sort) === 'rank' ? 'asc' : 'desc', order:
(paramsArr[2] ?? serviceDefaultParams?.sort) === "rank"
? "asc"
: "desc",
page: paramsArr[3] ?? serviceDefaultParams?.page, page: paramsArr[3] ?? serviceDefaultParams?.page,
}, },
service, service,
); );
case 'scoresaber': case "scoresaber":
default: default:
return update( return update(
{ {
sort: paramsArr[1] ?? serviceDefaultParams?.sort, sort: paramsArr[1] ?? serviceDefaultParams?.sort,
order: (paramsArr[1] ?? serviceDefaultParams?.sort) === 'rank' ? 'asc' : 'desc', order:
(paramsArr[1] ?? serviceDefaultParams?.sort) === "rank"
? "asc"
: "desc",
page: paramsArr[2] ?? serviceDefaultParams?.page, page: paramsArr[2] ?? serviceDefaultParams?.page,
}, },
service, service,
); );
} }
} };
const getUrl = (service, params = {}, noPage = false) => { const getUrl = (service, params = {}, noPage = false) => {
if (!service) return ''; if (!service) return "";
const serviceDefaultParams = getDefaultParams(service); const serviceDefaultParams = getDefaultParams(service);
switch (service) { switch (service) {
case 'beatsavior': case "beatsavior":
return `${service}/${params?.sort ?? serviceDefaultParams?.sort}${noPage ? '' : `/${params?.page ?? serviceDefaultParams?.page}`}`; return `${service}/${params?.sort ?? serviceDefaultParams?.sort}${
noPage ? "" : `/${params?.page ?? serviceDefaultParams?.page}`
}`;
case 'accsaber': case "accsaber":
return `${service}/${params?.type ?? serviceDefaultParams?.type}/${params?.sort ?? serviceDefaultParams?.sort}${noPage ? '' : `/${params?.page ?? serviceDefaultParams?.page}`}`; return `${service}/${params?.type ?? serviceDefaultParams?.type}/${
params?.sort ?? serviceDefaultParams?.sort
}${noPage ? "" : `/${params?.page ?? serviceDefaultParams?.page}`}`;
case 'scoresaber': case "scoresaber":
return `${service}/${params?.sort ?? serviceDefaultParams?.sort}${noPage ? '' : `/${params?.page ?? serviceDefaultParams?.page}`}`; return `${service}/${params?.sort ?? serviceDefaultParams?.sort}${
} noPage ? "" : `/${params?.page ?? serviceDefaultParams?.page}`
}`;
} }
};
const getCurrentServiceUrl = () => getUrl(currentService, currentServiceParams); const getCurrentServiceUrl = () =>
const getCurrentServiceUrlWithoutPage = () => getUrl(currentService, currentServiceParams, true); getUrl(currentService, currentServiceParams);
const getDefaultServiceUrl = (service = currentService) => getUrl(service, {}); const getCurrentServiceUrlWithoutPage = () =>
getUrl(currentService, currentServiceParams, true);
const getDefaultServiceUrl = (service = currentService) =>
getUrl(service, {});
return { return {
getAvailableServices: getAllServices, getAvailableServices: getAllServices,
@ -122,5 +148,5 @@ export default () => {
getParams: () => currentServiceParams, getParams: () => currentServiceParams,
update, update,
clearServiceParams, clearServiceParams,
} };
} };

@ -1,19 +1,29 @@
import eventBus from '../utils/broadcast-channel-pubsub' import eventBus from "../utils/broadcast-channel-pubsub";
export default (name, getObjKey) => { export default (name, getObjKey) => {
let cache = {}; let cache = {};
// update data cached on another node // update data cached on another node
eventBus.on('cache-key-set-' + name, ({key, value}, isLocal) => !isLocal ? set(key, value, false) : null); eventBus.on("cache-key-set-" + name, ({ key, value }, isLocal) =>
eventBus.on('cache-all-set' + name, ({data}, isLocal) => !isLocal ? setAll(data, false) : null); !isLocal ? set(key, value, false) : null,
eventBus.on('cache-merge-' + name, ({data}, isLocal) => !isLocal ? merge(data, false) : null); );
eventBus.on('cache-key-forget-' + name, ({key}, isLocal) => !isLocal ? forget(key, false) : null); eventBus.on("cache-all-set" + name, ({ data }, isLocal) =>
eventBus.on('cache-flush-' + name, (_, isLocal) => !isLocal ? flush(false) : null); !isLocal ? setAll(data, false) : null,
);
eventBus.on("cache-merge-" + name, ({ data }, isLocal) =>
!isLocal ? merge(data, false) : null,
);
eventBus.on("cache-key-forget-" + name, ({ key }, isLocal) =>
!isLocal ? forget(key, false) : null,
);
eventBus.on("cache-flush-" + name, (_, isLocal) =>
!isLocal ? flush(false) : null,
);
const set = (key, value, emitEvent = true) => { const set = (key, value, emitEvent = true) => {
cache[key] = value; cache[key] = value;
if (emitEvent) eventBus.publish('cache-key-set-' + name, {key, value}); if (emitEvent) eventBus.publish("cache-key-set-" + name, { key, value });
return value; return value;
}; };
@ -21,17 +31,17 @@ export default (name, getObjKey) => {
const setAll = (data, emitEvent = true) => { const setAll = (data, emitEvent = true) => {
cache = data; cache = data;
if (emitEvent) eventBus.publish('cache-all-set-' + name, {data}); if (emitEvent) eventBus.publish("cache-all-set-" + name, { data });
return cache; return cache;
} };
const merge = (data, emitEvent = true) => { const merge = (data, emitEvent = true) => {
cache = {...cache, ...data} cache = { ...cache, ...data };
if (emitEvent) eventBus.publish('cache-merge-' + name, {data}); if (emitEvent) eventBus.publish("cache-merge-" + name, { data });
return cache; return cache;
} };
const get = async (key, fetchFunc) => { const get = async (key, fetchFunc) => {
if (cache.hasOwnProperty(key)) return cache[key]; if (cache.hasOwnProperty(key)) return cache[key];
@ -53,42 +63,43 @@ export default (name, getObjKey) => {
const key = getObjKey(value); const key = getObjKey(value);
return set(key, value); return set(key, value);
} };
const getAll = () => cache; const getAll = () => cache;
const has = key => cache[key] !== undefined; const has = (key) => cache[key] !== undefined;
const getKeys = () => Object.keys(cache); const getKeys = () => Object.keys(cache);
const forget = (key, emitEvent = true) => { const forget = (key, emitEvent = true) => {
delete cache[key]; delete cache[key];
if (emitEvent) eventBus.publish('cache-key-forget-' + name, {key}); if (emitEvent) eventBus.publish("cache-key-forget-" + name, { key });
return cache; return cache;
} };
const forgetByFilter = (filterFunc, emitEvent = true) => { const forgetByFilter = (filterFunc, emitEvent = true) => {
if (!filterFunc) return false; if (!filterFunc) return false;
Object.keys(cache).filter(key => filterFunc(cache[key])) Object.keys(cache)
.forEach(key => { .filter((key) => filterFunc(cache[key]))
delete cache[key] .forEach((key) => {
delete cache[key];
if (emitEvent) eventBus.publish('cache-key-forget-' + name, {key}); if (emitEvent) eventBus.publish("cache-key-forget-" + name, { key });
}); });
return true; return true;
} };
const flush = (emitEvent = true) => { const flush = (emitEvent = true) => {
cache = {}; cache = {};
if (emitEvent) eventBus.publish('cache-flush-' + name, {}); if (emitEvent) eventBus.publish("cache-flush-" + name, {});
return cache; return cache;
} };
return { return {
has, has,
@ -102,5 +113,5 @@ export default (name, getObjKey) => {
forget, forget,
forgetByFilter, forgetByFilter,
flush, flush,
} };
} };

@ -1,195 +1,279 @@
import {openDB} from 'idb' import { openDB } from "idb";
import log from '../utils/logger' import log from "../utils/logger";
import {isDateObject} from '../utils/js' import { isDateObject } from "../utils/js";
import eventBus from '../utils/broadcast-channel-pubsub' import eventBus from "../utils/broadcast-channel-pubsub";
const SSR_DB_VERSION = 12; const SSR_DB_VERSION = 12;
export let db = null; export let db = null;
export default async () => { export default async () => {
IDBKeyRange.prototype.toString = function () { IDBKeyRange.prototype.toString = function () {
return "IDBKeyRange-" + (isDateObject(this.lower) ? this.lower.getTime() : this.lower) + '-' + (isDateObject(this.upper) ? this.upper : this.upper); return (
} "IDBKeyRange-" +
(isDateObject(this.lower) ? this.lower.getTime() : this.lower) +
"-" +
(isDateObject(this.upper) ? this.upper : this.upper)
);
};
return await openDatabase(); return await openDatabase();
} };
async function openDatabase() { async function openDatabase() {
try { try {
let dbNewVersion = 0, dbOldVersion = 0; let dbNewVersion = 0,
dbOldVersion = 0;
db = await openDB('ssr', SSR_DB_VERSION, { db = await openDB("ssr", SSR_DB_VERSION, {
async upgrade(db, oldVersion, newVersion, transaction) { async upgrade(db, oldVersion, newVersion, transaction) {
log.info(`Converting database from version ${oldVersion} to version ${newVersion}`); log.info(
`Converting database from version ${oldVersion} to version ${newVersion}`,
);
dbNewVersion = newVersion; dbNewVersion = newVersion;
dbOldVersion = oldVersion; dbOldVersion = oldVersion;
switch (true) { switch (true) {
case newVersion >= 1 && oldVersion <= 0: case newVersion >= 1 && oldVersion <= 0:
db.createObjectStore('players', { db.createObjectStore("players", {
keyPath: 'id', keyPath: "id",
autoIncrement: false, autoIncrement: false,
}); });
const playersHistory = db.createObjectStore('players-history', { const playersHistory = db.createObjectStore("players-history", {
keyPath: '_idbId', keyPath: "_idbId",
autoIncrement: true, autoIncrement: true,
}); });
playersHistory.createIndex('players-history-playerId', 'playerId', {unique: false}); playersHistory.createIndex("players-history-playerId", "playerId", {
playersHistory.createIndex('players-history-timestamp', 'timestamp', {unique: false}); unique: false,
});
playersHistory.createIndex(
"players-history-timestamp",
"timestamp",
{ unique: false },
);
const scoresStore = db.createObjectStore('scores', { const scoresStore = db.createObjectStore("scores", {
keyPath: 'id', keyPath: "id",
autoIncrement: false, autoIncrement: false,
}); });
scoresStore.createIndex('scores-leaderboardId', 'leaderboardId', {unique: false}); scoresStore.createIndex("scores-leaderboardId", "leaderboardId", {
scoresStore.createIndex('scores-playerId', 'playerId', {unique: false}); unique: false,
scoresStore.createIndex('scores-timeset', 'timeset', {unique: false}); });
scoresStore.createIndex('scores-pp', 'pp', {unique: false}); scoresStore.createIndex("scores-playerId", "playerId", {
unique: false,
});
scoresStore.createIndex("scores-timeset", "timeset", {
unique: false,
});
scoresStore.createIndex("scores-pp", "pp", { unique: false });
db.createObjectStore('rankeds', { db.createObjectStore("rankeds", {
keyPath: 'leaderboardId', keyPath: "leaderboardId",
autoIncrement: false, autoIncrement: false,
}); });
const songsStore = db.createObjectStore('songs', { const songsStore = db.createObjectStore("songs", {
keyPath: 'hash', keyPath: "hash",
autoIncrement: false, autoIncrement: false,
}); });
songsStore.createIndex('songs-key', 'key', {unique: true}); songsStore.createIndex("songs-key", "key", { unique: true });
db.createObjectStore('twitch', { db.createObjectStore("twitch", {
keyPath: 'playerId', keyPath: "playerId",
autoIncrement: false, autoIncrement: false,
}); });
const rankedsChangesStore = db.createObjectStore('rankeds-changes', { const rankedsChangesStore = db.createObjectStore(
keyPath: '_idbId', "rankeds-changes",
{
keyPath: "_idbId",
autoIncrement: true, autoIncrement: true,
}); },
rankedsChangesStore.createIndex('rankeds-changes-timestamp', 'timestamp', {unique: false}); );
rankedsChangesStore.createIndex('rankeds-changes-leaderboardId', 'leaderboardId', {unique: false}); rankedsChangesStore.createIndex(
"rankeds-changes-timestamp",
"timestamp",
{ unique: false },
);
rankedsChangesStore.createIndex(
"rankeds-changes-leaderboardId",
"leaderboardId",
{ unique: false },
);
// no autoIncrement, no keyPath - key must be provided // no autoIncrement, no keyPath - key must be provided
db.createObjectStore('key-value'); db.createObjectStore("key-value");
db.createObjectStore('cache'); db.createObjectStore("cache");
const groups = db.createObjectStore('groups', {keyPath: '_idbId', autoIncrement: true}); const groups = db.createObjectStore("groups", {
groups.createIndex('groups-name', 'name', {unique: false}); keyPath: "_idbId",
groups.createIndex('groups-playerId', 'playerId', {unique: false}); autoIncrement: true,
});
groups.createIndex("groups-name", "name", { unique: false });
groups.createIndex("groups-playerId", "playerId", {
unique: false,
});
const beatSaviorFiles = db.createObjectStore('beat-savior-files', { const beatSaviorFiles = db.createObjectStore("beat-savior-files", {
keyPath: 'fileId', keyPath: "fileId",
autoIncrement: false, autoIncrement: false,
}); });
const beatSavior = db.createObjectStore('beat-savior', { const beatSavior = db.createObjectStore("beat-savior", {
keyPath: 'beatSaviorId', keyPath: "beatSaviorId",
autoIncrement: false, autoIncrement: false,
}); });
beatSavior.createIndex('beat-savior-playerId', 'playerId', {unique: false}); beatSavior.createIndex("beat-savior-playerId", "playerId", {
beatSavior.createIndex('beat-savior-songId', 'songId', {unique: false}); unique: false,
beatSavior.createIndex('beat-savior-fileId', 'fileId', {unique: false}); });
beatSavior.createIndex("beat-savior-songId", "songId", {
unique: false,
});
beatSavior.createIndex("beat-savior-fileId", "fileId", {
unique: false,
});
// NO break here! // NO break here!
case newVersion >= 2 && oldVersion <= 1: case newVersion >= 2 && oldVersion <= 1:
db.createObjectStore('beat-savior-players', { db.createObjectStore("beat-savior-players", {
keyPath: 'playerId', keyPath: "playerId",
autoIncrement: false, autoIncrement: false,
}); });
// NO break here! // NO break here!
case newVersion >= 3 && oldVersion <= 2: case newVersion >= 3 && oldVersion <= 2:
db.deleteObjectStore('players'); db.deleteObjectStore("players");
db.createObjectStore('players', { db.createObjectStore("players", {
keyPath: 'playerId', keyPath: "playerId",
autoIncrement: false, autoIncrement: false,
}); });
const scoresStore4 = transaction.objectStore('scores'); const scoresStore4 = transaction.objectStore("scores");
scoresStore4.deleteIndex('scores-timeset'); scoresStore4.deleteIndex("scores-timeset");
scoresStore4.createIndex('scores-timeSet', 'timeSet', {unique: false}); scoresStore4.createIndex("scores-timeSet", "timeSet", {
unique: false,
});
// NO break here // NO break here
case newVersion >= 4 && oldVersion <= 3: case newVersion >= 4 && oldVersion <= 3:
db.deleteObjectStore('beat-savior-files'); db.deleteObjectStore("beat-savior-files");
const beatSaviorStore = transaction.objectStore('beat-savior'); const beatSaviorStore = transaction.objectStore("beat-savior");
beatSaviorStore.deleteIndex('beat-savior-fileId'); beatSaviorStore.deleteIndex("beat-savior-fileId");
beatSaviorStore.deleteIndex('beat-savior-songId'); beatSaviorStore.deleteIndex("beat-savior-songId");
// NO break here // NO break here
case newVersion >= 5 && oldVersion <= 4: case newVersion >= 5 && oldVersion <= 4:
const songsBeatMapsStore = db.createObjectStore('songs-beatmaps', { const songsBeatMapsStore = db.createObjectStore("songs-beatmaps", {
keyPath: 'hash', keyPath: "hash",
autoIncrement: false, autoIncrement: false,
}); });
songsBeatMapsStore.createIndex('songs-beatmaps--key', 'key', {unique: true}); songsBeatMapsStore.createIndex("songs-beatmaps--key", "key", {
unique: true,
});
// NO break here // NO break here
case newVersion >= 6 && oldVersion <= 5: case newVersion >= 6 && oldVersion <= 5:
const songsBeatMapsStorev6 = transaction.objectStore('songs-beatmaps'); const songsBeatMapsStorev6 =
songsBeatMapsStorev6.deleteIndex('songs-beatmaps--key'); transaction.objectStore("songs-beatmaps");
songsBeatMapsStorev6.createIndex('songs-beatmaps-key', 'key', {unique: true}); songsBeatMapsStorev6.deleteIndex("songs-beatmaps--key");
songsBeatMapsStorev6.createIndex("songs-beatmaps-key", "key", {
unique: true,
});
// NO break here // NO break here
case newVersion >= 7 && oldVersion <= 6: case newVersion >= 7 && oldVersion <= 6:
const scoresUpdateQueue = db.createObjectStore('scores-update-queue', { const scoresUpdateQueue = db.createObjectStore(
keyPath: 'id', "scores-update-queue",
{
keyPath: "id",
autoIncrement: false, autoIncrement: false,
}); },
scoresUpdateQueue.createIndex('scores-update-queue-fetchedAt', 'fetchedAt', {unique: false}); );
scoresUpdateQueue.createIndex(
"scores-update-queue-fetchedAt",
"fetchedAt",
{ unique: false },
);
case newVersion >= 8 && oldVersion <= 7: case newVersion >= 8 && oldVersion <= 7:
const beatSaviorStorev8 = transaction.objectStore('beat-savior'); const beatSaviorStorev8 = transaction.objectStore("beat-savior");
beatSaviorStorev8.createIndex('beat-savior-hash', 'hash', {unique: false}); beatSaviorStorev8.createIndex("beat-savior-hash", "hash", {
unique: false,
});
// NO break here // NO break here
case newVersion >= 9 && oldVersion <= 8: case newVersion >= 9 && oldVersion <= 8:
const playersHistoryStorev9 = transaction.objectStore('players-history'); const playersHistoryStorev9 =
playersHistoryStorev9.deleteIndex('players-history-timestamp'); transaction.objectStore("players-history");
playersHistoryStorev9.createIndex('players-history-playerIdSsTimestamp', 'playerIdSsTimestamp', {unique: true}); playersHistoryStorev9.deleteIndex("players-history-timestamp");
playersHistoryStorev9.createIndex(
"players-history-playerIdSsTimestamp",
"playerIdSsTimestamp",
{ unique: true },
);
// NO break here // NO break here
case newVersion >= 10 && oldVersion <= 9: case newVersion >= 10 && oldVersion <= 9:
const songsBeatMapsStoreV10 = transaction.objectStore('songs-beatmaps'); const songsBeatMapsStoreV10 =
songsBeatMapsStoreV10.deleteIndex('songs-beatmaps-key'); transaction.objectStore("songs-beatmaps");
songsBeatMapsStoreV10.createIndex('songs-beatmaps-key', 'key', {unique: false}); songsBeatMapsStoreV10.deleteIndex("songs-beatmaps-key");
songsBeatMapsStoreV10.createIndex("songs-beatmaps-key", "key", {
unique: false,
});
// NO break here // NO break here
case newVersion >= 11 && oldVersion <= 10: case newVersion >= 11 && oldVersion <= 10:
db.createObjectStore('accsaber-categories', { db.createObjectStore("accsaber-categories", {
keyPath: 'name', keyPath: "name",
autoIncrement: false, autoIncrement: false,
}); });
const accSaberPlayersStore = db.createObjectStore('accsaber-players', { const accSaberPlayersStore = db.createObjectStore(
keyPath: 'id', "accsaber-players",
{
keyPath: "id",
autoIncrement: false, autoIncrement: false,
}); },
accSaberPlayersStore.createIndex('accsaber-players-playerId', 'playerId', {unique: false}); );
accSaberPlayersStore.createIndex('accsaber-players-category', 'category', {unique: false}); accSaberPlayersStore.createIndex(
"accsaber-players-playerId",
"playerId",
{ unique: false },
);
accSaberPlayersStore.createIndex(
"accsaber-players-category",
"category",
{ unique: false },
);
// NO break here // NO break here
case newVersion >= 12 && oldVersion <= 11: case newVersion >= 12 && oldVersion <= 11:
const accSaberPlayersHistoryStore = db.createObjectStore('accsaber-players-history', { const accSaberPlayersHistoryStore = db.createObjectStore(
keyPath: 'playerIdTimestamp', "accsaber-players-history",
{
keyPath: "playerIdTimestamp",
autoIncrement: false, autoIncrement: false,
}); },
accSaberPlayersHistoryStore.createIndex('accsaber-players-history-playerId', 'playerId', {unique: false}); );
accSaberPlayersHistoryStore.createIndex(
"accsaber-players-history-playerId",
"playerId",
{ unique: false },
);
// NO break here // NO break here
} }
@ -198,28 +282,33 @@ async function openDatabase() {
}, },
blocked() { blocked() {
console.warn('DB blocked') console.warn("DB blocked");
}, },
blocking() { blocking() {
// other tab tries to open newer db version - close connection // other tab tries to open newer db version - close connection
console.warn('DB blocking... will be closed') console.warn("DB blocking... will be closed");
db.close(); db.close();
eventBus.publish('dl-manager-pause-cmd'); eventBus.publish("dl-manager-pause-cmd");
// TODO: should be reopened with new version: event.newVersion // TODO: should be reopened with new version: event.newVersion
// TODO: or rather notify user / auto reload page // TODO: or rather notify user / auto reload page
}, },
terminated() { terminated() {
console.warn('DB terminated'); console.warn("DB terminated");
eventBus.publish('dl-manager-pause-cmd'); eventBus.publish("dl-manager-pause-cmd");
}, },
}); });
// Closure code should awaits DB operations ONLY or fail // Closure code should awaits DB operations ONLY or fail
// https://github.com/jakearchibald/idb#user-content-transaction-lifetime // https://github.com/jakearchibald/idb#user-content-transaction-lifetime
db.runInTransaction = async (objectStores, closure, mode = 'readwrite', options = {durability: 'strict'}) => { db.runInTransaction = async (
objectStores,
closure,
mode = "readwrite",
options = { durability: "strict" },
) => {
try { try {
const tx = db.transaction(objectStores, mode, options); const tx = db.transaction(objectStores, mode, options);
@ -231,12 +320,11 @@ async function openDatabase() {
} catch (e) { } catch (e) {
throw e; throw e;
} }
} };
return db; return db;
} } catch (e) {
catch(e) { log.error("Can not open DB.");
log.error('Can not open DB.');
throw e; throw e;
} }

@ -1,51 +1,61 @@
import keyValueRepository from './repository/key-value'; import keyValueRepository from "./repository/key-value";
import createBeatMapsService from '../services/beatmaps' import createBeatMapsService from "../services/beatmaps";
import log from '../utils/logger'; import log from "../utils/logger";
import {db} from './db' import { db } from "./db";
import {isDateObject} from '../utils/js' import { isDateObject } from "../utils/js";
import twitchRepository from './repository/twitch' import twitchRepository from "./repository/twitch";
import {correctOldSsDate} from '../utils/date' import { correctOldSsDate } from "../utils/date";
const FIXES_KEY = 'data-fix'; const FIXES_KEY = "data-fix";
const getAppliedFixes = async () => keyValueRepository().get(FIXES_KEY, true); const getAppliedFixes = async () => keyValueRepository().get(FIXES_KEY, true);
const setAppliedFixes = async fixes => keyValueRepository().set(fixes, FIXES_KEY); const setAppliedFixes = async (fixes) =>
const addAppliedFix = async fixName => { keyValueRepository().set(fixes, FIXES_KEY);
const addAppliedFix = async (fixName) => {
let allAppliedFixes = await getAppliedFixes(); let allAppliedFixes = await getAppliedFixes();
allAppliedFixes = allAppliedFixes && Array.isArray(allAppliedFixes) ? allAppliedFixes : []; allAppliedFixes =
allAppliedFixes && Array.isArray(allAppliedFixes) ? allAppliedFixes : [];
allAppliedFixes.push(fixName); allAppliedFixes.push(fixName);
await setAppliedFixes(allAppliedFixes); await setAppliedFixes(allAppliedFixes);
} };
const allFixes = { const allFixes = {
'rankeds-20210725': { "rankeds-20210725": {
apply: async fixName => { apply: async (fixName) => {
log.info('Apply rankeds refresh fix (20210725)') log.info("Apply rankeds refresh fix (20210725)");
return db.runInTransaction(['rankeds-changes', 'rankeds', 'key-value'], async tx => { return db.runInTransaction(
await tx.objectStore('rankeds-changes').clear(); ["rankeds-changes", "rankeds", "key-value"],
await tx.objectStore('rankeds').clear(); async (tx) => {
await tx.objectStore("rankeds-changes").clear();
await tx.objectStore("rankeds").clear();
const keyValueStore = tx.objectStore('key-value') const keyValueStore = tx.objectStore("key-value");
keyValueStore.delete('rankedsLastUpdated'); keyValueStore.delete("rankedsLastUpdated");
let allAppliedFixes = await keyValueStore.get(FIXES_KEY); let allAppliedFixes = await keyValueStore.get(FIXES_KEY);
allAppliedFixes = allAppliedFixes && Array.isArray(allAppliedFixes) ? allAppliedFixes : []; allAppliedFixes =
allAppliedFixes && Array.isArray(allAppliedFixes)
? allAppliedFixes
: [];
allAppliedFixes.push(fixName); allAppliedFixes.push(fixName);
await keyValueStore.put(allAppliedFixes, FIXES_KEY); await keyValueStore.put(allAppliedFixes, FIXES_KEY);
}); },
);
}, },
}, },
'beatsaver-20210804': { "beatsaver-20210804": {
apply: async fixName => { apply: async (fixName) => {
log.info('Converting BeatSaver songs to a new format...', 'DBFix') log.info("Converting BeatSaver songs to a new format...", "DBFix");
return db.runInTransaction(['songs', 'songs-beatmaps', 'key-value'], async tx => { return db.runInTransaction(
const songsBeatMapsStore = tx.objectStore('songs-beatmaps'); ["songs", "songs-beatmaps", "key-value"],
async (tx) => {
const songsBeatMapsStore = tx.objectStore("songs-beatmaps");
let cursor = await tx.objectStore('songs').openCursor(); let cursor = await tx.objectStore("songs").openCursor();
let songCount = 0; let songCount = 0;
@ -55,90 +65,109 @@ const allFixes = {
const beatSaverSong = cursor.value; const beatSaverSong = cursor.value;
if (beatSaverSong?.metadata?.characteristics) { if (beatSaverSong?.metadata?.characteristics) {
const beatMapsSong = beatmapsService.convertOldBeatSaverToBeatMaps(beatSaverSong); const beatMapsSong =
beatmapsService.convertOldBeatSaverToBeatMaps(beatSaverSong);
if (beatMapsSong) { if (beatMapsSong) {
songsBeatMapsStore.put(beatMapsSong) songsBeatMapsStore.put(beatMapsSong);
songCount++; songCount++;
} else { } else {
log.info(`Unable to convert, deleting a song`, 'DBFix', beatSaverSong); log.info(
`Unable to convert, deleting a song`,
"DBFix",
beatSaverSong,
);
} }
} else { } else {
log.info(`No metadata characteristics, skipping a song`, 'DBFix', beatSaverSong); log.info(
`No metadata characteristics, skipping a song`,
"DBFix",
beatSaverSong,
);
} }
cursor = await cursor.continue(); cursor = await cursor.continue();
} }
const keyValueStore = tx.objectStore('key-value') const keyValueStore = tx.objectStore("key-value");
let allAppliedFixes = await keyValueStore.get(FIXES_KEY); let allAppliedFixes = await keyValueStore.get(FIXES_KEY);
allAppliedFixes = allAppliedFixes && Array.isArray(allAppliedFixes) ? allAppliedFixes : []; allAppliedFixes =
allAppliedFixes && Array.isArray(allAppliedFixes)
? allAppliedFixes
: [];
allAppliedFixes.push(fixName); allAppliedFixes.push(fixName);
await keyValueStore.put(allAppliedFixes, FIXES_KEY); await keyValueStore.put(allAppliedFixes, FIXES_KEY);
log.info(`${songCount} BeatSaver song(s) converted`, 'DBFix') log.info(`${songCount} BeatSaver song(s) converted`, "DBFix");
}); },
} );
},
}, },
'twitch-20210808': { "twitch-20210808": {
apply: async fixName => { apply: async (fixName) => {
const predefinedProfiles = { const predefinedProfiles = {
'76561198059659922': 'patian25', "76561198059659922": "patian25",
'1994101560659098': 'xoxobluff', 1994101560659098: "xoxobluff",
'76561198138327464': 'altrowilddog', "76561198138327464": "altrowilddog",
'76561198855288628': 'inbourne', "76561198855288628": "inbourne",
'76561198136177445': 'riviengt', "76561198136177445": "riviengt",
'76561199004224834': 'nyaanos', "76561199004224834": "nyaanos",
'76561198023909718': 'danielduel', "76561198023909718": "danielduel",
'76561198212019365': 'fnyt', "76561198212019365": "fnyt",
'76561197966674102': 'maciekvr', "76561197966674102": "maciekvr",
'76561198025451538': 'drakonno', "76561198025451538": "drakonno",
'76561197994110158': 'sanorek', "76561197994110158": "sanorek",
'76561198034203862': 'vr_agent', "76561198034203862": "vr_agent",
'3702342373170767': 'xjedam', 3702342373170767: "xjedam",
'76561197995161445': 'mediekore', "76561197995161445": "mediekore",
'76561198087710981': 'shreddyfreddy', "76561198087710981": "shreddyfreddy",
'76561198999385463': 'woltixo', "76561198999385463": "woltixo",
'76561198035381239': 'motzel', "76561198035381239": "motzel",
'76561198178407566' : 'acetari', "76561198178407566": "acetari",
'76561198045386379': 'duhhello', "76561198045386379": "duhhello",
'76561198835772160': 'tornadoef6', 76561198835772160: "tornadoef6",
'76561198187936410': 'garsh_', "76561198187936410": "garsh_",
'76561198362923485': 'tseska_', "76561198362923485": "tseska_",
'76561198154190170': 'tieeli', "76561198154190170": "tieeli",
'76561198333869741': 'cerret07', "76561198333869741": "cerret07",
'76561197995162898': 'electrostats', "76561197995162898": "electrostats",
'76561198166289091': 'rocker1904', "76561198166289091": "rocker1904",
'2538637699496776': 'astrella_', 2538637699496776: "astrella_",
'76561198171842815': 'coolpickb', "76561198171842815": "coolpickb",
'76561198145281261': 'harbgy' "76561198145281261": "harbgy",
} };
log.info('Adding predefined Twitch profiles...', 'DBFix') log.info("Adding predefined Twitch profiles...", "DBFix");
const updatePlayerTwitchProfile = async (twitchProfile) => twitchRepository().set(twitchProfile); const updatePlayerTwitchProfile = async (twitchProfile) =>
twitchRepository().set(twitchProfile);
await Promise.all(Object.entries(predefinedProfiles).map(async ([playerId, twitchLogin]) => updatePlayerTwitchProfile( await Promise.all(
{ Object.entries(predefinedProfiles).map(
async ([playerId, twitchLogin]) =>
updatePlayerTwitchProfile({
lastUpdated: null, lastUpdated: null,
login: twitchLogin, login: twitchLogin,
playerId playerId,
} }),
))) ),
);
await addAppliedFix(fixName); await addAppliedFix(fixName);
log.info('Twitch profiles added.', 'DBFix') log.info("Twitch profiles added.", "DBFix");
} },
}, },
'player-history-20211022': { "player-history-20211022": {
apply: async fixName => { apply: async (fixName) => {
log.info('Apply player ss history fix (20211022)') log.info("Apply player ss history fix (20211022)");
return db.runInTransaction(['players-history', 'key-value'], async tx => { return db.runInTransaction(
const playersHistoryStore = tx.objectStore('players-history'); ["players-history", "key-value"],
async (tx) => {
const playersHistoryStore = tx.objectStore("players-history");
let cursor = await playersHistoryStore.openCursor(); let cursor = await playersHistoryStore.openCursor();
@ -157,33 +186,46 @@ const allFixes = {
const playerIdSsTimestamp = `${playerId}_${ssDate.getTime()}`; const playerIdSsTimestamp = `${playerId}_${ssDate.getTime()}`;
await cursor.delete(); await cursor.delete();
playersHistoryStore.put({...history, ssDate, playerIdSsTimestamp}); playersHistoryStore.put({
...history,
ssDate,
playerIdSsTimestamp,
});
cursor = await cursor.continue(); cursor = await cursor.continue();
} }
const keyValueStore = tx.objectStore('key-value') const keyValueStore = tx.objectStore("key-value");
let allAppliedFixes = await keyValueStore.get(FIXES_KEY); let allAppliedFixes = await keyValueStore.get(FIXES_KEY);
allAppliedFixes = allAppliedFixes && Array.isArray(allAppliedFixes) ? allAppliedFixes : []; allAppliedFixes =
allAppliedFixes && Array.isArray(allAppliedFixes)
? allAppliedFixes
: [];
allAppliedFixes.push(fixName); allAppliedFixes.push(fixName);
await keyValueStore.put(allAppliedFixes, FIXES_KEY); await keyValueStore.put(allAppliedFixes, FIXES_KEY);
}); },
);
}, },
}, },
}; };
export default async () => { export default async () => {
let appliedDbFixes = await getAppliedFixes(); let appliedDbFixes = await getAppliedFixes();
const appliedFixes = appliedDbFixes && Array.isArray(appliedDbFixes) ? appliedDbFixes : []; const appliedFixes =
const neededFixes = Object.keys(allFixes).filter(f => !appliedFixes.includes(f) && (!allFixes[f].validTo || allFixes[f].validTo > new Date())); appliedDbFixes && Array.isArray(appliedDbFixes) ? appliedDbFixes : [];
const neededFixes = Object.keys(allFixes).filter(
(f) =>
!appliedFixes.includes(f) &&
(!allFixes[f].validTo || allFixes[f].validTo > new Date()),
);
if (!neededFixes.length) return; if (!neededFixes.length) return;
document.body.innerHTML = '<p>Database conversion. Please wait...</p>'; document.body.innerHTML = "<p>Database conversion. Please wait...</p>";
for (let key of neededFixes) { for (let key of neededFixes) {
await allFixes[key].apply(key); await allFixes[key].apply(key);
} }
document.body.innerHTML = ''; document.body.innerHTML = "";
} };

@ -1,19 +1,29 @@
import cacheRepository from './repository/cache'; import cacheRepository from "./repository/cache";
import groupsRepository from './repository/groups'; import groupsRepository from "./repository/groups";
import keyValueRepository from './repository/key-value'; import keyValueRepository from "./repository/key-value";
import playersRepository from './repository/players'; import playersRepository from "./repository/players";
import playersHistoryRepository from './repository/players-history'; import playersHistoryRepository from "./repository/players-history";
import rankedsRepository from './repository/rankeds'; import rankedsRepository from "./repository/rankeds";
import rankedsChangesRepository from './repository/rankeds-changes'; import rankedsChangesRepository from "./repository/rankeds-changes";
import scoresRepository from './repository/scores'; import scoresRepository from "./repository/scores";
import songsRepository from './repository/songs'; import songsRepository from "./repository/songs";
import twitchRepository from './repository/twitch'; import twitchRepository from "./repository/twitch";
import log from '../utils/logger'; import log from "../utils/logger";
export default () => { export default () => {
log.debug('Initialize DB repositories'); log.debug("Initialize DB repositories");
// initialize all repositories in order to create cache to sync // initialize all repositories in order to create cache to sync
[cacheRepository, groupsRepository, keyValueRepository, playersRepository, playersHistoryRepository, rankedsRepository, rankedsChangesRepository, scoresRepository, songsRepository, twitchRepository].map(repository => repository()); [
} cacheRepository,
groupsRepository,
keyValueRepository,
playersRepository,
playersHistoryRepository,
rankedsRepository,
rankedsChangesRepository,
scoresRepository,
songsRepository,
twitchRepository,
].map((repository) => repository());
};

@ -1,3 +1,3 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('accsaber-categories', 'name'); export default () => createRepository("accsaber-categories", "name");

@ -1,6 +1,7 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('accsaber-players-history', 'playerIdTimestamp', { export default () =>
'accsaber-players-history-playerId': 'playerId', createRepository("accsaber-players-history", "playerIdTimestamp", {
'accsaber-players-history-playerIdTimestamp': 'playerIdTimestamp' "accsaber-players-history-playerId": "playerId",
"accsaber-players-history-playerIdTimestamp": "playerIdTimestamp",
}); });

@ -1,10 +1,7 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository( export default () =>
'accsaber-players', createRepository("accsaber-players", "id", {
'id', "accsaber-players-playerId": "playerId",
{ "accsaber-players-category": "category",
'accsaber-players-playerId': 'playerId', });
'accsaber-players-category': 'category',
},
);

@ -1,3 +1,3 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('beat-savior-files', 'fileId'); export default () => createRepository("beat-savior-files", "fileId");

@ -1,3 +1,3 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('beat-savior-players', 'playerId'); export default () => createRepository("beat-savior-players", "playerId");

@ -1,6 +1,7 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('beat-savior', 'beatSaviorId', { export default () =>
'beat-savior-playerId': 'playerId', createRepository("beat-savior", "beatSaviorId", {
'beat-savior-hash': 'hash', "beat-savior-playerId": "playerId",
"beat-savior-hash": "hash",
}); });

@ -1,3 +1,3 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('cache'); export default () => createRepository("cache");

@ -1,11 +1,11 @@
import cache from '../cache'; import cache from "../cache";
import {db} from '../db'; import { db } from "../db";
import {convertArrayToObjectByKey} from '../../utils/js' import { convertArrayToObjectByKey } from "../../utils/js";
import makePendingPromisePool from '../../utils/pending-promises' import makePendingPromisePool from "../../utils/pending-promises";
import eventBus from '../../utils/broadcast-channel-pubsub' import eventBus from "../../utils/broadcast-channel-pubsub";
export const ALL_KEY = '__ALL'; export const ALL_KEY = "__ALL";
const NONE_KEY = '__NONE'; const NONE_KEY = "__NONE";
let repositories = {}; let repositories = {};
@ -20,46 +20,52 @@ export default (storeName, inlineKeyName = undefined, indexesKeyNames = {}) => {
const getKeyName = () => inlineKeyName; const getKeyName = () => inlineKeyName;
const hasOutOfLineKey = () => getKeyName() === undefined; const hasOutOfLineKey = () => getKeyName() === undefined;
const getObjKey = (obj, outOfLineKey = undefined) => { const getObjKey = (obj, outOfLineKey = undefined) => {
const key = hasOutOfLineKey() ? outOfLineKey : obj[inlineKeyName] const key = hasOutOfLineKey() ? outOfLineKey : obj[inlineKeyName];
return key ? key : outOfLineKey; return key ? key : outOfLineKey;
} };
let repositoryCache = cache(repositoryName, getObjKey); let repositoryCache = cache(repositoryName, getObjKey);
const getCacheKeyFor = (query, indexName) => (indexName ? indexName : ALL_KEY) + '-' + (query ? query : NONE_KEY); const getCacheKeyFor = (query, indexName) =>
(indexName ? indexName : ALL_KEY) + "-" + (query ? query : NONE_KEY);
const getFieldForIndexName = indexName => indexesKeyNames[indexName]; const getFieldForIndexName = (indexName) => indexesKeyNames[indexName];
const isFieldForIndexDefined = indexName => !!getFieldForIndexName(indexName); const isFieldForIndexDefined = (indexName) =>
!!getFieldForIndexName(indexName);
const setDataAvailabilityStatus = cacheKey => dataAvailableFor[cacheKey] = true; const setDataAvailabilityStatus = (cacheKey) =>
const setAllDataAvailabilityStatus = () => setDataAvailabilityStatus(getCacheKeyFor()); (dataAvailableFor[cacheKey] = true);
const removeDataAvailabilityStatus = cacheKey => { const setAllDataAvailabilityStatus = () =>
setDataAvailabilityStatus(getCacheKeyFor());
const removeDataAvailabilityStatus = (cacheKey) => {
delete dataAvailableFor[cacheKey]; delete dataAvailableFor[cacheKey];
delete dataAvailableFor[getCacheKeyFor()]; delete dataAvailableFor[getCacheKeyFor()];
} };
const flushDataAvailabilityStatus = () => dataAvailableFor = {}; const flushDataAvailabilityStatus = () => (dataAvailableFor = {});
const isIndexDataAvailable = cacheKey => !!dataAvailableFor[cacheKey]; const isIndexDataAvailable = (cacheKey) => !!dataAvailableFor[cacheKey];
const isAllDataAvailable = () => isIndexDataAvailable(getCacheKeyFor()); const isAllDataAvailable = () => isIndexDataAvailable(getCacheKeyFor());
const flushCache = () => { const flushCache = () => {
repositoryCache.flush(); repositoryCache.flush();
flushDataAvailabilityStatus(); flushDataAvailabilityStatus();
} };
const forgetCacheKey = key => repositoryCache.forget(key); const forgetCacheKey = (key) => repositoryCache.forget(key);
const forgetObject = async obj => { const forgetObject = async (obj) => {
if (hasOutOfLineKey()) throw 'forgetObject function is not available in repositories with out-of-line keys'; if (hasOutOfLineKey())
throw "forgetObject function is not available in repositories with out-of-line keys";
const key = getObjKey(obj); const key = getObjKey(obj);
if (!key) throw `Object does not contain ${inlineKeyName} field which is repository key`; if (!key)
throw `Object does not contain ${inlineKeyName} field which is repository key`;
forgetCacheKey(key); forgetCacheKey(key);
} };
const getStoreName = () => storeName; const getStoreName = () => storeName;
const getCachedKeys = _ => repositoryCache.getKeys(); const getCachedKeys = (_) => repositoryCache.getKeys();
const getAllKeys = async () => db.getAllKeys(storeName); const getAllKeys = async () => db.getAllKeys(storeName);
@ -68,16 +74,23 @@ export default (storeName, inlineKeyName = undefined, indexesKeyNames = {}) => {
const cacheKey = getCacheKeyFor(key); const cacheKey = getCacheKeyFor(key);
return repositoryCache.get(key, () => resolvePromiseOrWaitForPending(cacheKey, () => db.get(storeName, key))); return repositoryCache.get(key, () =>
resolvePromiseOrWaitForPending(cacheKey, () => db.get(storeName, key)),
);
}; };
const getFromIndex = async (indexName, query, refreshCache = false) => { const getFromIndex = async (indexName, query, refreshCache = false) => {
if (hasOutOfLineKey()) throw `getFromIndex() is not available for stores with out-of-line key`; if (hasOutOfLineKey())
if (!isFieldForIndexDefined(indexName)) throw `Index ${indexName} has no field set`; throw `getFromIndex() is not available for stores with out-of-line key`;
if (!isFieldForIndexDefined(indexName))
throw `Index ${indexName} has no field set`;
const cacheKey = getCacheKeyFor(query, indexName + '-single'); const cacheKey = getCacheKeyFor(query, indexName + "-single");
const getFromDb = () => resolvePromiseOrWaitForPending(cacheKey, () => db.getFromIndex(storeName, indexName, query)); const getFromDb = () =>
resolvePromiseOrWaitForPending(cacheKey, () =>
db.getFromIndex(storeName, indexName, query),
);
if (query && query instanceof IDBKeyRange) return getFromDb(); if (query && query instanceof IDBKeyRange) return getFromDb();
@ -85,7 +98,8 @@ export default (storeName, inlineKeyName = undefined, indexesKeyNames = {}) => {
const fullIndexCacheKey = getCacheKeyFor(query, indexName); const fullIndexCacheKey = getCacheKeyFor(query, indexName);
const filterItems = item => item !== undefined && (!query || item[field] === query); const filterItems = (item) =>
item !== undefined && (!query || item[field] === query);
if (refreshCache) { if (refreshCache) {
removeDataAvailabilityStatus(cacheKey); removeDataAvailabilityStatus(cacheKey);
@ -94,24 +108,34 @@ export default (storeName, inlineKeyName = undefined, indexesKeyNames = {}) => {
repositoryCache.forgetByFilter(filterItems); repositoryCache.forgetByFilter(filterItems);
} }
return repositoryCache.getByFilter(getFromDb, isAllDataAvailable() || isIndexDataAvailable(cacheKey) || isIndexDataAvailable(fullIndexCacheKey) ? filterItems : null); return repositoryCache.getByFilter(
getFromDb,
isAllDataAvailable() ||
isIndexDataAvailable(cacheKey) ||
isIndexDataAvailable(fullIndexCacheKey)
? filterItems
: null,
);
}; };
const getAll = async (refreshCache = false) => { const getAll = async (refreshCache = false) => {
const cacheKey = getCacheKeyFor(); const cacheKey = getCacheKeyFor();
const getFromDb = () => resolvePromiseOrWaitForPending(cacheKey, () => db.getAll(storeName)) const getFromDb = () =>
resolvePromiseOrWaitForPending(cacheKey, () => db.getAll(storeName));
if (hasOutOfLineKey()) return getFromDb(); if (hasOutOfLineKey()) return getFromDb();
if (refreshCache) flushCache(); if (refreshCache) flushCache();
const filterUndefined = item => item !== undefined; const filterUndefined = (item) => item !== undefined;
if (!isAllDataAvailable()) { if (!isAllDataAvailable()) {
const data = convertArrayToObjectByKey(await getFromDb(), inlineKeyName); const data = convertArrayToObjectByKey(await getFromDb(), inlineKeyName);
const ret = Object.values(repositoryCache.setAll(data)).filter(filterUndefined); const ret = Object.values(repositoryCache.setAll(data)).filter(
filterUndefined,
);
setAllDataAvailabilityStatus(); setAllDataAvailabilityStatus();
@ -119,28 +143,39 @@ export default (storeName, inlineKeyName = undefined, indexesKeyNames = {}) => {
} }
return Object.values(repositoryCache.getAll()).filter(filterUndefined); return Object.values(repositoryCache.getAll()).filter(filterUndefined);
} };
const getAllFromIndex = async(indexName, query = undefined, refreshCache = false) => { const getAllFromIndex = async (
if (hasOutOfLineKey()) throw `getAllFromIndex() is not available for stores with out-of-line key`; indexName,
if (!isFieldForIndexDefined(indexName)) throw `Index ${indexName} has no field set`; query = undefined,
refreshCache = false,
) => {
if (hasOutOfLineKey())
throw `getAllFromIndex() is not available for stores with out-of-line key`;
if (!isFieldForIndexDefined(indexName))
throw `Index ${indexName} has no field set`;
const cacheKey = getCacheKeyFor(query, indexName); const cacheKey = getCacheKeyFor(query, indexName);
const getFromDb = async () => resolvePromiseOrWaitForPending(cacheKey, () => db.getAllFromIndex(storeName, indexName, query)); const getFromDb = async () =>
resolvePromiseOrWaitForPending(cacheKey, () =>
db.getAllFromIndex(storeName, indexName, query),
);
if (query && query instanceof IDBKeyRange) return getFromDb(); if (query && query instanceof IDBKeyRange) return getFromDb();
const field = getFieldForIndexName(indexName); const field = getFieldForIndexName(indexName);
const filterItems = item => item !== undefined && (!query || item[field] === query); const filterItems = (item) =>
item !== undefined && (!query || item[field] === query);
if (refreshCache) { if (refreshCache) {
removeDataAvailabilityStatus(cacheKey); removeDataAvailabilityStatus(cacheKey);
repositoryCache.forgetByFilter(filterItems); repositoryCache.forgetByFilter(filterItems);
} }
const getFromDbAndUpdateCache = async () => resolvePromiseOrWaitForPending(`${cacheKey}-updateDb`, async () => { const getFromDbAndUpdateCache = async () =>
resolvePromiseOrWaitForPending(`${cacheKey}-updateDb`, async () => {
const data = await getFromDb(); const data = await getFromDb();
repositoryCache.merge(convertArrayToObjectByKey(data, inlineKeyName)); repositoryCache.merge(convertArrayToObjectByKey(data, inlineKeyName));
@ -148,63 +183,71 @@ export default (storeName, inlineKeyName = undefined, indexesKeyNames = {}) => {
setDataAvailabilityStatus(cacheKey); setDataAvailabilityStatus(cacheKey);
return data; return data;
}) });
if (!isAllDataAvailable() && !isIndexDataAvailable(cacheKey)) return await getFromDbAndUpdateCache(); if (!isAllDataAvailable() && !isIndexDataAvailable(cacheKey))
return await getFromDbAndUpdateCache();
return Object.values(repositoryCache.getAll()).filter(filterItems); return Object.values(repositoryCache.getAll()).filter(filterItems);
} };
const set = async (value, key = undefined, tx = null) => { const set = async (value, key = undefined, tx = null) => {
const txStores = tx ? [...tx.objectStoreNames] : null; const txStores = tx ? [...tx.objectStoreNames] : null;
let putKey; let putKey;
if (tx && txStores.includes(storeName)) { if (tx && txStores.includes(storeName)) {
putKey = await tx.objectStore(storeName).put(value, inlineKeyName ? undefined : key); putKey = await tx
.objectStore(storeName)
.put(value, inlineKeyName ? undefined : key);
} else { } else {
putKey = await db.put(storeName, value, inlineKeyName ? undefined : key) putKey = await db.put(storeName, value, inlineKeyName ? undefined : key);
} }
if (!hasOutOfLineKey() && !getObjKey(value)) value[inlineKeyName] = putKey; if (!hasOutOfLineKey() && !getObjKey(value)) value[inlineKeyName] = putKey;
return repositoryCache.set(getObjKey(value, key), value); return repositoryCache.set(getObjKey(value, key), value);
} };
const del = async key => { const del = async (key) => {
await db.delete(storeName, key); await db.delete(storeName, key);
return repositoryCache.forget(key); return repositoryCache.forget(key);
} };
const deleteObject = async obj => { const deleteObject = async (obj) => {
if (hasOutOfLineKey()) throw 'deleteObject function is not available in repositories with out-of-line keys'; if (hasOutOfLineKey())
throw "deleteObject function is not available in repositories with out-of-line keys";
const key = getObjKey(obj); const key = getObjKey(obj);
if (!key) throw `Object does not contain ${inlineKeyName} field which is repository key`; if (!key)
throw `Object does not contain ${inlineKeyName} field which is repository key`;
return del(key); return del(key);
} };
const openCursor = async (mode = 'readonly') => db.transaction(storeName, mode).store.openCursor(); const openCursor = async (mode = "readonly") =>
db.transaction(storeName, mode).store.openCursor();
const setCache = (value, key) => { const setCache = (value, key) => {
if (hasOutOfLineKey()) { if (hasOutOfLineKey()) {
if (!key) throw `setCache() needs a key for stores (${storeName}) with out-of-line keys`; if (!key)
throw `setCache() needs a key for stores (${storeName}) with out-of-line keys`;
} else { } else {
key = getObjKey(value, key); key = getObjKey(value, key);
} }
repositoryCache.set(key, value); repositoryCache.set(key, value);
} };
const addToCache = data => { const addToCache = (data) => {
if (hasOutOfLineKey()) throw `addToCache() is not available for stores (${storeName}) with out-of-line key`; if (hasOutOfLineKey())
throw `addToCache() is not available for stores (${storeName}) with out-of-line key`;
repositoryCache.merge(convertArrayToObjectByKey(data, inlineKeyName)); repositoryCache.merge(convertArrayToObjectByKey(data, inlineKeyName));
} };
const getCache = () => repositoryCache; const getCache = () => repositoryCache;
return repositories[repositoryName] = { return (repositories[repositoryName] = {
getStoreName, getStoreName,
hasOutOfLineKey, hasOutOfLineKey,
getAllKeys, getAllKeys,
@ -224,5 +267,5 @@ export default (storeName, inlineKeyName = undefined, indexesKeyNames = {}) => {
setCache, setCache,
addToCache, addToCache,
getCache, getCache,
}; });
}; };

@ -1,5 +1,11 @@
import createRepository from './generic'; import createRepository from "./generic";
let repository; let repository;
export default () => repository ? repository : repository = createRepository('groups', '_idbId', {'groups-name': 'name', 'groups-playerId': 'playerId'}); export default () =>
repository
? repository
: (repository = createRepository("groups", "_idbId", {
"groups-name": "name",
"groups-playerId": "playerId",
}));

@ -1,3 +1,3 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('key-value'); export default () => createRepository("key-value");

@ -1,6 +1,7 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('players-history', '_idbId', { export default () =>
'players-history-playerId': 'playerId', createRepository("players-history", "_idbId", {
'players-history-playerIdSsTimestamp': 'playerIdSsTimestamp' "players-history-playerId": "playerId",
"players-history-playerIdSsTimestamp": "playerIdSsTimestamp",
}); });

@ -1,3 +1,3 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('players', 'playerId'); export default () => createRepository("players", "playerId");

@ -1,3 +1,7 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('rankeds-changes', '_idbId', {'rankeds-changes-timestamp': 'timestamp', 'rankeds-changes-leaderboardId': 'leaderboardId'}); export default () =>
createRepository("rankeds-changes", "_idbId", {
"rankeds-changes-timestamp": "timestamp",
"rankeds-changes-leaderboardId": "leaderboardId",
});

@ -1,3 +1,3 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('rankeds', 'leaderboardId'); export default () => createRepository("rankeds", "leaderboardId");

@ -1,9 +1,6 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository( export default () =>
'scores-update-queue', createRepository("scores-update-queue", "id", {
'id', "scores-update-queue-fetchedAt": "fetchedAt",
{ });
'scores-update-queue-fetchedAt': 'fetchedAt',
},
)

@ -1,12 +1,9 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository( export default () =>
'scores', createRepository("scores", "id", {
'id', "scores-timeset": "timeset",
{ "scores-leaderboardId": "leaderboardId",
'scores-timeset': 'timeset', "scores-playerId": "playerId",
'scores-leaderboardId': 'leaderboardId', "scores-pp": "pp",
'scores-playerId': 'playerId', });
'scores-pp': 'pp',
},
)

@ -1,3 +1,4 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('songs-beatmaps', 'hash', {'songs-beatmaps-key': 'key'}); export default () =>
createRepository("songs-beatmaps", "hash", { "songs-beatmaps-key": "key" });

@ -1,3 +1,3 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('songs', 'hash', {'songs-key': 'key'}); export default () => createRepository("songs", "hash", { "songs-key": "key" });

@ -1,3 +1,3 @@
import createRepository from './generic'; import createRepository from "./generic";
export default () => createRepository('twitch', 'playerId'); export default () => createRepository("twitch", "playerId");

@ -1,18 +1,18 @@
import App from './App.svelte'; import App from "./App.svelte";
import log from './utils/logger' import log from "./utils/logger";
import initDb from './db/db' import initDb from "./db/db";
import initializeRepositories from './db/repositories-init'; import initializeRepositories from "./db/repositories-init";
import setupDataFixes from './db/fix-data' import setupDataFixes from "./db/fix-data";
import createConfigStore from './stores/config' import createConfigStore from "./stores/config";
import createPlayerService from './services/scoresaber/player' import createPlayerService from "./services/scoresaber/player";
import createBeatSaviorService from './services/beatsavior' import createBeatSaviorService from "./services/beatsavior";
import createRankedsStore from './stores/scoresaber/rankeds' import createRankedsStore from "./stores/scoresaber/rankeds";
import initDownloadManager from './network/download-manager' import initDownloadManager from "./network/download-manager";
import initCommandProcessor from './network/command-processor' import initCommandProcessor from "./network/command-processor";
import {enablePatches, setAutoFreeze} from 'immer' import { enablePatches, setAutoFreeze } from "immer";
import {initCompareEnhancer} from './stores/http/enhancers/scores/compare' import { initCompareEnhancer } from "./stores/http/enhancers/scores/compare";
import ErrorComponent from './components/Common/Error.svelte' import ErrorComponent from "./components/Common/Error.svelte";
import initializeWorkers from './utils/worker-wrappers' import initializeWorkers from "./utils/worker-wrappers";
let app = null; let app = null;
@ -22,7 +22,7 @@ let app = null;
// log.setLevel(log.TRACE); // log.setLevel(log.TRACE);
// log.logOnly(['AccSaberService']); // log.logOnly(['AccSaberService']);
log.info('Starting up...', 'Main') log.info("Starting up...", "Main");
await initDb(); await initDb();
await initializeRepositories(); await initializeRepositories();
@ -47,7 +47,7 @@ let app = null;
initCommandProcessor(await initDownloadManager()); initCommandProcessor(await initDownloadManager());
log.info('Site initialized', 'Main') log.info("Site initialized", "Main");
app = new App({ app = new App({
target: document.body, target: document.body,
@ -56,8 +56,14 @@ let app = null;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if (error instanceof DOMException && error.toString() === 'InvalidStateError: A mutation operation was attempted on a database that did not allow mutations.') if (
error = new Error('Firefox in private mode does not support the database. Please run the site in normal mode.') error instanceof DOMException &&
error.toString() ===
"InvalidStateError: A mutation operation was attempted on a database that did not allow mutations."
)
error = new Error(
"Firefox in private mode does not support the database. Please run the site in normal mode.",
);
app = new ErrorComponent({ app = new ErrorComponent({
target: document.body, target: document.body,
@ -66,5 +72,4 @@ let app = null;
} }
})(); })();
export default app; export default app;

@ -1,5 +1,5 @@
// import eventBus from '../utils/broadcast-channel-pubsub' // import eventBus from '../utils/broadcast-channel-pubsub'
import {addToDate, MINUTE} from '../utils/date' import { addToDate, MINUTE } from "../utils/date";
const DEFAULT_CACHE_SIZE = 100; const DEFAULT_CACHE_SIZE = 100;
@ -7,20 +7,25 @@ export default (size = DEFAULT_CACHE_SIZE, expiryIn = MINUTE) => {
let cache = {}; let cache = {};
let cacheSize = size; let cacheSize = size;
const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope const isWorker =
typeof WorkerGlobalScope !== "undefined" &&
self instanceof WorkerGlobalScope;
const defaultExpiryIn = expiryIn; const defaultExpiryIn = expiryIn;
const packValue = value => { const packValue = (value) => {
if (!value || typeof value !== 'object') return value; if (!value || typeof value !== "object") return value;
const newValue = { ...value }; const newValue = { ...value };
if (value.headers && value.headers instanceof Headers) { if (value.headers && value.headers instanceof Headers) {
newValue.headers = [...value.headers.entries()].reduce((cum, [key, value]) => { newValue.headers = [...value.headers.entries()].reduce(
(cum, [key, value]) => {
cum[key] = value; cum[key] = value;
return cum; return cum;
}, {}) },
{},
);
} }
if (value.body && value.body instanceof Document) { if (value.body && value.body instanceof Document) {
@ -28,25 +33,29 @@ export default (size = DEFAULT_CACHE_SIZE, expiryIn = MINUTE) => {
} }
return newValue; return newValue;
} };
const unpackValue = value => { const unpackValue = (value) => {
if (!value || typeof value !== 'object') return value; if (!value || typeof value !== "object") return value;
const newValue = { ...value }; const newValue = { ...value };
if (value.headers) { if (value.headers) {
const headers = new Headers(); const headers = new Headers();
Object.keys(value.headers).map(k => headers.append(k, value.headers[k])); Object.keys(value.headers).map((k) =>
headers.append(k, value.headers[k]),
);
newValue.headers = headers; newValue.headers = headers;
} }
if (value.body) { if (value.body) {
newValue.body = !isWorker ? new DOMParser().parseFromString(value.body, 'text/html') : value.body; newValue.body = !isWorker
? new DOMParser().parseFromString(value.body, "text/html")
: value.body;
} }
return newValue; return newValue;
} };
// update data cached on another node // update data cached on another node
// const setUnsubscribe = eventBus.on('net-cache-key-set', ({key, value, expiryIn}, isLocal) => !isLocal ? set(key, unpackValue(value), expiryIn, false) : null); // const setUnsubscribe = eventBus.on('net-cache-key-set', ({key, value, expiryIn}, isLocal) => !isLocal ? set(key, unpackValue(value), expiryIn, false) : null);
@ -54,14 +63,25 @@ export default (size = DEFAULT_CACHE_SIZE, expiryIn = MINUTE) => {
// const flushUnsubscribe = eventBus.on('net-cache-flush', (_, isLocal) => !isLocal ? flush(false) : null); // const flushUnsubscribe = eventBus.on('net-cache-flush', (_, isLocal) => !isLocal ? flush(false) : null);
const has = (key, maxAge = null, withExpired = false) => const has = (key, maxAge = null, withExpired = false) =>
cache.hasOwnProperty(key) && cache[key] && cache.hasOwnProperty(key) &&
(withExpired || !cache[key].expiryAt || cache[key].expiryAt >= new Date()) && cache[key] &&
(!Number.isFinite(maxAge) || !cache[key].cachedAt || addToDate(maxAge, cache[key].cachedAt) >= new Date()); (withExpired ||
!cache[key].expiryAt ||
cache[key].expiryAt >= new Date()) &&
(!Number.isFinite(maxAge) ||
!cache[key].cachedAt ||
addToDate(maxAge, cache[key].cachedAt) >= new Date());
const set = (key, value, expiryIn = null, emitEvent = true) => { const set = (key, value, expiryIn = null, emitEvent = true) => {
expiryIn = expiryIn ? expiryIn : defaultExpiryIn; expiryIn = expiryIn ? expiryIn : defaultExpiryIn;
cache[key] = {key, cachedAt: new Date(), expiryIn, expiryAt: addToDate(expiryIn, new Date()), value}; cache[key] = {
key,
cachedAt: new Date(),
expiryIn,
expiryAt: addToDate(expiryIn, new Date()),
value,
};
// if (emitEvent) eventBus.publish('net-cache-key-set', {key, value: packValue(value), expiryIn}); // if (emitEvent) eventBus.publish('net-cache-key-set', {key, value: packValue(value), expiryIn});
@ -70,7 +90,12 @@ export default (size = DEFAULT_CACHE_SIZE, expiryIn = MINUTE) => {
return value; return value;
}; };
const get = (key, maxAge = null, withExpired = false, valueOnly = true) => has(key, maxAge, withExpired) ? (valueOnly ? cache[key].value : cache[key]) : undefined; const get = (key, maxAge = null, withExpired = false, valueOnly = true) =>
has(key, maxAge, withExpired)
? valueOnly
? cache[key].value
: cache[key]
: undefined;
const getAll = () => cache; const getAll = () => cache;
@ -82,7 +107,7 @@ export default (size = DEFAULT_CACHE_SIZE, expiryIn = MINUTE) => {
// if (emitEvent) eventBus.publish('net-cache-key-forget', {key}); // if (emitEvent) eventBus.publish('net-cache-key-forget', {key});
return cache; return cache;
} };
const flush = (emitEvent = true) => { const flush = (emitEvent = true) => {
cache = {}; cache = {};
@ -90,7 +115,7 @@ export default (size = DEFAULT_CACHE_SIZE, expiryIn = MINUTE) => {
// if (emitEvent) eventBus.publish('net-cache-flush', {}); // if (emitEvent) eventBus.publish('net-cache-flush', {});
return cache; return cache;
} };
const garbageCollect = (size = cacheSize) => { const garbageCollect = (size = cacheSize) => {
const values = Object.values(cache); const values = Object.values(cache);
@ -99,14 +124,17 @@ export default (size = DEFAULT_CACHE_SIZE, expiryIn = MINUTE) => {
cache = values cache = values
.sort((a, b) => b.expiryAt - a.expiryAt) .sort((a, b) => b.expiryAt - a.expiryAt)
.slice(0, size) .slice(0, size)
.reduce((cum, item) => {cum[item.key] = item; return cum;}, {}); .reduce((cum, item) => {
} cum[item.key] = item;
return cum;
}, {});
};
const destroy = () => { const destroy = () => {
// setUnsubscribe(); // setUnsubscribe();
// forgetUnsubscribe(); // forgetUnsubscribe();
// flushUnsubscribe(); // flushUnsubscribe();
} };
return { return {
has, has,
@ -117,5 +145,5 @@ export default (size = DEFAULT_CACHE_SIZE, expiryIn = MINUTE) => {
forget, forget,
flush, flush,
destroy, destroy,
} };
} };

@ -1,18 +1,21 @@
import queue from '../../queues/queues' import queue from "../../queues/queues";
import createClient from '../generic' import createClient from "../generic";
const process = response => { const process = (response) => {
if (!response || !Array.isArray(response)) return []; if (!response || !Array.isArray(response)) return [];
return response.map(c => ({ return response.map((c) => ({
name: c.categoryName, name: c.categoryName,
displayName: c.categoryDisplayName, displayName: c.categoryDisplayName,
countsTowardsOverall: c.countsTowardsOverall, countsTowardsOverall: c.countsTowardsOverall,
description: c.description description: c.description,
})); }));
} };
const get = async ({priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.ACCSABER.categories(priority, queueOptions); const get = async ({
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.ACCSABER.categories(priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,10 +1,16 @@
import queue from '../../queues/queues' import queue from "../../queues/queues";
import createClient from '../generic' import createClient from "../generic";
import {dateFromString, formatDateRelative} from '../../../utils/date' import { dateFromString, formatDateRelative } from "../../../utils/date";
import {LEADERBOARD_SCORES_PER_PAGE} from '../../../utils/accsaber/consts' import { LEADERBOARD_SCORES_PER_PAGE } from "../../../utils/accsaber/consts";
const process = response => { const process = (response) => {
if (!response || !Array.isArray(response.responses) || response.responses.length !== 2 || !Array.isArray(response.responses[0])) return []; if (
!response ||
!Array.isArray(response.responses) ||
response.responses.length !== 2 ||
!Array.isArray(response.responses[0])
)
return [];
const page = response?.fetchOptions.page ?? 1; const page = response?.fetchOptions.page ?? 1;
const totalItems = response.responses[0].length; const totalItems = response.responses[0].length;
@ -25,16 +31,32 @@ const process = response => {
difficulty, difficulty,
} = mapInfo; } = mapInfo;
const song = {hash, name, subName, authorName, levelAuthorName, beatsaverKey}; const song = {
const diffInfo = {type: 'Standard', diff: difficulty?.toLowerCase()?.replace('plus', 'Plus')} hash,
const leaderboard = {leaderboardId, song, diffInfo, complexity, categoryDisplayName}; name,
subName,
authorName,
levelAuthorName,
beatsaverKey,
};
const diffInfo = {
type: "Standard",
diff: difficulty?.toLowerCase()?.replace("plus", "Plus"),
};
const leaderboard = {
leaderboardId,
song,
diffInfo,
complexity,
categoryDisplayName,
};
return { return {
page, page,
pageQty, pageQty,
totalItems, totalItems,
leaderboard, leaderboard,
scores: response.responses[0].map(s => { scores: response.responses[0].map((s) => {
let { let {
accuracy: acc, accuracy: acc,
ap, ap,
@ -48,14 +70,16 @@ const process = response => {
if (acc && Number.isFinite(acc)) acc *= 100; if (acc && Number.isFinite(acc)) acc *= 100;
timeSet = dateFromString(timeSet) timeSet = dateFromString(timeSet);
const timeSetString = formatDateRelative(timeSet); const timeSetString = formatDateRelative(timeSet);
return { return {
player: { player: {
name, name,
playerId, playerId,
playerInfo: {avatar: `https://cdn.accsaber.com/avatars/${playerId}.jpg`}, playerInfo: {
avatar: `https://cdn.accsaber.com/avatars/${playerId}.jpg`,
},
}, },
score: { score: {
acc, acc,
@ -67,19 +91,30 @@ const process = response => {
timeSetString, timeSetString,
}, },
other: rest, other: rest,
} };
}), }),
}; };
} };
const get = async ({leaderboardId, page = 1, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => { const get = async ({
leaderboardId,
page = 1,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => {
const responses = await Promise.all([ const responses = await Promise.all([
queue.ACCSABER.leaderboard(leaderboardId, page, priority, queueOptions), queue.ACCSABER.leaderboard(leaderboardId, page, priority, queueOptions),
queue.ACCSABER.leaderboardInfo(leaderboardId, priority, queueOptions) queue.ACCSABER.leaderboardInfo(leaderboardId, priority, queueOptions),
]); ]);
return {...responses[0], body: {responses: responses.map(r => r.body), fetchOptions: {leaderboardId, page}}} return {
} ...responses[0],
body: {
responses: responses.map((r) => r.body),
fetchOptions: { leaderboardId, page },
},
};
};
const client = createClient(get, process); const client = createClient(get, process);

@ -1,28 +1,43 @@
import queue from '../../queues/queues' import queue from "../../queues/queues";
import createClient from '../generic' import createClient from "../generic";
import {fromAccSaberDateString} from '../../../utils/date' import { fromAccSaberDateString } from "../../../utils/date";
import {isDateObject} from '../../../utils/js' import { isDateObject } from "../../../utils/js";
const process = response => { const process = (response) => {
const playerId = response?.fetchOptions?.playerId ?? null; const playerId = response?.fetchOptions?.playerId ?? null;
if (!response?.response || !Object.keys(response.response)?.length || !playerId) return []; if (
!response?.response ||
!Object.keys(response.response)?.length ||
!playerId
)
return [];
return { return {
playerId, playerId,
history: Object.entries(response.response) history: Object.entries(response.response)
.map(([date, rank]) => ({ date: fromAccSaberDateString(date), rank })) .map(([date, rank]) => ({ date: fromAccSaberDateString(date), rank }))
.filter(obj => isDateObject(obj?.date)) .filter((obj) => isDateObject(obj?.date))
.sort((a,b) => a.date.getTime() - b.date.getTime()) .sort((a, b) => a.date.getTime() - b.date.getTime()),
, };
} };
}
const get = async ({playerId, page = 1, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => { const get = async ({
const response = await queue.ACCSABER.playerRankHistory(playerId, priority, queueOptions); playerId,
page = 1,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => {
const response = await queue.ACCSABER.playerRankHistory(
playerId,
priority,
queueOptions,
);
return {...response, body: {response: response.body, fetchOptions: {playerId}}} return {
} ...response,
body: { response: response.body, fetchOptions: { playerId } },
};
};
const client = createClient(get, process); const client = createClient(get, process);

@ -1,23 +1,36 @@
import queue from '../../queues/queues' import queue from "../../queues/queues";
import createClient from '../generic' import createClient from "../generic";
const process = response => { const process = (response) => {
const category = response?.fetchOptions?.category ?? 'overall'; const category = response?.fetchOptions?.category ?? "overall";
if (!response?.response || !Array.isArray(response.response)) return []; if (!response?.response || !Array.isArray(response.response)) return [];
return response.response.map(p => ({ return response.response.map((p) => ({
...p, ...p,
id: `${p.playerId}-${category}`, id: `${p.playerId}-${category}`,
category, category,
lastUpdated: new Date(), lastUpdated: new Date(),
})); }));
} };
const get = async ({category = 'overall', page = 1, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => { const get = async ({
const response = await queue.ACCSABER.ranking(category, page, priority, queueOptions); category = "overall",
page = 1,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => {
const response = await queue.ACCSABER.ranking(
category,
page,
priority,
queueOptions,
);
return {...response, body: {response: response.body, fetchOptions: {category}}} return {
} ...response,
body: { response: response.body, fetchOptions: { category } },
};
};
const client = createClient(get, process); const client = createClient(get, process);

@ -1,12 +1,13 @@
import queue from '../../queues/queues' import queue from "../../queues/queues";
import createClient from '../generic' import createClient from "../generic";
import {dateFromString} from '../../../utils/date' import { dateFromString } from "../../../utils/date";
const process = response => { const process = (response) => {
const playerId = response?.fetchOptions?.playerId ?? null; const playerId = response?.fetchOptions?.playerId ?? null;
if (!response?.response || !Array.isArray(response.response) || !playerId) return []; if (!response?.response || !Array.isArray(response.response) || !playerId)
return [];
return response.response.map(s => { return response.response.map((s) => {
let { let {
songHash: hash, songHash: hash,
songName: name, songName: name,
@ -28,11 +29,27 @@ const process = response => {
leaderboardId = parseInt(leaderboardId, 10); leaderboardId = parseInt(leaderboardId, 10);
if (isNaN(leaderboardId)) leaderboardId = null; if (isNaN(leaderboardId)) leaderboardId = null;
const song = {hash, name, subName: '', authorName, levelAuthorName, beatsaverKey}; const song = {
const diffInfo = {type: 'Standard', diff: difficulty?.toLowerCase()?.replace('plus', 'Plus')} hash,
const leaderboard = {leaderboardId, song, diffInfo, complexity, categoryDisplayName}; name,
subName: "",
authorName,
levelAuthorName,
beatsaverKey,
};
const diffInfo = {
type: "Standard",
diff: difficulty?.toLowerCase()?.replace("plus", "Plus"),
};
const leaderboard = {
leaderboardId,
song,
diffInfo,
complexity,
categoryDisplayName,
};
const timeSet = dateFromString(s.timeSet) const timeSet = dateFromString(s.timeSet);
return { return {
id: `${playerId}-${s.leaderboardId}`, id: `${playerId}-${s.leaderboardId}`,
playerId, playerId,
@ -41,18 +58,41 @@ const process = response => {
ap, ap,
acc, acc,
leaderboard, leaderboard,
score: {...originalScore, ap, unmodifiedScore: score, score, mods: null, timeSet, acc, percentage: acc, weightedAp}, score: {
...originalScore,
ap,
unmodifiedScore: score,
score,
mods: null,
timeSet,
acc,
percentage: acc,
weightedAp,
},
fetchedAt: new Date(), fetchedAt: new Date(),
lastUpdated: new Date(), lastUpdated: new Date(),
} };
}); });
} };
const get = async ({playerId, page = 1, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => { const get = async ({
const response = await queue.ACCSABER.scores(playerId, page, priority, queueOptions); playerId,
page = 1,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => {
const response = await queue.ACCSABER.scores(
playerId,
page,
priority,
queueOptions,
);
return {...response, body: {response: response.body, fetchOptions: {playerId, page}}} return {
} ...response,
body: { response: response.body, fetchOptions: { playerId, page } },
};
};
const client = createClient(get, process); const client = createClient(get, process);

@ -1,8 +1,12 @@
import queue from '../../queues/queues' import queue from "../../queues/queues";
import createClient from '../generic' import createClient from "../generic";
import process from './utils/process' import process from "./utils/process";
const get = async ({hash, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.BEATMAPS.byHash(hash, priority, queueOptions); const get = async ({
hash,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.BEATMAPS.byHash(hash, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,8 +1,12 @@
import queue from '../../queues/queues' import queue from "../../queues/queues";
import createClient from '../generic' import createClient from "../generic";
import process from './utils/process' import process from "./utils/process";
const get = async ({key, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.BEATMAPS.byKey(key, priority, queueOptions); const get = async ({
key,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.BEATMAPS.byKey(key, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,15 +1,15 @@
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
export default response => { export default (response) => {
const versions = opt(response, 'versions'); const versions = opt(response, "versions");
if (!versions || !Array.isArray(versions) || !versions.length) return null; if (!versions || !Array.isArray(versions) || !versions.length) return null;
const lastIdx = versions.length - 1; const lastIdx = versions.length - 1;
const hash = opt(versions, `${lastIdx}.hash`); const hash = opt(versions, `${lastIdx}.hash`);
const key = opt(response, 'id'); const key = opt(response, "id");
if (!hash || !key || !hash.toLowerCase) return null; if (!hash || !key || !hash.toLowerCase) return null;
return {...response, hash: hash.toLowerCase(), key} return { ...response, hash: hash.toLowerCase(), key };
} };

@ -1,6 +1,6 @@
import queue from '../../queues/queues' import queue from "../../queues/queues";
import {dateFromString} from '../../../utils/date' import { dateFromString } from "../../../utils/date";
import createClient from '../generic' import createClient from "../generic";
const SONG_DATA_TYPES = { const SONG_DATA_TYPES = {
None: 0, None: 0,
@ -8,14 +8,14 @@ const SONG_DATA_TYPES = {
Fail: 2, Fail: 2,
Practice: 3, Practice: 3,
Replay: 4, Replay: 4,
Campaign: 5 Campaign: 5,
} };
const process = response => { const process = (response) => {
if (!response || !Array.isArray(response)) return null; if (!response || !Array.isArray(response)) return null;
return response return response
.map(s => { .map((s) => {
let { let {
_id: beatSaviorId, _id: beatSaviorId,
playerID: playerId, playerID: playerId,
@ -29,28 +29,65 @@ const process = response => {
timeSet, timeSet,
trackers, trackers,
trackers: { trackers: {
accuracyTracker: {accLeft, accRight, leftAverageCut, rightAverageCut, leftTimeDependence, rightTimeDependence, leftPreswing, leftPostswing, rightPreswing, rightPostswing}, accuracyTracker: {
accLeft,
accRight,
leftAverageCut,
rightAverageCut,
leftTimeDependence,
rightTimeDependence,
leftPreswing,
leftPostswing,
rightPreswing,
rightPostswing,
},
winTracker: { won, nbOfPause: pauses, rank }, winTracker: { won, nbOfPause: pauses, rank },
hitTracker: {bombHit, miss, missedNotes, badCuts, nbOfWallHit: wallHit, maxCombo}, hitTracker: {
bombHit,
miss,
missedNotes,
badCuts,
nbOfWallHit: wallHit,
maxCombo,
},
scoreTracker: { score }, scoreTracker: { score },
}, },
} = s; } = s;
if (![SONG_DATA_TYPES.Pass, SONG_DATA_TYPES.Fail, SONG_DATA_TYPES.Campaign].includes(type)) return null; if (
![
SONG_DATA_TYPES.Pass,
SONG_DATA_TYPES.Fail,
SONG_DATA_TYPES.Campaign,
].includes(type)
)
return null;
const leaderboardId = null; const leaderboardId = null;
hash = hash ? hash.toLowerCase() : null; hash = hash ? hash.toLowerCase() : null;
if (!playerId || !playerId.length || !hash || !hash.length || !diff || !diff.length || !score) return null; if (
!playerId ||
!playerId.length ||
!hash ||
!hash.length ||
!diff ||
!diff.length ||
!score
)
return null;
const song = {hash, name, subName: '', authorName, levelAuthorName}; const song = { hash, name, subName: "", authorName, levelAuthorName };
const leaderboard = { const leaderboard = {
leaderboardId, leaderboardId,
difficulty, difficulty,
diffInfo: {diff: diff === 'expertplus' ? 'expertPlus' : diff, type: 'Standard'}, diffInfo: {
diff: diff === "expertplus" ? "expertPlus" : diff,
type: "Standard",
},
song, song,
} };
const stats = { const stats = {
won, won,
@ -62,9 +99,17 @@ const process = response => {
bombHit, bombHit,
wallHit, wallHit,
maxCombo, maxCombo,
accLeft, accRight, leftAverageCut, rightAverageCut, leftTimeDependence, rightTimeDependence, accLeft,
leftPreswing, leftPostswing, rightPreswing, rightPostswing, accRight,
} leftAverageCut,
rightAverageCut,
leftTimeDependence,
rightTimeDependence,
leftPreswing,
leftPostswing,
rightPreswing,
rightPostswing,
};
return { return {
beatSaviorId, beatSaviorId,
@ -72,24 +117,27 @@ const process = response => {
leaderboardId, leaderboardId,
scoreId: null, scoreId: null,
hash, hash,
diff: diff === 'expertplus' ? 'expertPlus' : diff, diff: diff === "expertplus" ? "expertPlus" : diff,
score, score,
type, type,
leaderboard, leaderboard,
timeSet: dateFromString(timeSet), timeSet: dateFromString(timeSet),
stats, stats,
trackers, trackers,
} };
}) })
.filter(s => s); .filter((s) => s);
}; };
const get = async ({playerId, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.BEATSAVIOR.player(playerId, priority, queueOptions); const get = async ({
playerId,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.BEATSAVIOR.player(playerId, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);
export default { export default {
...client, ...client,
SONG_DATA_TYPES SONG_DATA_TYPES,
}; };

@ -1,19 +1,35 @@
import queue, {getResponseBody, isResponseCached, updateResponseBody} from '../queues/queues' import queue, {
getResponseBody,
isResponseCached,
updateResponseBody,
} from "../queues/queues";
export default (get, process) => { export default (get, process) => {
const clientGet = async ({priority = queue.PRIORITY.FG_LOW, fullResponse = false, ...getOptions} = {}) => { const clientGet = async ({
priority = queue.PRIORITY.FG_LOW,
fullResponse = false,
...getOptions
} = {}) => {
const response = await get({ ...getOptions, priority }); const response = await get({ ...getOptions, priority });
return fullResponse ? response : getResponseBody(response); return fullResponse ? response : getResponseBody(response);
} };
const clientGetProcessed = async ({priority = queue.PRIORITY.FG_LOW, fullResponse = false, ...getOptions} = {}) => { const clientGetProcessed = async ({
priority = queue.PRIORITY.FG_LOW,
fullResponse = false,
...getOptions
} = {}) => {
const response = await clientGet({ ...getOptions, priority, fullResponse }); const response = await clientGet({ ...getOptions, priority, fullResponse });
const processedResponse = process(fullResponse ? getResponseBody(response) : response); const processedResponse = process(
fullResponse ? getResponseBody(response) : response,
);
return fullResponse ? updateResponseBody(response, processedResponse) : processedResponse; return fullResponse
} ? updateResponseBody(response, processedResponse)
: processedResponse;
};
return { return {
get: clientGet, get: clientGet,
@ -21,5 +37,5 @@ export default (get, process) => {
getProcessed: clientGetProcessed, getProcessed: clientGetProcessed,
getDataFromResponse: getResponseBody, getDataFromResponse: getResponseBody,
isResponseCached, isResponseCached,
} };
} };

@ -1,34 +1,63 @@
import queue from '../../../queues/queues' import queue from "../../../queues/queues";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
import createClient from '../../generic' import createClient from "../../generic";
const process = response => { const process = (response) => {
if (!opt(response, 'scores') || !Array.isArray(response.scores)) return null; if (!opt(response, "scores") || !Array.isArray(response.scores)) return null;
const scores = response.scores.map(s => { const scores = response.scores.map((s) => {
let { unmodififiedScore: unmodifiedScore, mods, ...score } = s.score; let { unmodififiedScore: unmodifiedScore, mods, ...score } = s.score;
if (mods && typeof mods === 'string') mods = mods.split(',').map(m => m.trim().toUpperCase()).filter(m => m.length); if (mods && typeof mods === "string")
mods = mods
.split(",")
.map((m) => m.trim().toUpperCase())
.filter((m) => m.length);
else if (!mods) mods = null; else if (!mods) mods = null;
const acc = unmodifiedScore && opt(score, 'maxScore') ? unmodifiedScore / score.maxScore * 100 : opt(score, 'acc', null); const acc =
const percentage = opt(score, 'score') && opt(score, 'maxScore') ? score.score / score.maxScore * 100 : opt(score, 'percentage', null); unmodifiedScore && opt(score, "maxScore")
? (unmodifiedScore / score.maxScore) * 100
: opt(score, "acc", null);
const percentage =
opt(score, "score") && opt(score, "maxScore")
? (score.score / score.maxScore) * 100
: opt(score, "percentage", null);
const ppWeighted = opt(score, 'pp') && opt(score, 'weight') ? score.pp * score.weight : null; const ppWeighted =
opt(score, "pp") && opt(score, "weight") ? score.pp * score.weight : null;
return { return {
...s, ...s,
score: {...score, unmodifiedScore: unmodifiedScore || null, mods, acc, percentage, ppWeighted}, score: {
...score,
unmodifiedScore: unmodifiedScore || null,
mods,
acc,
percentage,
ppWeighted,
},
}; };
}); });
return { return {
...response, ...response,
scores scores,
} };
} };
const get = async ({leaderboardId, page = 1, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.SCORESABER_PAGE.leaderboard(leaderboardId, page, priority, queueOptions); const get = async ({
leaderboardId,
page = 1,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) =>
queue.SCORESABER_PAGE.leaderboard(
leaderboardId,
page,
priority,
queueOptions,
);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,34 +1,56 @@
import queue from '../../../queues/queues' import queue from "../../../queues/queues";
import createClient from '../../generic' import createClient from "../../generic";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
const process = response => { const process = (response) => {
if (!opt(response, 'playerInfo')) return null; if (!opt(response, "playerInfo")) return null;
const { playerInfo: info, scoreStats } = response; const { playerInfo: info, scoreStats } = response;
const {playerId, playerName: name, country, countryRank, avatar, permissions, ...playerInfo} = info; const {
playerId,
playerName: name,
country,
countryRank,
avatar,
permissions,
...playerInfo
} = info;
if (avatar) { if (avatar) {
if (!avatar.startsWith('http')) if (!avatar.startsWith("http"))
playerInfo.avatar = `${queue.SCORESABER_API.SS_API_HOST}${!avatar.startsWith('/') ? '/' : ''}${avatar}`; playerInfo.avatar = `${queue.SCORESABER_API.SS_API_HOST}${
else !avatar.startsWith("/") ? "/" : ""
playerInfo.avatar = avatar; }${avatar}`;
else playerInfo.avatar = avatar;
} }
playerInfo.banned = !!playerInfo.banned; playerInfo.banned = !!playerInfo.banned;
playerInfo.inactive = !!playerInfo.inactive; playerInfo.inactive = !!playerInfo.inactive;
playerInfo.rankHistory = playerInfo.history && playerInfo.history.length playerInfo.rankHistory =
? playerInfo.history.split(',').map(r => parseInt(r, 10)).filter(r => !isNaN(r)) playerInfo.history && playerInfo.history.length
? playerInfo.history
.split(",")
.map((r) => parseInt(r, 10))
.filter((r) => !isNaN(r))
: []; : [];
delete playerInfo.history; delete playerInfo.history;
playerInfo.externalProfileUrl = null; playerInfo.externalProfileUrl = null;
playerInfo.countries = [{ country, rank: countryRank }]; playerInfo.countries = [{ country, rank: countryRank }];
return {playerId, name, playerInfo, scoreStats: scoreStats ? scoreStats : null}; return {
playerId,
name,
playerInfo,
scoreStats: scoreStats ? scoreStats : null,
};
}; };
const get = async ({playerId, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.SCORESABER_API.player(playerId, priority, queueOptions); const get = async ({
playerId,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.SCORESABER_API.player(playerId, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,21 +1,27 @@
import queue from '../../../queues/queues' import queue from "../../../queues/queues";
import api from './api' import api from "./api";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
import createClient from '../../generic' import createClient from "../../generic";
const process = response => { const process = (response) => {
const apiProcessedResponse = api.process(response && response.player ? response.player : null); const apiProcessedResponse = api.process(
response && response.player ? response.player : null,
);
if (!opt(apiProcessedResponse, 'player.playerInfo')) return null; if (!opt(apiProcessedResponse, "player.playerInfo")) return null;
const recentPlay = opt(response, 'player.recentPlay'); const recentPlay = opt(response, "player.recentPlay");
const recentPlayLastUpdated = opt(response, 'player.recentPlayLastUpdated'); const recentPlayLastUpdated = opt(response, "player.recentPlayLastUpdated");
if (recentPlay && recentPlayLastUpdated) { if (recentPlay && recentPlayLastUpdated) {
apiProcessedResponse.playerInfo.recentPlay = recentPlay; apiProcessedResponse.playerInfo.recentPlay = recentPlay;
apiProcessedResponse.playerInfo.recentPlayLastUpdated = recentPlayLastUpdated; apiProcessedResponse.playerInfo.recentPlayLastUpdated =
recentPlayLastUpdated;
} }
const externalProfileUrl = opt(response, 'player.playerInfo.externalProfileUrl'); const externalProfileUrl = opt(
response,
"player.playerInfo.externalProfileUrl",
);
if (externalProfileUrl) { if (externalProfileUrl) {
apiProcessedResponse.playerInfo.externalProfileUrl = externalProfileUrl; apiProcessedResponse.playerInfo.externalProfileUrl = externalProfileUrl;
} }
@ -23,7 +29,11 @@ const process = response => {
return apiProcessedResponse; return apiProcessedResponse;
}; };
const get = async ({playerId, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.SCORESABER_PAGE.player(playerId, priority, queueOptions); const get = async ({
playerId,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.SCORESABER_PAGE.player(playerId, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,8 +1,12 @@
import queue from '../../../queues/queues' import queue from "../../../queues/queues";
import process from './utils/process' import process from "./utils/process";
import createClient from '../../generic' import createClient from "../../generic";
const get = async ({query, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.SCORESABER_API.findPlayer(query, priority, queueOptions); const get = async ({
query,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.SCORESABER_API.findPlayer(query, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,10 +1,13 @@
import queue from '../../../queues/queues' import queue from "../../../queues/queues";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
import createClient from '../../generic' import createClient from "../../generic";
const process = response => opt(response, 'pages', null) const process = (response) => opt(response, "pages", null);
const get = async ({priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.SCORESABER_API.rankingGlobalPages(priority, queueOptions); const get = async ({
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.SCORESABER_API.rankingGlobalPages(priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,8 +1,12 @@
import queue from '../../../queues/queues' import queue from "../../../queues/queues";
import process from './utils/process' import process from "./utils/process";
import createClient from '../../generic' import createClient from "../../generic";
const get = async ({page = 1, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.SCORESABER_API.rankingGlobal(page, priority, queueOptions); const get = async ({
page = 1,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.SCORESABER_API.rankingGlobal(page, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,17 +1,23 @@
import queue from '../../../queues/queues' import queue from "../../../queues/queues";
import api from './api-ranking-global' import api from "./api-ranking-global";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
import createClient from '../../generic' import createClient from "../../generic";
const process = response => { const process = (response) => {
const apiProcessedResponse = api.process(response); const apiProcessedResponse = api.process(response);
if (!opt(response, 'players')) return null; if (!opt(response, "players")) return null;
return apiProcessedResponse; return apiProcessedResponse;
} };
const get = async ({country, page = 1, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.SCORESABER_PAGE.countryRanking(country, page, priority, queueOptions); const get = async ({
country,
page = 1,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) =>
queue.SCORESABER_PAGE.countryRanking(country, page, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,17 +1,28 @@
import {opt} from '../../../../../utils/js' import { opt } from "../../../../../utils/js";
import queue from '../../../../queues/queues' import queue from "../../../../queues/queues";
export default response => { export default (response) => {
if (!opt(response, 'players')) return null; if (!opt(response, "players")) return null;
if (!Array.isArray(response.players)) return null; if (!Array.isArray(response.players)) return null;
return response.players.map(player => { return response.players.map((player) => {
let {avatar, country, difference, history, playerId, playerName: name, pp, rank} = player; let {
avatar,
country,
difference,
history,
playerId,
playerName: name,
pp,
rank,
} = player;
if (avatar) { if (avatar) {
if (!avatar.startsWith('http')) if (!avatar.startsWith("http"))
avatar = `${queue.SCORESABER_API.SS_API_HOST}${!avatar.startsWith('/') ? '/' : ''}${avatar}`; avatar = `${queue.SCORESABER_API.SS_API_HOST}${
!avatar.startsWith("/") ? "/" : ""
}${avatar}`;
} }
return { return {
@ -22,13 +33,17 @@ export default response => {
countries: [{ country, rank: null }], countries: [{ country, rank: null }],
pp, pp,
rank, rank,
rankHistory: history && history.length rankHistory:
? history.split(',').map(r => parseInt(r, 10)).filter(r => !isNaN(r)) history && history.length
? history
.split(",")
.map((r) => parseInt(r, 10))
.filter((r) => !isNaN(r))
: [], : [],
}, },
others: { others: {
difference, difference,
}, },
} };
}); });
}; };

@ -1,9 +1,13 @@
import createClient from '../../generic' import createClient from "../../generic";
import queues from '../../../queues/queues' import queues from "../../../queues/queues";
const process = response => response; const process = (response) => response;
const get = async ({page = 1, priority = queues.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queues.SCORESABER_PAGE.rankeds(page, priority, queueOptions) const get = async ({
page = 1,
priority = queues.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queues.SCORESABER_PAGE.rankeds(page, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,8 +1,14 @@
import queue from '../../../queues/queues' import queue from "../../../queues/queues";
import process from './utils/process'; import process from "./utils/process";
import createClient from '../../generic' import createClient from "../../generic";
const get = async ({playerId, page = 1, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.SCORESABER_API.recentScores(playerId, page, priority, queueOptions); const get = async ({
playerId,
page = 1,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) =>
queue.SCORESABER_API.recentScores(playerId, page, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,8 +1,14 @@
import queue from '../../../queues/queues' import queue from "../../../queues/queues";
import createClient from '../../generic' import createClient from "../../generic";
import process from './utils/process' import process from "./utils/process";
const get = async ({playerId, page = 1, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.SCORESABER_API.topScores(playerId, page, priority, queueOptions); const get = async ({
playerId,
page = 1,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) =>
queue.SCORESABER_API.topScores(playerId, page, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);

@ -1,11 +1,16 @@
import {dateFromString} from '../../../../../utils/date' import { dateFromString } from "../../../../../utils/date";
import {extractDiffAndType} from '../../../../../utils/scoresaber/format' import { extractDiffAndType } from "../../../../../utils/scoresaber/format";
import {opt} from '../../../../../utils/js' import { opt } from "../../../../../utils/js";
export default response => { export default (response) => {
if (!opt(response, 'scores') || !Array.isArray(response.scores) || !opt(response, 'scores.0.scoreId')) return []; if (
!opt(response, "scores") ||
!Array.isArray(response.scores) ||
!opt(response, "scores.0.scoreId")
)
return [];
return response.scores.map(s => { return response.scores.map((s) => {
const { const {
songHash: hash, songHash: hash,
songName: name, songName: name,
@ -24,19 +29,38 @@ export default response => {
let { unmodififiedScore: unmodifiedScore, mods, ...score } = originalScore; let { unmodififiedScore: unmodifiedScore, mods, ...score } = originalScore;
if (mods && typeof mods === 'string') mods = mods.split(',').map(m => m.trim().toUpperCase()).filter(m => m.length); if (mods && typeof mods === "string")
mods = mods
.split(",")
.map((m) => m.trim().toUpperCase())
.filter((m) => m.length);
else if (!mods) mods = null; else if (!mods) mods = null;
const acc = unmodifiedScore && opt(score, 'maxScore') ? unmodifiedScore / score.maxScore * 100 : null; const acc =
const percentage = opt(score, 'score') && opt(score, 'maxScore') ? score.score / score.maxScore * 100 : null; unmodifiedScore && opt(score, "maxScore")
? (unmodifiedScore / score.maxScore) * 100
: null;
const percentage =
opt(score, "score") && opt(score, "maxScore")
? (score.score / score.maxScore) * 100
: null;
const ppWeighted = opt(score, 'pp') && opt(score, 'weight') ? score.pp * score.weight : null; const ppWeighted =
opt(score, "pp") && opt(score, "weight") ? score.pp * score.weight : null;
return { return {
leaderboard, leaderboard,
score: {...score, unmodifiedScore, mods, timeSet: dateFromString(score.timeSet), acc, percentage, ppWeighted}, score: {
...score,
unmodifiedScore,
mods,
timeSet: dateFromString(score.timeSet),
acc,
percentage,
ppWeighted,
},
fetchedAt: new Date(), fetchedAt: new Date(),
lastUpdated: new Date(), lastUpdated: new Date(),
}; };
}); });
} };

@ -1,17 +1,22 @@
import queue from '../../queues/queues' import queue from "../../queues/queues";
import createClient from '../generic' import createClient from "../generic";
import {opt} from '../../../utils/js' import { opt } from "../../../utils/js";
const process = response => { const process = (response) => {
if (!opt(response, 'data.0')) return null; if (!opt(response, "data.0")) return null;
return { ...response.data[0], profileLastUpdated: new Date() }; return { ...response.data[0], profileLastUpdated: new Date() };
}; };
const get = async ({accessToken, login, priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.TWITCH.profile(accessToken, login, priority, queueOptions); const get = async ({
accessToken,
login,
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.TWITCH.profile(accessToken, login, priority, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);
export default { export default {
...client ...client,
} };

@ -1,16 +1,22 @@
import queue from '../../queues/queues' import queue from "../../queues/queues";
import createClient from '../generic' import createClient from "../generic";
const process = response => { const process = (response) => {
if (!response || !response.data || !Array.isArray(response.data)) return null; if (!response || !response.data || !Array.isArray(response.data)) return null;
return response.data; return response.data;
}; };
const get = async ({accessToken, userId, type = 'archive', priority = queue.PRIORITY.FG_HIGH, ...queueOptions} = {}) => queue.TWITCH.videos(accessToken, userId, type, queueOptions); const get = async ({
accessToken,
userId,
type = "archive",
priority = queue.PRIORITY.FG_HIGH,
...queueOptions
} = {}) => queue.TWITCH.videos(accessToken, userId, type, queueOptions);
const client = createClient(get, process); const client = createClient(get, process);
export default { export default {
...client, ...client,
} };

@ -1,45 +1,48 @@
import eventBus from '../utils/broadcast-channel-pubsub' import eventBus from "../utils/broadcast-channel-pubsub";
import createPlayerService from '../services/scoresaber/player' import createPlayerService from "../services/scoresaber/player";
import log from '../utils/logger' import log from "../utils/logger";
let initialized = false; let initialized = false;
export default (dlManager) => { export default (dlManager) => {
if (initialized) { if (initialized) {
log.debug(`Command processor already initialized.`, 'CmdProcessor'); log.debug(`Command processor already initialized.`, "CmdProcessor");
return; return;
} }
const playerService = createPlayerService(); const playerService = createPlayerService();
eventBus.on('data-imported', () => { eventBus.on("data-imported", () => {
if (window) window.location.reload() if (window) window.location.reload();
}); });
eventBus.on('player-add-cmd', async ({playerId}) => { eventBus.on("player-add-cmd", async ({ playerId }) => {
await dlManager.enqueuePlayer(playerId); await dlManager.enqueuePlayer(playerId);
}); });
eventBus.on('player-remove-cmd', async ({playerId, purgeScores = false}) => { eventBus.on(
"player-remove-cmd",
async ({ playerId, purgeScores = false }) => {
if (!playerId) return; if (!playerId) return;
await playerService.remove(playerId, purgeScores); await playerService.remove(playerId, purgeScores);
}); },
);
eventBus.on('dl-manager-pause-cmd', () => { eventBus.on("dl-manager-pause-cmd", () => {
log.debug('Pause Dl Manager', 'CmdProcessor'); log.debug("Pause Dl Manager", "CmdProcessor");
dlManager.pause(); dlManager.pause();
}); });
eventBus.on('dl-manager-unpause-cmd', () => { eventBus.on("dl-manager-unpause-cmd", () => {
log.debug('Unpause Dl Manager', 'CmdProcessor'); log.debug("Unpause Dl Manager", "CmdProcessor");
dlManager.start(); dlManager.start();
}); });
initialized = true; initialized = true;
log.info(`Command processor initialized`, 'CmdProcessor'); log.info(`Command processor initialized`, "CmdProcessor");
} };

@ -1,15 +1,15 @@
import eventBus from '../utils/broadcast-channel-pubsub' import eventBus from "../utils/broadcast-channel-pubsub";
import log from '../utils/logger' import log from "../utils/logger";
import createQueue, {PRIORITY} from '../utils/queue' import createQueue, { PRIORITY } from "../utils/queue";
import {configStore} from '../stores/config' import { configStore } from "../stores/config";
import createRankedsStore from '../stores/scoresaber/rankeds' import createRankedsStore from "../stores/scoresaber/rankeds";
import createPlayerService from '../services/scoresaber/player' import createPlayerService from "../services/scoresaber/player";
import createScoresService from '../services/scoresaber/scores' import createScoresService from "../services/scoresaber/scores";
import createBeatSaviorService from '../services/beatsavior' import createBeatSaviorService from "../services/beatsavior";
import createAccSaberService from '../services/accsaber' import createAccSaberService from "../services/accsaber";
import {PRIORITY as HTTP_QUEUE_PRIORITY} from './queues/http-queue' import { PRIORITY as HTTP_QUEUE_PRIORITY } from "./queues/http-queue";
import {HOUR, MINUTE} from '../utils/date' import { HOUR, MINUTE } from "../utils/date";
import {opt} from '../utils/js' import { opt } from "../utils/js";
const INTERVAL_TICK = MINUTE; const INTERVAL_TICK = MINUTE;
@ -22,110 +22,198 @@ let beatSaviorService = null;
let accSaberService = null; let accSaberService = null;
const TYPES = { const TYPES = {
BEATSAVIOR: {name: 'BEATSAVIOR', priority: PRIORITY.LOW}, BEATSAVIOR: { name: "BEATSAVIOR", priority: PRIORITY.LOW },
RANKEDS: {name: 'RANKEDS', priority: PRIORITY.LOW}, RANKEDS: { name: "RANKEDS", priority: PRIORITY.LOW },
ACCSABER: {name: 'ACCSABER', priority: PRIORITY.NORMAL}, ACCSABER: { name: "ACCSABER", priority: PRIORITY.NORMAL },
PLAYER_SCORES: {name: 'PLAYER-SCORES', priority: PRIORITY.NORMAL}, PLAYER_SCORES: { name: "PLAYER-SCORES", priority: PRIORITY.NORMAL },
PLAYER_SCORES_UPDATE_QUEUE: {name: 'PLAYER_SCORES_UPDATE_QUEUE', priority: PRIORITY.LOWEST}, PLAYER_SCORES_UPDATE_QUEUE: {
ACTIVE_PLAYERS: {name: 'ACTIVE-PLAYERS', priority: PRIORITY.HIGH}, name: "PLAYER_SCORES_UPDATE_QUEUE",
MAIN_PLAYER: {name: 'MAIN-PLAYER', priority: PRIORITY.HIGHEST}, priority: PRIORITY.LOWEST,
} },
ACTIVE_PLAYERS: { name: "ACTIVE-PLAYERS", priority: PRIORITY.HIGH },
MAIN_PLAYER: { name: "MAIN-PLAYER", priority: PRIORITY.HIGHEST },
};
const enqueue = async (queue, type, force = false, data = null, then = null) => { const enqueue = async (
queue,
type,
force = false,
data = null,
then = null,
) => {
if (!type || !type.name || !Number.isFinite(type.priority)) { if (!type || !type.name || !Number.isFinite(type.priority)) {
log.warn(`Unknown type enqueued.`, 'DlManager', type); log.warn(`Unknown type enqueued.`, "DlManager", type);
return; return;
} }
log.debug(`Try to enqueue type ${type.name}. Forced: ${force}, data: ${JSON.stringify(data)}`, 'DlManager'); log.debug(
`Try to enqueue type ${type.name}. Forced: ${force}, data: ${JSON.stringify(
data,
)}`,
"DlManager",
);
const priority = force ? PRIORITY.HIGHEST : type.priority; const priority = force ? PRIORITY.HIGHEST : type.priority;
const networkPriority = priority === PRIORITY.HIGHEST ? HTTP_QUEUE_PRIORITY.BG_HIGH : HTTP_QUEUE_PRIORITY.BG_NORMAL; const networkPriority =
priority === PRIORITY.HIGHEST
? HTTP_QUEUE_PRIORITY.BG_HIGH
: HTTP_QUEUE_PRIORITY.BG_NORMAL;
const processThen = async (promise, then = null) => { const processThen = async (promise, then = null) => {
promise.then(result => { promise.then((result) => {
if(then) log.debug('Processing then command...', 'DlManager'); if (then) log.debug("Processing then command...", "DlManager");
return then ? { result, thenResult: then() } : result; return then ? { result, thenResult: then() } : result;
}) });
} };
switch (type) { switch (type) {
case TYPES.MAIN_PLAYER: case TYPES.MAIN_PLAYER:
if (mainPlayerId) { if (mainPlayerId) {
log.debug(`Enqueue main player`, 'DlManager'); log.debug(`Enqueue main player`, "DlManager");
await Promise.all([ await Promise.all([
enqueue(queue, {...TYPES.ACTIVE_PLAYERS, priority: PRIORITY.HIGHEST}, force, {playerId: mainPlayerId}), enqueue(
enqueue(queue, {...TYPES.PLAYER_SCORES, priority: PRIORITY.HIGHEST}, force, {playerId: mainPlayerId}), queue,
{ ...TYPES.ACTIVE_PLAYERS, priority: PRIORITY.HIGHEST },
force,
{ playerId: mainPlayerId },
),
enqueue(
queue,
{ ...TYPES.PLAYER_SCORES, priority: PRIORITY.HIGHEST },
force,
{ playerId: mainPlayerId },
),
]); ]);
} }
break; break;
case TYPES.RANKEDS: case TYPES.RANKEDS:
log.debug(`Enqueue rankeds`, 'DlManager'); log.debug(`Enqueue rankeds`, "DlManager");
if (!rankedsStore) rankedsStore = await createRankedsStore(); if (!rankedsStore) rankedsStore = await createRankedsStore();
processThen(queue.add(async () => rankedsStore.refresh(force, networkPriority), priority), then) processThen(
.then(_ => log.debug('Enqueued rankeds processed.', 'DlManager')); queue.add(
async () => rankedsStore.refresh(force, networkPriority),
priority,
),
then,
).then((_) => log.debug("Enqueued rankeds processed.", "DlManager"));
break; break;
case TYPES.ACTIVE_PLAYERS: case TYPES.ACTIVE_PLAYERS:
log.debug(`Enqueue active players`, 'DlManager'); log.debug(`Enqueue active players`, "DlManager");
if (data && data.playerId) { if (data && data.playerId) {
if (data.add) if (data.add)
processThen(queue.add(async () => playerService.add(data.playerId, networkPriority), priority), then) processThen(
.then(_ => log.debug('Enqueued active players processed.', 'DlManager')); queue.add(
async () => playerService.add(data.playerId, networkPriority),
priority,
),
then,
).then((_) =>
log.debug("Enqueued active players processed.", "DlManager"),
);
else else
processThen(queue.add(async () => playerService.refresh(data.playerId, force, networkPriority), priority), then) processThen(
.then(_ => log.debug('Enqueued active players processed.', 'DlManager')); queue.add(
async () =>
playerService.refresh(data.playerId, force, networkPriority),
priority,
),
then,
).then((_) =>
log.debug("Enqueued active players processed.", "DlManager"),
);
} else } else
processThen(queue.add(async () => playerService.refreshAll(force, networkPriority), priority), then) processThen(
.then(_ => log.debug('Enqueued active players processed.', 'DlManager')); queue.add(
async () => playerService.refreshAll(force, networkPriority),
priority,
),
then,
).then((_) =>
log.debug("Enqueued active players processed.", "DlManager"),
);
break; break;
case TYPES.PLAYER_SCORES: case TYPES.PLAYER_SCORES:
log.debug(`Enqueue players scores`, 'DlManager'); log.debug(`Enqueue players scores`, "DlManager");
if (data && data.playerId) if (data && data.playerId)
processThen(queue.add(async () => scoresService.refresh(data.playerId, force, networkPriority), priority), then) processThen(
.then(_ => log.debug('Enqueued players scores processed.', 'DlManager')); queue.add(
async () =>
scoresService.refresh(data.playerId, force, networkPriority),
priority,
),
then,
).then((_) =>
log.debug("Enqueued players scores processed.", "DlManager"),
);
else else
processThen(queue.add(async () => scoresService.refreshAll(force, networkPriority), priority), then) processThen(
.then(_ => log.debug('Enqueued players scores processed.', 'DlManager')); queue.add(
async () => scoresService.refreshAll(force, networkPriority),
priority,
),
then,
).then((_) =>
log.debug("Enqueued players scores processed.", "DlManager"),
);
break; break;
case TYPES.BEATSAVIOR: case TYPES.BEATSAVIOR:
log.debug(`Enqueue Beat Savior`, 'DlManager'); log.debug(`Enqueue Beat Savior`, "DlManager");
processThen(queue.add(async () => beatSaviorService.refreshAll(force, networkPriority), priority), then) processThen(
.then(_ => log.debug('Enqueued Beat Savior processed.', 'DlManager')); queue.add(
async () => beatSaviorService.refreshAll(force, networkPriority),
priority,
),
then,
).then((_) => log.debug("Enqueued Beat Savior processed.", "DlManager"));
break; break;
case TYPES.PLAYER_SCORES_UPDATE_QUEUE: case TYPES.PLAYER_SCORES_UPDATE_QUEUE:
log.debug(`Enqueue player scores rank && pp updates`, 'DlManager'); log.debug(`Enqueue player scores rank && pp updates`, "DlManager");
processThen(queue.add(async () => scoresService.updateRankAndPpFromTheQueue(), priority), then) processThen(
.then(_ => log.debug('Enqueued player scores rank & pp updates processed.', 'DlManager')); queue.add(
async () => scoresService.updateRankAndPpFromTheQueue(),
priority,
),
then,
).then((_) =>
log.debug(
"Enqueued player scores rank & pp updates processed.",
"DlManager",
),
);
break; break;
case TYPES.ACCSABER: case TYPES.ACCSABER:
log.debug(`Enqueue AccSaber updates`, 'DlManager'); log.debug(`Enqueue AccSaber updates`, "DlManager");
processThen(queue.add(async () => accSaberService.refreshAll(), priority), then) processThen(
.then(_ => log.debug('Enqueued AccSaber updates processed.', 'DlManager')); queue.add(async () => accSaberService.refreshAll(), priority),
then,
).then((_) =>
log.debug("Enqueued AccSaber updates processed.", "DlManager"),
);
break; break;
} }
} };
const enqueueAllJobs = async queue => { const enqueueAllJobs = async (queue) => {
log.debug(`Try to enqueue & process queue.`, 'DlManager'); log.debug(`Try to enqueue & process queue.`, "DlManager");
await Promise.all([ await Promise.all([
enqueue(queue, TYPES.MAIN_PLAYER), enqueue(queue, TYPES.MAIN_PLAYER),
@ -137,18 +225,18 @@ const enqueueAllJobs = async queue => {
// it should be at the end of the queue // it should be at the end of the queue
enqueue(queue, TYPES.PLAYER_SCORES_UPDATE_QUEUE), enqueue(queue, TYPES.PLAYER_SCORES_UPDATE_QUEUE),
]) ]);
} };
let intervalId; let intervalId;
const startSyncing = async queue => { const startSyncing = async (queue) => {
await enqueueAllJobs(queue); await enqueueAllJobs(queue);
intervalId = setInterval(() => enqueueAllJobs(queue), INTERVAL_TICK); intervalId = setInterval(() => enqueueAllJobs(queue), INTERVAL_TICK);
} };
export default async () => { export default async () => {
if (initialized) { if (initialized) {
log.debug(`Download manager already initialized.`, 'DlManager'); log.debug(`Download manager already initialized.`, "DlManager");
return; return;
} }
@ -161,49 +249,54 @@ export default async () => {
mainPlayerId = configStore.getMainPlayerId(); mainPlayerId = configStore.getMainPlayerId();
configStore.subscribe(config => { configStore.subscribe((config) => {
const newMainPlayerId = opt(config, 'users.main') const newMainPlayerId = opt(config, "users.main");
if (mainPlayerId !== newMainPlayerId) { if (mainPlayerId !== newMainPlayerId) {
mainPlayerId = newMainPlayerId; mainPlayerId = newMainPlayerId;
log.debug(`Main player changed to ${mainPlayerId}`, 'DlManager') log.debug(`Main player changed to ${mainPlayerId}`, "DlManager");
} }
}) });
playerService = createPlayerService(); playerService = createPlayerService();
scoresService = createScoresService(); scoresService = createScoresService();
beatSaviorService = createBeatSaviorService(); beatSaviorService = createBeatSaviorService();
accSaberService = createAccSaberService(); accSaberService = createAccSaberService();
eventBus.leaderStore.subscribe(async isLeader => { eventBus.leaderStore.subscribe(async (isLeader) => {
if (isLeader) { if (isLeader) {
queue.clear(); queue.clear();
queue.start(); queue.start();
const nodeId = eventBus.getNodeId(); const nodeId = eventBus.getNodeId();
log.info(`Node ${nodeId} is a leader, queue processing enabled`, 'DlManager') log.info(
`Node ${nodeId} is a leader, queue processing enabled`,
"DlManager",
);
await startSyncing(queue) await startSyncing(queue);
} }
}) });
const enqueuePlayer = async playerId => { const enqueuePlayer = async (playerId) => {
await enqueue( await enqueue(
queue, TYPES.ACTIVE_PLAYERS, true, queue,
TYPES.ACTIVE_PLAYERS,
true,
{ playerId, add: true }, { playerId, add: true },
async () => enqueue(queue, TYPES.PLAYER_SCORES, true, { playerId }), async () => enqueue(queue, TYPES.PLAYER_SCORES, true, { playerId }),
); );
} };
const pause = () => { const pause = () => {
log.debug('Pause Dl Manager', 'DlManager'); log.debug("Pause Dl Manager", "DlManager");
queue.clear(); queue.clear();
queue.pause(); queue.pause();
}; };
const start = () => { const start = () => {
log.debug('Unpause Dl Manager', 'DlManager'); log.debug("Unpause Dl Manager", "DlManager");
queue.clear(); queue.clear();
queue.start(); queue.start();
@ -213,11 +306,11 @@ export default async () => {
initialized = true; initialized = true;
log.info(`Download manager initialized`, 'DlManager'); log.info(`Download manager initialized`, "DlManager");
return { return {
start, start,
pause, pause,
enqueuePlayer enqueuePlayer,
} };
} };

@ -1,6 +1,6 @@
import {SsrError} from '../others/errors' import { SsrError } from "../others/errors";
import {delay} from '../utils/promise' import { delay } from "../utils/promise";
import {parseRateLimitHeaders} from './utils' import { parseRateLimitHeaders } from "./utils";
export class SsrNetworkError extends SsrError { export class SsrNetworkError extends SsrError {
constructor(message) { constructor(message) {
@ -20,7 +20,7 @@ export class SsrNetworkError extends SsrError {
export class SsrNetworkTimeoutError extends SsrNetworkError { export class SsrNetworkTimeoutError extends SsrNetworkError {
constructor(timeout, message) { constructor(timeout, message) {
super(message && message.length ? message : `Timeout Error (${timeout}ms)`) super(message && message.length ? message : `Timeout Error (${timeout}ms)`);
this.name = "SsrNetworkTimeoutError"; this.name = "SsrNetworkTimeoutError";
this.timeout = timeout; this.timeout = timeout;
@ -29,9 +29,14 @@ export class SsrNetworkTimeoutError extends SsrNetworkError {
export class SsrHttpResponseError extends SsrNetworkError { export class SsrHttpResponseError extends SsrNetworkError {
constructor(response, ...args) { constructor(response, ...args) {
super(`HTTP Error Response: ${response && response.status ? response.status : 'None'} ${response && response.statusText ? response.statusText : ''}`, ...args); super(
`HTTP Error Response: ${
response && response.status ? response.status : "None"
} ${response && response.statusText ? response.statusText : ""}`,
...args,
);
this.name = 'SsrHttpResponseError'; this.name = "SsrHttpResponseError";
this.response = response; this.response = response;
const { remaining, limit, resetAt } = parseRateLimitHeaders(response); const { remaining, limit, resetAt } = parseRateLimitHeaders(response);
@ -50,7 +55,7 @@ export class SsrHttpClientError extends SsrHttpResponseError {
constructor(...args) { constructor(...args) {
super(...args); super(...args);
this.name = 'SsrHttpClientError'; this.name = "SsrHttpClientError";
} }
shouldRetry() { shouldRetry() {
@ -66,7 +71,7 @@ export class SsrHttpRateLimitError extends SsrHttpClientError {
constructor(response, ...args) { constructor(response, ...args) {
super(response, ...args); super(response, ...args);
this.name = 'SsrHttpRateLimitError'; this.name = "SsrHttpRateLimitError";
} }
shouldRetry() { shouldRetry() {
@ -119,6 +124,6 @@ export class SsrHttpServerError extends SsrHttpResponseError {
constructor(...args) { constructor(...args) {
super(...args); super(...args);
this.name = 'SsrHttpServerError'; this.name = "SsrHttpServerError";
} }
} }

@ -97,12 +97,12 @@ export async function fetchUrl(url, options = {}, cors = true) {
export async function fetchJson( export async function fetchJson(
url, url,
{ cacheTtl = null, maxAge = null, ...restOptions } = {} { cacheTtl = null, maxAge = null, ...restOptions } = {},
) { ) {
const options = getOptionsWithCacheKey( const options = getOptionsWithCacheKey(
url, url,
{ cacheTtl, maxAge, ...restOptions }, { cacheTtl, maxAge, ...restOptions },
"json" "json",
); );
const { const {
@ -129,7 +129,7 @@ export async function fetchJson(
body, body,
}, },
fetchCacheKey, fetchCacheKey,
fetchCacheTtl fetchCacheTtl,
); );
}) })
.catch((err) => { .catch((err) => {
@ -141,12 +141,12 @@ export async function fetchJson(
export async function fetchHtml( export async function fetchHtml(
url, url,
{ cacheTtl = null, maxAge = null, ...restOptions } = {} { cacheTtl = null, maxAge = null, ...restOptions } = {},
) { ) {
const options = getOptionsWithCacheKey( const options = getOptionsWithCacheKey(
url, url,
{ cacheTtl, maxAge, ...restOptions }, { cacheTtl, maxAge, ...restOptions },
"json" "json",
); );
const { const {
@ -172,7 +172,7 @@ export async function fetchHtml(
body: new DOMParser().parseFromString(body, "text/html"), body: new DOMParser().parseFromString(body, "text/html"),
}, },
fetchCacheKey, fetchCacheKey,
fetchCacheTtl fetchCacheTtl,
); );
}); });
} }

@ -1,25 +1,75 @@
import {default as createQueue, PRIORITY} from '../http-queue'; import { default as createQueue, PRIORITY } from "../http-queue";
import { substituteVars } from "../../../utils/format"; import { substituteVars } from "../../../utils/format";
const ACCSABER_API_URL = 'https://api.accsaber.com'; const ACCSABER_API_URL = "https://api.accsaber.com";
const CATEGORIES_URL = ACCSABER_API_URL + '/categories'; const CATEGORIES_URL = ACCSABER_API_URL + "/categories";
const RANKING_URL = ACCSABER_API_URL + '/categories/${category}/standings'; const RANKING_URL = ACCSABER_API_URL + "/categories/${category}/standings";
const PLAYER_SCORES_URL = ACCSABER_API_URL + '/players/${playerId}/scores'; const PLAYER_SCORES_URL = ACCSABER_API_URL + "/players/${playerId}/scores";
const PLAYER_RANK_HISTORY = ACCSABER_API_URL + '/players/${playerId}/recent-rank-history' const PLAYER_RANK_HISTORY =
const LEADERBOARD_URL = ACCSABER_API_URL + '/map-leaderboards/${leaderboardId}'; ACCSABER_API_URL + "/players/${playerId}/recent-rank-history";
const LEADERBOARD_INFO_URL = ACCSABER_API_URL + '/ranked-maps/${leaderboardId}'; const LEADERBOARD_URL = ACCSABER_API_URL + "/map-leaderboards/${leaderboardId}";
const LEADERBOARD_INFO_URL = ACCSABER_API_URL + "/ranked-maps/${leaderboardId}";
export default (options = {}) => { export default (options = {}) => {
const queue = createQueue(options); const queue = createQueue(options);
const { fetchJson, fetchHtml, ...queueToReturn } = queue; const { fetchJson, fetchHtml, ...queueToReturn } = queue;
const categories = async (priority = PRIORITY.FG_LOW, options = {}) => fetchJson(CATEGORIES_URL, options, priority) const categories = async (priority = PRIORITY.FG_LOW, options = {}) =>
const ranking = async (category = 'overall', page = 1, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(RANKING_URL, {category, page}), options, priority) fetchJson(CATEGORIES_URL, options, priority);
const scores = async (playerId, page = 1, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(PLAYER_SCORES_URL, {playerId, page}), options, priority) const ranking = async (
const playerRankHistory = async (playerId, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(PLAYER_RANK_HISTORY, {playerId}), options, priority) category = "overall",
const leaderboard = async (leaderboardId, page = 1, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(LEADERBOARD_URL, {leaderboardId, page}), options, priority) page = 1,
const leaderboardInfo = async (leaderboardId, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(LEADERBOARD_INFO_URL, {leaderboardId}), options, priority) priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchJson(
substituteVars(RANKING_URL, { category, page }),
options,
priority,
);
const scores = async (
playerId,
page = 1,
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchJson(
substituteVars(PLAYER_SCORES_URL, { playerId, page }),
options,
priority,
);
const playerRankHistory = async (
playerId,
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchJson(
substituteVars(PLAYER_RANK_HISTORY, { playerId }),
options,
priority,
);
const leaderboard = async (
leaderboardId,
page = 1,
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchJson(
substituteVars(LEADERBOARD_URL, { leaderboardId, page }),
options,
priority,
);
const leaderboardInfo = async (
leaderboardId,
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchJson(
substituteVars(LEADERBOARD_INFO_URL, { leaderboardId }),
options,
priority,
);
return { return {
categories, categories,
@ -29,5 +79,5 @@ export default (options = {}) => {
leaderboard, leaderboard,
leaderboardInfo, leaderboardInfo,
...queueToReturn, ...queueToReturn,
} };
} };

@ -1,21 +1,23 @@
import {default as createQueue, PRIORITY} from '../http-queue'; import { default as createQueue, PRIORITY } from "../http-queue";
import { substituteVars } from "../../../utils/format"; import { substituteVars } from "../../../utils/format";
const BEATMAPS_API_URL = 'https://api.beatsaver.com/'; const BEATMAPS_API_URL = "https://api.beatsaver.com/";
const SONG_BY_HASH_URL = BEATMAPS_API_URL + '/maps/hash/${hash}'; const SONG_BY_HASH_URL = BEATMAPS_API_URL + "/maps/hash/${hash}";
const SONG_BY_KEY_URL = BEATMAPS_API_URL + '/maps/id/${key}' const SONG_BY_KEY_URL = BEATMAPS_API_URL + "/maps/id/${key}";
export default (options = {}) => { export default (options = {}) => {
const queue = createQueue(options); const queue = createQueue(options);
const { fetchJson, fetchHtml, ...queueToReturn } = queue; const { fetchJson, fetchHtml, ...queueToReturn } = queue;
const byHash = async (hash, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(SONG_BY_HASH_URL, {hash}), options, priority) const byHash = async (hash, priority = PRIORITY.FG_LOW, options = {}) =>
const byKey = async (key, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(SONG_BY_KEY_URL, {key}), options, priority) fetchJson(substituteVars(SONG_BY_HASH_URL, { hash }), options, priority);
const byKey = async (key, priority = PRIORITY.FG_LOW, options = {}) =>
fetchJson(substituteVars(SONG_BY_KEY_URL, { key }), options, priority);
return { return {
byHash, byHash,
byKey, byKey,
...queueToReturn, ...queueToReturn,
} };
} };

@ -1,9 +1,17 @@
import {default as createQueue, PRIORITY as QUEUE_PRIORITY} from '../../utils/queue'; import {
import {SsrError, SsrTimeoutError} from '../../others/errors' default as createQueue,
import {SsrHttpRateLimitError, SsrHttpResponseError, SsrNetworkError, SsrNetworkTimeoutError} from '../errors' PRIORITY as QUEUE_PRIORITY,
import {fetchHtml, fetchJson} from '../fetch'; } from "../../utils/queue";
import makePendingPromisePool from '../../utils/pending-promises' import { SsrError, SsrTimeoutError } from "../../others/errors";
import {AbortError} from '../../utils/promise' import {
SsrHttpRateLimitError,
SsrHttpResponseError,
SsrNetworkError,
SsrNetworkTimeoutError,
} from "../errors";
import { fetchHtml, fetchJson } from "../fetch";
import makePendingPromisePool from "../../utils/pending-promises";
import { AbortError } from "../../utils/promise";
const DEFAULT_RETRIES = 2; const DEFAULT_RETRIES = 2;
@ -13,24 +21,41 @@ export const PRIORITY = {
BG_HIGH: QUEUE_PRIORITY.NORMAL, BG_HIGH: QUEUE_PRIORITY.NORMAL,
BG_NORMAL: QUEUE_PRIORITY.LOW, BG_NORMAL: QUEUE_PRIORITY.LOW,
BG_LOW: QUEUE_PRIORITY.LOWEST, BG_LOW: QUEUE_PRIORITY.LOWEST,
} };
const resolvePromiseOrWaitForPending = makePendingPromisePool(); const resolvePromiseOrWaitForPending = makePendingPromisePool();
export default (options = {}) => { export default (options = {}) => {
const {retries, rateLimitTick, ...queueOptions} = {retries: DEFAULT_RETRIES, rateLimitTick: 500, ...options}; const { retries, rateLimitTick, ...queueOptions } = {
retries: DEFAULT_RETRIES,
rateLimitTick: 500,
...options,
};
const queue = createQueue(queueOptions); const queue = createQueue(queueOptions);
const { add, emitter, ...queueToReturn } = queue; const { add, emitter, ...queueToReturn } = queue;
let lastRateLimitError = null; let lastRateLimitError = null;
let rateLimitTimerId = null; let rateLimitTimerId = null;
let currentRateLimit = {waiting: 0, remaining: null, limit: null, resetAt: null}; let currentRateLimit = {
waiting: 0,
remaining: null,
limit: null,
resetAt: null,
};
const rateLimitTicker = () => { const rateLimitTicker = () => {
const expiresInMs = lastRateLimitError && lastRateLimitError.resetAt ? lastRateLimitError.resetAt - new Date() + 1000 : 0; const expiresInMs =
lastRateLimitError && lastRateLimitError.resetAt
? lastRateLimitError.resetAt - new Date() + 1000
: 0;
if (expiresInMs <= 0) { if (expiresInMs <= 0) {
emitter.emit('waiting', {waiting: 0, remaining: null, limit: null, resetAt: null}); emitter.emit("waiting", {
waiting: 0,
remaining: null,
limit: null,
resetAt: null,
});
if (rateLimitTimerId) clearTimeout(rateLimitTimerId); if (rateLimitTimerId) clearTimeout(rateLimitTimerId);
@ -38,13 +63,23 @@ export default (options = {}) => {
} }
const { remaining, limit, resetAt } = lastRateLimitError; const { remaining, limit, resetAt } = lastRateLimitError;
emitter.emit('waiting', {waiting: expiresInMs, remaining, limit, resetAt}); emitter.emit("waiting", {
waiting: expiresInMs,
remaining,
limit,
resetAt,
});
if (rateLimitTimerId) clearTimeout(rateLimitTimerId); if (rateLimitTimerId) clearTimeout(rateLimitTimerId);
rateLimitTimerId = setTimeout(rateLimitTicker, rateLimitTick); rateLimitTimerId = setTimeout(rateLimitTicker, rateLimitTick);
} };
const retriedFetch = async (fetchFunc, url, options, priority = PRIORITY.FG_LOW) => { const retriedFetch = async (
fetchFunc,
url,
options,
priority = PRIORITY.FG_LOW,
) => {
for (let i = 0; i <= retries; i++) { for (let i = 0; i <= retries; i++) {
try { try {
return await add(async () => { return await add(async () => {
@ -55,19 +90,18 @@ export default (options = {}) => {
} }
return fetchFunc(url, options) return fetchFunc(url, options)
.then(response => { .then((response) => {
currentRateLimit = { ...response.rateLimit, waiting: 0 }; currentRateLimit = { ...response.rateLimit, waiting: 0 };
return response; return response;
}) })
.catch(err => { .catch((err) => {
if (err instanceof SsrTimeoutError) throw new SsrNetworkTimeoutError(err.timeout); if (err instanceof SsrTimeoutError)
throw new SsrNetworkTimeoutError(err.timeout);
throw err; throw err;
}) });
}, }, priority);
priority,
)
} catch (err) { } catch (err) {
if (err instanceof SsrHttpResponseError) { if (err instanceof SsrHttpResponseError) {
const { remaining, limit, resetAt } = err; const { remaining, limit, resetAt } = err;
@ -79,7 +113,13 @@ export default (options = {}) => {
if (!shouldRetry || i === retries) throw err; if (!shouldRetry || i === retries) throw err;
if (err instanceof SsrHttpRateLimitError) { if (err instanceof SsrHttpRateLimitError) {
if (err.remaining <= 0 && err.resetAt && (!lastRateLimitError || !lastRateLimitError.resetAt || lastRateLimitError.resetAt < err.resetAt)) { if (
err.remaining <= 0 &&
err.resetAt &&
(!lastRateLimitError ||
!lastRateLimitError.resetAt ||
lastRateLimitError.resetAt < err.resetAt)
) {
lastRateLimitError = err; lastRateLimitError = err;
rateLimitTicker(); rateLimitTicker();
@ -95,11 +135,17 @@ export default (options = {}) => {
} }
} }
throw new SsrError('Unknown error'); throw new SsrError("Unknown error");
} };
const queuedFetchJson = async (url, options, priority = PRIORITY.FG_LOW) => resolvePromiseOrWaitForPending(url, () => retriedFetch(fetchJson, url, options, priority)); const queuedFetchJson = async (url, options, priority = PRIORITY.FG_LOW) =>
const queuedFetchHtml = async (url, options, priority = PRIORITY.FG_LOW) => resolvePromiseOrWaitForPending(url, () => retriedFetch(fetchHtml, url, options, priority)); resolvePromiseOrWaitForPending(url, () =>
retriedFetch(fetchJson, url, options, priority),
);
const queuedFetchHtml = async (url, options, priority = PRIORITY.FG_LOW) =>
resolvePromiseOrWaitForPending(url, () =>
retriedFetch(fetchHtml, url, options, priority),
);
const getRateLimit = () => currentRateLimit; const getRateLimit = () => currentRateLimit;
@ -108,5 +154,5 @@ export default (options = {}) => {
fetchHtml: queuedFetchHtml, fetchHtml: queuedFetchHtml,
getRateLimit, getRateLimit,
...queueToReturn, ...queueToReturn,
} };
} };

@ -1,17 +1,18 @@
import {writable} from 'svelte/store' import { writable } from "svelte/store";
import {PRIORITY} from './http-queue' import { PRIORITY } from "./http-queue";
import createScoreSaberApiQueue from './scoresaber/api-queue' import createScoreSaberApiQueue from "./scoresaber/api-queue";
import createScoreSaberPageQueue from './scoresaber/page-queue' import createScoreSaberPageQueue from "./scoresaber/page-queue";
import createBeatMapsApiQueue from './beatmaps/api-queue' import createBeatMapsApiQueue from "./beatmaps/api-queue";
import createBeatSaviorApiQueue from './beatsavior/api-queue' import createBeatSaviorApiQueue from "./beatsavior/api-queue";
import createTwitchApiQueue from './twitch/api-queue' import createTwitchApiQueue from "./twitch/api-queue";
import createAccSaberApiQueue from './accsaber/api-queue' import createAccSaberApiQueue from "./accsaber/api-queue";
export const getResponseBody = response => response ? response.body : null; export const getResponseBody = (response) => (response ? response.body : null);
export const isResponseCached = response => !!(response && response.cached) export const isResponseCached = (response) => !!(response && response.cached);
export const updateResponseBody = (response, body) => response ? {...response, body} : null; export const updateResponseBody = (response, body) =>
response ? { ...response, body } : null;
const initQueue = queue => { const initQueue = (queue) => {
let queueState = { let queueState = {
size: 0, size: 0,
pending: 0, pending: 0,
@ -21,35 +22,78 @@ const initQueue = queue => {
const { subscribe, set } = writable(queueState); const { subscribe, set } = writable(queueState);
queue.on('change', ({size, pending}) => { queue.on("change", ({ size, pending }) => {
const {rateLimit: {waiting}} = queueState; const {
rateLimit: { waiting },
} = queueState;
const { remaining, limit, resetAt } = queue.getRateLimit(); const { remaining, limit, resetAt } = queue.getRateLimit();
queueState = {...queueState, size, pending, rateLimit: {waiting, remaining, limit, resetAt}}; queueState = {
...queueState,
size,
pending,
rateLimit: { waiting, remaining, limit, resetAt },
};
set(queueState); set(queueState);
}); });
queue.on('progress', ({progress, num, count}) => { queue.on("progress", ({ progress, num, count }) => {
const {rateLimit: {waiting}} = queueState; const {
rateLimit: { waiting },
} = queueState;
const { remaining, limit, resetAt } = queue.getRateLimit(); const { remaining, limit, resetAt } = queue.getRateLimit();
queueState = {...queueState, progress: {num, count, progress}, rateLimit: {waiting, remaining, limit, resetAt}} queueState = {
...queueState,
progress: { num, count, progress },
rateLimit: { waiting, remaining, limit, resetAt },
};
set(queueState); set(queueState);
}); });
queue.on('waiting', ({waiting, remaining, limit, resetAt}) => { queue.on("waiting", ({ waiting, remaining, limit, resetAt }) => {
queueState = {...queueState, rateLimit: {waiting, remaining, limit, resetAt}} queueState = {
...queueState,
rateLimit: { waiting, remaining, limit, resetAt },
};
set(queueState); set(queueState);
}) });
return { return {
subscribe, subscribe,
...queue, ...queue,
} };
} };
export default { export default {
SCORESABER_API: initQueue(createScoreSaberApiQueue({concurrency: 3, timeout: 95000})), SCORESABER_API: initQueue(
SCORESABER_PAGE: initQueue(createScoreSaberPageQueue({concurrency: 3, timeout: 30000})), createScoreSaberApiQueue({ concurrency: 3, timeout: 95000 }),
BEATMAPS: initQueue(createBeatMapsApiQueue({concurrency: 1, timeout: 10000, intervalCap: 10, interval: 1000})), ),
BEATSAVIOR: initQueue(createBeatSaviorApiQueue({concurrency: 1, timeout: 10000, intervalCap: 60, interval: 60000})), SCORESABER_PAGE: initQueue(
TWITCH: initQueue(createTwitchApiQueue({concurrency: 8, timeout: 8000, intervalCap: 800, interval: 60000})), createScoreSaberPageQueue({ concurrency: 3, timeout: 30000 }),
ACCSABER: initQueue(createAccSaberApiQueue({concurrency: 2, timeout: 10000})), ),
BEATMAPS: initQueue(
createBeatMapsApiQueue({
concurrency: 1,
timeout: 10000,
intervalCap: 10,
interval: 1000,
}),
),
BEATSAVIOR: initQueue(
createBeatSaviorApiQueue({
concurrency: 1,
timeout: 10000,
intervalCap: 60,
interval: 60000,
}),
),
TWITCH: initQueue(
createTwitchApiQueue({
concurrency: 8,
timeout: 8000,
intervalCap: 800,
interval: 60000,
}),
),
ACCSABER: initQueue(
createAccSaberApiQueue({ concurrency: 2, timeout: 10000 }),
),
PRIORITY, PRIORITY,
} };

@ -1,35 +1,79 @@
import {default as createQueue, PRIORITY} from '../http-queue'; import { default as createQueue, PRIORITY } from "../http-queue";
import {substituteVars} from '../../../utils/format' import { substituteVars } from "../../../utils/format";
import {PLAYER_SCORES_PER_PAGE, PLAYERS_PER_PAGE} from '../../../utils/scoresaber/consts' import {
PLAYER_SCORES_PER_PAGE,
PLAYERS_PER_PAGE,
} from "../../../utils/scoresaber/consts";
export const SS_API_HOST = 'https://new.scoresaber.com'; export const SS_API_HOST = "https://new.scoresaber.com";
export const SS_API_URL = `${SS_API_HOST}/api`; export const SS_API_URL = `${SS_API_HOST}/api`;
export const SS_API_PLAYER_INFO_URL = SS_API_URL + '/player/${playerId}/full'; export const SS_API_PLAYER_INFO_URL = SS_API_URL + "/player/${playerId}/full";
export const SS_API_RECENT_SCORES_URL = SS_API_URL + '/player/${playerId}/scores/recent/${page}'; export const SS_API_RECENT_SCORES_URL =
export const SS_API_TOP_SCORES_URL = SS_API_URL + '/player/${playerId}/scores/top/${page}'; SS_API_URL + "/player/${playerId}/scores/recent/${page}";
export const SS_API_FIND_PLAYER_URL = SS_API_URL + '/players/by-name/${query}' export const SS_API_TOP_SCORES_URL =
export const SS_API_RANKING_GLOBAL_URL = SS_API_URL + '/players/${page}' SS_API_URL + "/player/${playerId}/scores/top/${page}";
export const SS_API_RANKING_GLOBAL_PAGES_URL = SS_API_URL + '/players/pages' export const SS_API_FIND_PLAYER_URL = SS_API_URL + "/players/by-name/${query}";
export const SS_API_RANKING_GLOBAL_URL = SS_API_URL + "/players/${page}";
export const SS_API_RANKING_GLOBAL_PAGES_URL = SS_API_URL + "/players/pages";
export default (options = {}) => { export default (options = {}) => {
const queue = createQueue(options); const queue = createQueue(options);
const { fetchJson, fetchHtml, ...queueToReturn } = queue; const { fetchJson, fetchHtml, ...queueToReturn } = queue;
const fetchScores = async (baseUrl, playerId, page = 1, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(baseUrl, {playerId, page}), options, priority); const fetchScores = async (
baseUrl,
playerId,
page = 1,
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchJson(substituteVars(baseUrl, { playerId, page }), options, priority);
const player = async (playerId, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(SS_API_PLAYER_INFO_URL, {playerId}), options, priority); const player = async (playerId, priority = PRIORITY.FG_LOW, options = {}) =>
fetchJson(
substituteVars(SS_API_PLAYER_INFO_URL, { playerId }),
options,
priority,
);
const recentScores = async (playerId, page = 1, priority = PRIORITY.FG_LOW, options = {}) => fetchScores(SS_API_RECENT_SCORES_URL, playerId, page, priority, options); const recentScores = async (
playerId,
page = 1,
priority = PRIORITY.FG_LOW,
options = {},
) => fetchScores(SS_API_RECENT_SCORES_URL, playerId, page, priority, options);
const topScores = async (playerId, page = 1, priority = PRIORITY.FG_LOW, options = {}) => fetchScores(SS_API_TOP_SCORES_URL, playerId, page, priority, options); const topScores = async (
playerId,
page = 1,
priority = PRIORITY.FG_LOW,
options = {},
) => fetchScores(SS_API_TOP_SCORES_URL, playerId, page, priority, options);
const findPlayer = async (query, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(SS_API_FIND_PLAYER_URL, {query: encodeURIComponent(query)}), options, priority); const findPlayer = async (query, priority = PRIORITY.FG_LOW, options = {}) =>
fetchJson(
substituteVars(SS_API_FIND_PLAYER_URL, {
query: encodeURIComponent(query),
}),
options,
priority,
);
const rankingGlobal = async (page = 1, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(substituteVars(SS_API_RANKING_GLOBAL_URL, {page}), options, priority); const rankingGlobal = async (
page = 1,
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchJson(
substituteVars(SS_API_RANKING_GLOBAL_URL, { page }),
options,
priority,
);
const rankingGlobalPages = async (priority = PRIORITY.FG_LOW, options = {}) => fetchJson(SS_API_RANKING_GLOBAL_PAGES_URL, options, priority); const rankingGlobalPages = async (priority = PRIORITY.FG_LOW, options = {}) =>
fetchJson(SS_API_RANKING_GLOBAL_PAGES_URL, options, priority);
return { return {
player, player,
@ -42,5 +86,5 @@ export default (options = {}) => {
PLAYER_SCORES_PER_PAGE, PLAYER_SCORES_PER_PAGE,
PLAYERS_PER_PAGE, PLAYERS_PER_PAGE,
...queueToReturn, ...queueToReturn,
} };
} };

@ -24,7 +24,7 @@ export const parseSsInt = (text) => {
export const parseSsFloat = (text) => export const parseSsFloat = (text) =>
text text
? parseFloat( ? parseFloat(
getFirstRegexpMatch(/([0-9,.]+)\s*$/, text.replace(/[^\d.]/g, "")) getFirstRegexpMatch(/([0-9,.]+)\s*$/, text.replace(/[^\d.]/g, "")),
) )
: null; : null;
@ -78,32 +78,32 @@ export default (options = {}) => {
const rankeds = async ( const rankeds = async (
page = 1, page = 1,
priority = PRIORITY.BG_NORMAL, priority = PRIORITY.BG_NORMAL,
options = {} options = {},
) => ) =>
fetchJson(substituteVars(RANKEDS_URL, { page }), options, priority).then( fetchJson(substituteVars(RANKEDS_URL, { page }), options, priority).then(
(r) => { (r) => {
r.body = processRankeds(r.body); r.body = processRankeds(r.body);
return r; return r;
} },
); );
const processPlayerProfile = (playerId, doc) => { const processPlayerProfile = (playerId, doc) => {
cfDecryptEmail(doc); cfDecryptEmail(doc);
let avatar = getImgUrl( let avatar = getImgUrl(
opt(doc.querySelector(".column.avatar img"), "src", null) opt(doc.querySelector(".column.avatar img"), "src", null),
); );
let playerName = opt( let playerName = opt(
doc.querySelector(".content .column:not(.avatar) .title a"), doc.querySelector(".content .column:not(.avatar) .title a"),
"innerText" "innerText",
); );
playerName = playerName ? playerName.trim() : null; playerName = playerName ? playerName.trim() : null;
let country = getFirstRegexpMatch( let country = getFirstRegexpMatch(
/^.*?\/flags\/([^.]+)\..*$/, /^.*?\/flags\/([^.]+)\..*$/,
opt(doc.querySelector(".content .column .title img"), "src") opt(doc.querySelector(".content .column .title img"), "src"),
); );
country = country ? country.toUpperCase() : null; country = country ? country.toUpperCase() : null;
@ -111,8 +111,8 @@ export default (options = {}) => {
opt( opt(
doc.querySelector(".pagination .pagination-list li a.is-current"), doc.querySelector(".pagination .pagination-list li a.is-current"),
"innerText", "innerText",
null null,
) ),
); );
pageNum = !isNaN(pageNum) ? pageNum : null; pageNum = !isNaN(pageNum) ? pageNum : null;
@ -120,8 +120,8 @@ export default (options = {}) => {
opt( opt(
doc.querySelector(".pagination .pagination-list li:last-of-type"), doc.querySelector(".pagination .pagination-list li:last-of-type"),
"innerText", "innerText",
null null,
) ),
); );
pageQty = !isNaN(pageQty) ? pageQty : null; pageQty = !isNaN(pageQty) ? pageQty : null;
@ -130,31 +130,31 @@ export default (options = {}) => {
/^\s*<strong>(?:[^:]+)\s*:?\s*<\/strong>\s*(.*)$/, /^\s*<strong>(?:[^:]+)\s*:?\s*<\/strong>\s*(.*)$/,
opt( opt(
doc.querySelector( doc.querySelector(
".columns .column:not(.is-narrow) ul li:nth-of-type(3)" ".columns .column:not(.is-narrow) ul li:nth-of-type(3)",
),
"innerHTML",
),
), ),
"innerHTML"
)
)
); );
totalItems = !isNaN(totalItems) ? totalItems : 0; totalItems = !isNaN(totalItems) ? totalItems : 0;
let playerRank = parseSsInt( let playerRank = parseSsInt(
opt( opt(
doc.querySelector( doc.querySelector(
".content .column ul li:first-of-type a:first-of-type" ".content .column ul li:first-of-type a:first-of-type",
),
"innerText",
), ),
"innerText"
)
); );
playerRank = !isNaN(playerRank) ? playerRank : null; playerRank = !isNaN(playerRank) ? playerRank : null;
let countryRank = parseSsInt( let countryRank = parseSsInt(
opt( opt(
doc.querySelector( doc.querySelector(
'.content .column ul li:first-of-type a[href^="/global?country="]' '.content .column ul li:first-of-type a[href^="/global?country="]',
),
"innerText",
), ),
"innerText"
)
); );
countryRank = !isNaN(countryRank) ? countryRank : null; countryRank = !isNaN(countryRank) ? countryRank : null;
@ -170,7 +170,7 @@ export default (options = {}) => {
[...doc.querySelectorAll(".content .column ul li")] [...doc.querySelectorAll(".content .column ul li")]
.map((li) => { .map((li) => {
const matches = li.innerHTML.match( const matches = li.innerHTML.match(
/^\s*<strong>([^:]+)\s*:?\s*<\/strong>\s*(.*)$/ /^\s*<strong>([^:]+)\s*:?\s*<\/strong>\s*(.*)$/,
); );
if (!matches) return null; if (!matches) return null;
@ -219,7 +219,7 @@ export default (options = {}) => {
const item = mapping.find((m) => m.key === matches[1]); const item = mapping.find((m) => m.key === matches[1]);
return item ? { ...item, value } : { label: matches[1], value }; return item ? { ...item, value } : { label: matches[1], value };
}) })
.filter((s) => s) .filter((s) => s),
) )
.reduce( .reduce(
(cum, item) => { (cum, item) => {
@ -255,7 +255,7 @@ export default (options = {}) => {
return cum; return cum;
}, },
{ inactiveAccount: false, bannedAccount: false } { inactiveAccount: false, bannedAccount: false },
); );
const scores = [...doc.querySelectorAll("table.ranking tbody tr")].map( const scores = [...doc.querySelectorAll("table.ranking tbody tr")].map(
@ -274,7 +274,7 @@ export default (options = {}) => {
if (song) { if (song) {
const leaderboardId = parseInt( const leaderboardId = parseInt(
getFirstRegexpMatch(/leaderboard\/(\d+)/, song.href), getFirstRegexpMatch(/leaderboard\/(\d+)/, song.href),
10 10,
); );
ret.leaderboardId = leaderboardId ? leaderboardId : null; ret.leaderboardId = leaderboardId ? leaderboardId : null;
} else { } else {
@ -293,7 +293,7 @@ export default (options = {}) => {
.replace(/&amp;/g, "&") .replace(/&amp;/g, "&")
.replace( .replace(
/<span class="__cf_email__" data-cfemail="[^"]+">\[email&nbsp;protected]<\/span>/g, /<span class="__cf_email__" data-cfemail="[^"]+">\[email&nbsp;protected]<\/span>/g,
"" "",
) )
.match(/^(.*?)\s*<span[^>]+>(.*?)<\/span>/) .match(/^(.*?)\s*<span[^>]+>(.*?)<\/span>/)
: null; : null;
@ -328,7 +328,7 @@ export default (options = {}) => {
ret.timeSet = songDate ? dateFromString(songDate.title) : null; ret.timeSet = songDate ? dateFromString(songDate.title) : null;
const pp = parseSsFloat( const pp = parseSsFloat(
opt(tr.querySelector("th.score .scoreTop.ppValue"), "innerText") opt(tr.querySelector("th.score .scoreTop.ppValue"), "innerText"),
); );
ret.pp = !isNaN(pp) ? pp : null; ret.pp = !isNaN(pp) ? pp : null;
@ -337,9 +337,9 @@ export default (options = {}) => {
/^\(([0-9.]+)pp\)$/, /^\(([0-9.]+)pp\)$/,
opt( opt(
tr.querySelector("th.score .scoreTop.ppWeightedValue"), tr.querySelector("th.score .scoreTop.ppWeightedValue"),
"innerText" "innerText",
) ),
) ),
); );
ret.ppWeighted = !isNaN(ppWeighted) ? ppWeighted : null; ret.ppWeighted = !isNaN(ppWeighted) ? ppWeighted : null;
@ -380,7 +380,7 @@ export default (options = {}) => {
} }
return ret; return ret;
} },
); );
const recentPlay = const recentPlay =
scores && scores.length && scores[0].timeSet ? scores[0].timeSet : null; scores && scores.length && scores[0].timeSet ? scores[0].timeSet : null;
@ -394,18 +394,18 @@ export default (options = {}) => {
externalProfileUrl: opt( externalProfileUrl: opt(
doc.querySelector(".content .column:not(.avatar) .title a"), doc.querySelector(".content .column:not(.avatar) .title a"),
"href", "href",
null null,
), ),
history: getFirstRegexpMatch( history: getFirstRegexpMatch(
/data:\s*\[([0-9,]+)\]/, /data:\s*\[([0-9,]+)\]/,
doc.body.innerHTML doc.body.innerHTML,
), ),
country, country,
badges: [...doc.querySelectorAll(".column.avatar center img")].map( badges: [...doc.querySelectorAll(".column.avatar center img")].map(
(img) => ({ (img) => ({
image: getImgUrl(img.src), image: getImgUrl(img.src),
description: img.title, description: img.title,
}) }),
), ),
rank: stats.rank ? stats.rank : null, rank: stats.rank ? stats.rank : null,
countryRank: stats.countryRank ? stats.countryRank : null, countryRank: stats.countryRank ? stats.countryRank : null,
@ -435,7 +435,7 @@ export default (options = {}) => {
fetchHtml( fetchHtml(
substituteVars(PLAYER_PROFILE_URL, { playerId }), substituteVars(PLAYER_PROFILE_URL, { playerId }),
options, options,
priority priority,
).then((r) => { ).then((r) => {
r.body = processPlayerProfile(playerId, r.body); r.body = processPlayerProfile(playerId, r.body);
@ -451,17 +451,17 @@ export default (options = {}) => {
const id = getFirstRegexpMatch(/\/(\d+)$/, a.href); const id = getFirstRegexpMatch(/\/(\d+)$/, a.href);
const avatar = getImgUrl( const avatar = getImgUrl(
opt(tr.querySelector("td.picture img"), "src", null) opt(tr.querySelector("td.picture img"), "src", null),
); );
let country = getFirstRegexpMatch( let country = getFirstRegexpMatch(
/^.*?\/flags\/([^.]+)\..*$/, /^.*?\/flags\/([^.]+)\..*$/,
opt(tr.querySelector("td.player img"), "src", null) opt(tr.querySelector("td.player img"), "src", null),
); );
country = country ? country.toUpperCase() : null; country = country ? country.toUpperCase() : null;
let difference = parseSsInt( let difference = parseSsInt(
opt(tr.querySelector("td.diff"), "innerText", null) opt(tr.querySelector("td.diff"), "innerText", null),
); );
difference = !isNaN(difference) ? difference : null; difference = !isNaN(difference) ? difference : null;
@ -469,15 +469,15 @@ export default (options = {}) => {
playerName = playerName || playerName === "" ? playerName.trim() : null; playerName = playerName || playerName === "" ? playerName.trim() : null;
let pp = parseSsFloat( let pp = parseSsFloat(
opt(tr.querySelector("td.pp .scoreTop.ppValue"), "innerText") opt(tr.querySelector("td.pp .scoreTop.ppValue"), "innerText"),
); );
pp = !isNaN(pp) ? pp : null; pp = !isNaN(pp) ? pp : null;
let rank = parseSsInt( let rank = parseSsInt(
getFirstRegexpMatch( getFirstRegexpMatch(
/^\s*#(\d+)\s*$/, /^\s*#(\d+)\s*$/,
opt(tr.querySelector("td.rank"), "innerText", null) opt(tr.querySelector("td.rank"), "innerText", null),
) ),
); );
rank = !isNaN(rank) ? rank : null; rank = !isNaN(rank) ? rank : null;
@ -491,7 +491,7 @@ export default (options = {}) => {
pp, pp,
rank, rank,
}; };
} },
); );
return { players: data }; return { players: data };
@ -501,12 +501,12 @@ export default (options = {}) => {
country, country,
page = 1, page = 1,
priority = PRIORITY.FG_LOW, priority = PRIORITY.FG_LOW,
options = {} options = {},
) => ) =>
fetchHtml( fetchHtml(
substituteVars(COUNTRY_RANKING_URL, { country, page }), substituteVars(COUNTRY_RANKING_URL, { country, page }),
options, options,
priority priority,
).then((r) => { ).then((r) => {
r.body = processCountryRanking(country, r.body); r.body = processCountryRanking(country, r.body);
@ -529,11 +529,11 @@ export default (options = {}) => {
}; };
ret.player.playerInfo.avatar = getImgUrl( ret.player.playerInfo.avatar = getImgUrl(
opt(tr.querySelector(".picture img"), "src", null) opt(tr.querySelector(".picture img"), "src", null),
); );
ret.score.rank = parseSsInt( ret.score.rank = parseSsInt(
opt(tr.querySelector("td.rank"), "innerText") opt(tr.querySelector("td.rank"), "innerText"),
); );
if (isNaN(ret.score.rank)) ret.score.rank = null; if (isNaN(ret.score.rank)) ret.score.rank = null;
@ -541,7 +541,7 @@ export default (options = {}) => {
if (player) { if (player) {
let country = getFirstRegexpMatch( let country = getFirstRegexpMatch(
/^.*?\/flags\/([^.]+)\..*$/, /^.*?\/flags\/([^.]+)\..*$/,
opt(player.querySelector("img"), "src", "") opt(player.querySelector("img"), "src", ""),
); );
country = country ? country.toUpperCase() : null; country = country ? country.toUpperCase() : null;
if (country) { if (country) {
@ -551,14 +551,14 @@ export default (options = {}) => {
ret.player.name = opt( ret.player.name = opt(
player.querySelector("span.songTop.pp"), player.querySelector("span.songTop.pp"),
"innerText" "innerText",
); );
ret.player.name = ret.player.name ret.player.name = ret.player.name
? ret.player.name.trim().replace("&#039;", "'") ? ret.player.name.trim().replace("&#039;", "'")
: null; : null;
ret.player.playerId = getFirstRegexpMatch( ret.player.playerId = getFirstRegexpMatch(
/\/u\/(\d+)((\?|&|#).*)?$/, /\/u\/(\d+)((\?|&|#).*)?$/,
opt(player, "href", "") opt(player, "href", ""),
); );
ret.player.playerId = ret.player.playerId ret.player.playerId = ret.player.playerId
? ret.player.playerId.trim() ? ret.player.playerId.trim()
@ -574,7 +574,7 @@ export default (options = {}) => {
ret.score.timeSetString = opt( ret.score.timeSetString = opt(
tr.querySelector("td.timeset"), tr.querySelector("td.timeset"),
"innerText", "innerText",
null null,
); );
if (ret.score.timeSetString) if (ret.score.timeSetString)
ret.score.timeSetString = ret.score.timeSetString.trim(); ret.score.timeSetString = ret.score.timeSetString.trim();
@ -602,7 +602,7 @@ export default (options = {}) => {
const diffs = [...doc.querySelectorAll(".tabs ul li a")].map((a) => { const diffs = [...doc.querySelectorAll(".tabs ul li a")].map((a) => {
let leaderboardId = parseInt( let leaderboardId = parseInt(
getFirstRegexpMatch(/leaderboard\/(\d+)$/, a.href), getFirstRegexpMatch(/leaderboard\/(\d+)$/, a.href),
10 10,
); );
if (isNaN(leaderboardId)) leaderboardId = null; if (isNaN(leaderboardId)) leaderboardId = null;
@ -615,7 +615,7 @@ export default (options = {}) => {
const currentDiffHuman = opt( const currentDiffHuman = opt(
doc.querySelector(".tabs li.is-active a span"), doc.querySelector(".tabs li.is-active a span"),
"innerText", "innerText",
null null,
); );
let diff = null; let diff = null;
@ -628,20 +628,20 @@ export default (options = {}) => {
const songName = opt( const songName = opt(
doc.querySelector( doc.querySelector(
".column.is-one-third-desktop .box:first-of-type .title a" ".column.is-one-third-desktop .box:first-of-type .title a",
), ),
"innerText", "innerText",
null null,
); );
const imageUrl = getImgUrl( const imageUrl = getImgUrl(
opt( opt(
doc.querySelector( doc.querySelector(
".column.is-one-third-desktop .box:first-of-type .columns .column.is-one-quarter img" ".column.is-one-third-desktop .box:first-of-type .columns .column.is-one-quarter img",
), ),
"src", "src",
null null,
) ),
); );
const songInfo = [ const songInfo = [
@ -656,13 +656,13 @@ export default (options = {}) => {
] ]
.map((sid) => { .map((sid) => {
let songInfoBox = doc.querySelector( let songInfoBox = doc.querySelector(
".column.is-one-third-desktop .box:first-of-type" ".column.is-one-third-desktop .box:first-of-type",
); );
return { return {
...sid, ...sid,
value: songInfoBox value: songInfoBox
? songInfoBox.innerHTML.match( ? songInfoBox.innerHTML.match(
new RegExp(sid.label + ":\\s*<b>(.*?)</b>", "i") new RegExp(sid.label + ":\\s*<b>(.*?)</b>", "i"),
) )
: null, : null,
}; };
@ -708,7 +708,7 @@ export default (options = {}) => {
return cum; return cum;
}, },
{ imageUrl, stats: {} } { imageUrl, stats: {} },
); );
const { stats, ...song } = songInfo; const { stats, ...song } = songInfo;
@ -718,9 +718,9 @@ export default (options = {}) => {
opt( opt(
doc.querySelector(".pagination .pagination-list li:last-of-type"), doc.querySelector(".pagination .pagination-list li:last-of-type"),
"innerText", "innerText",
null null,
), ),
10 10,
); );
if (isNaN(pageQty)) pageQty = null; if (isNaN(pageQty)) pageQty = null;
@ -736,7 +736,7 @@ export default (options = {}) => {
let diffChartText = getFirstRegexpMatch( let diffChartText = getFirstRegexpMatch(
/'difficulty',\s*([0-9.,\s]+)\s*\]/, /'difficulty',\s*([0-9.,\s]+)\s*\]/,
doc.body.innerHTML doc.body.innerHTML,
); );
let diffChart = (diffChartText ? diffChartText : "") let diffChart = (diffChartText ? diffChartText : "")
.split(",") .split(",")
@ -758,12 +758,12 @@ export default (options = {}) => {
leaderboardId, leaderboardId,
page = 1, page = 1,
priority = PRIORITY.FG_LOW, priority = PRIORITY.FG_LOW,
options = {} options = {},
) => ) =>
fetchHtml( fetchHtml(
substituteVars(LEADERBOARD_URL, { leaderboardId, page }), substituteVars(LEADERBOARD_URL, { leaderboardId, page }),
options, options,
priority priority,
).then((r) => { ).then((r) => {
r.body = processLeaderboard(leaderboardId, page, r.body); r.body = processLeaderboard(leaderboardId, page, r.body);

@ -1,44 +1,104 @@
import {default as createQueue, PRIORITY} from '../http-queue'; import { default as createQueue, PRIORITY } from "../http-queue";
import ssrConfig from '../../../ssr-config' import ssrConfig from "../../../ssr-config";
import { substituteVars } from "../../../utils/format"; import { substituteVars } from "../../../utils/format";
const CLIENT_ID = 'u0swxz56n4iumc634at1osoqdk31qt'; const CLIENT_ID = "u0swxz56n4iumc634at1osoqdk31qt";
const TWITCH_AUTH_URL = 'https://id.twitch.tv/oauth2' const TWITCH_AUTH_URL = "https://id.twitch.tv/oauth2";
const AUTHORIZATION_URL = `${TWITCH_AUTH_URL}/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(ssrConfig.domain + '/twitch')}&response_type=token` + '&scope=${scopes}&state=${state}'; const AUTHORIZATION_URL =
const VALIDATE_URL = `${TWITCH_AUTH_URL}/validate` `${TWITCH_AUTH_URL}/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(
ssrConfig.domain + "/twitch",
)}&response_type=token` + "&scope=${scopes}&state=${state}";
const VALIDATE_URL = `${TWITCH_AUTH_URL}/validate`;
const TWITCH_API_URL = 'https://api.twitch.tv/helix'; const TWITCH_API_URL = "https://api.twitch.tv/helix";
const PROFILE_URL = TWITCH_API_URL + '/users?login=${login}'; const PROFILE_URL = TWITCH_API_URL + "/users?login=${login}";
const VIDEOS_URL = TWITCH_API_URL + '/videos?user_id=${userId}&type=${type}&first=100'; const VIDEOS_URL =
const STREAMS_URL = TWITCH_API_URL + '/streams?user_id=${userId}'; TWITCH_API_URL + "/videos?user_id=${userId}&type=${type}&first=100";
const STREAMS_URL = TWITCH_API_URL + "/streams?user_id=${userId}";
export default (options = {}) => { export default (options = {}) => {
const queue = createQueue(options); const queue = createQueue(options);
const { fetchJson, fetchHtml, ...queueToReturn } = queue; const { fetchJson, fetchHtml, ...queueToReturn } = queue;
const fetchApi = (url, accessToken, priority = PRIORITY.FG_LOW, options = {}) => fetchJson( const fetchApi = (
url,
accessToken,
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchJson(
url, url,
{ {
...options, ...options,
headers: { headers: {
'Client-ID': CLIENT_ID, "Client-ID": CLIENT_ID,
'Authorization': `Bearer ${accessToken}` Authorization: `Bearer ${accessToken}`,
} },
}, },
priority, priority,
) );
const getAuthUrl = (state = '', scopes = '') => substituteVars(AUTHORIZATION_URL, {state: encodeURIComponent(state), scopes: encodeURIComponent(scopes)}); const getAuthUrl = (state = "", scopes = "") =>
substituteVars(AUTHORIZATION_URL, {
state: encodeURIComponent(state),
scopes: encodeURIComponent(scopes),
});
const validateToken = async (accessToken, priority = PRIORITY.FG_LOW, options = {}) => fetchJson(VALIDATE_URL, {...options, headers: {'Authorization': `OAuth ${accessToken}`}}, priority) const validateToken = async (
accessToken,
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchJson(
VALIDATE_URL,
{ ...options, headers: { Authorization: `OAuth ${accessToken}` } },
priority,
);
const profile = async (accessToken, login, priority = PRIORITY.FG_LOW, options = {}) => fetchApi(substituteVars(PROFILE_URL, {login: encodeURIComponent(login)}), accessToken, priority, options) const profile = async (
accessToken,
login,
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchApi(
substituteVars(PROFILE_URL, { login: encodeURIComponent(login) }),
accessToken,
priority,
options,
);
const videos = async (accessToken, userId, type = 'archive', priority = PRIORITY.FG_LOW, options = {}) => fetchApi(substituteVars(VIDEOS_URL, {userId: encodeURIComponent(userId), type: encodeURIComponent(type)}), accessToken, priority, options) const videos = async (
accessToken,
userId,
type = "archive",
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchApi(
substituteVars(VIDEOS_URL, {
userId: encodeURIComponent(userId),
type: encodeURIComponent(type),
}),
accessToken,
priority,
options,
);
const streams = async (accessToken, userId, priority = PRIORITY.FG_LOW, options = {}) => fetchApi(substituteVars(STREAMS_URL, {userId: encodeURIComponent(userId)}), accessToken, priority, options) const streams = async (
accessToken,
userId,
priority = PRIORITY.FG_LOW,
options = {},
) =>
fetchApi(
substituteVars(STREAMS_URL, { userId: encodeURIComponent(userId) }),
accessToken,
priority,
options,
);
return { return {
getAuthUrl, getAuthUrl,
@ -47,5 +107,5 @@ export default (options = {}) => {
videos, videos,
streams, streams,
...queueToReturn, ...queueToReturn,
} };
} };

@ -1,13 +1,13 @@
export const parseRateLimitHeaders = response => { export const parseRateLimitHeaders = (response) => {
if (!response || !response.headers) return null; if (!response || !response.headers) return null;
const remaining = parseInt(response.headers.get('x-ratelimit-remaining'), 10); const remaining = parseInt(response.headers.get("x-ratelimit-remaining"), 10);
const limit = parseInt(response.headers.get('x-ratelimit-limit'), 10); const limit = parseInt(response.headers.get("x-ratelimit-limit"), 10);
const resetAt = parseInt(response.headers.get('x-ratelimit-reset'), 10); const resetAt = parseInt(response.headers.get("x-ratelimit-reset"), 10);
return { return {
remaining: !isNaN(remaining) ? remaining : null, remaining: !isNaN(remaining) ? remaining : null,
limit: !isNaN(limit) ? limit : null, limit: !isNaN(limit) ? limit : null,
resetAt: !isNaN(resetAt) ? new Date(resetAt * 1000) : null, resetAt: !isNaN(resetAt) ? new Date(resetAt * 1000) : null,
} };
} };

@ -12,7 +12,7 @@ export class SsrError extends Error {
export class SsrTimeoutError extends SsrError { export class SsrTimeoutError extends SsrError {
constructor(timeout, message) { constructor(timeout, message) {
super(message && message.length ? message : `Timeout Error (${timeout}ms)`) super(message && message.length ? message : `Timeout Error (${timeout}ms)`);
this.name = "SsrTimeoutError"; this.name = "SsrTimeoutError";
this.timeout = timeout; this.timeout = timeout;
@ -21,7 +21,7 @@ export class SsrTimeoutError extends SsrError {
export class SsrDataFormatError extends SsrError { export class SsrDataFormatError extends SsrError {
constructor(message, previous = null) { constructor(message, previous = null) {
super(message && message.length ? message : `Data format error`) super(message && message.length ? message : `Data format error`);
this.name = "SsrDataFormatError"; this.name = "SsrDataFormatError";
this.previous = previous; this.previous = previous;

@ -1,35 +1,36 @@
import {db} from '../db/db' import { db } from "../db/db";
import queues from '../network/queues/queues'; import queues from "../network/queues/queues";
import accSaberCategoriesApiClient from '../network/clients/accsaber/api-categories'; import accSaberCategoriesApiClient from "../network/clients/accsaber/api-categories";
import accSaberRankingApiClient from '../network/clients/accsaber/api-ranking'; import accSaberRankingApiClient from "../network/clients/accsaber/api-ranking";
import accSaberScoresApiClient from '../network/clients/accsaber/api-scores'; import accSaberScoresApiClient from "../network/clients/accsaber/api-scores";
import accSaberPlayerRankHistoryApiClient from '../network/clients/accsaber/api-player-rank-history'; import accSaberPlayerRankHistoryApiClient from "../network/clients/accsaber/api-player-rank-history";
import accSaberCategoriesRepository from '../db/repository/accsaber-categories' import accSaberCategoriesRepository from "../db/repository/accsaber-categories";
import accSaberPlayersRepository from '../db/repository/accsaber-players' import accSaberPlayersRepository from "../db/repository/accsaber-players";
import accSaberPlayersHistoryRepository from '../db/repository/accsaber-players-history'; import accSaberPlayersHistoryRepository from "../db/repository/accsaber-players-history";
import keyValueRepository from '../db/repository/key-value' import keyValueRepository from "../db/repository/key-value";
import createPlayerService from '../services/scoresaber/player'; import createPlayerService from "../services/scoresaber/player";
import {capitalize, convertArrayToObjectByKey} from '../utils/js' import { capitalize, convertArrayToObjectByKey } from "../utils/js";
import log from '../utils/logger' import log from "../utils/logger";
import { import {
addToDate, addToDate,
toAccSaberMidnight, toAccSaberMidnight,
formatDate, formatDate,
HOUR, HOUR,
MINUTE, MINUTE,
dateFromString, truncateDate, dateFromString,
} from '../utils/date' truncateDate,
import {PRIORITY} from '../network/queues/http-queue' } from "../utils/date";
import makePendingPromisePool from '../utils/pending-promises' import { PRIORITY } from "../network/queues/http-queue";
import {getServicePlayerGain, serviceFilterFunc} from './utils' import makePendingPromisePool from "../utils/pending-promises";
import {PLAYER_SCORES_PER_PAGE} from '../utils/accsaber/consts' import { getServicePlayerGain, serviceFilterFunc } from "./utils";
import {roundToPrecision} from '../utils/format' import { PLAYER_SCORES_PER_PAGE } from "../utils/accsaber/consts";
import { roundToPrecision } from "../utils/format";
const REFRESH_INTERVAL = HOUR; const REFRESH_INTERVAL = HOUR;
const SCORES_NETWORK_TTL = MINUTE * 5; const SCORES_NETWORK_TTL = MINUTE * 5;
const HISTOGRAM_AP_PRECISION = 5; const HISTOGRAM_AP_PRECISION = 5;
const CATEGORIES_ORDER = ['overall', 'true', 'standard', 'tech']; const CATEGORIES_ORDER = ["overall", "true", "standard", "tech"];
let service = null; let service = null;
export default () => { export default () => {
@ -40,61 +41,120 @@ export default () => {
const resolvePromiseOrWaitForPending = makePendingPromisePool(); const resolvePromiseOrWaitForPending = makePendingPromisePool();
const getCategories = async () => { const getCategories = async () => {
const categories = await resolvePromiseOrWaitForPending(`accSaberCategories`, () => accSaberCategoriesRepository().getAll()); const categories = await resolvePromiseOrWaitForPending(
`accSaberCategories`,
() => accSaberCategoriesRepository().getAll(),
);
const getIdx = category => { const getIdx = (category) => {
const idx = CATEGORIES_ORDER.findIndex(v => v === category?.name); const idx = CATEGORIES_ORDER.findIndex((v) => v === category?.name);
return idx >= 0 ? idx : 100000; return idx >= 0 ? idx : 100000;
} };
return categories.sort((a, b) => getIdx(a) - getIdx(b)); return categories.sort((a, b) => getIdx(a) - getIdx(b));
} };
const getPlayer = async playerId => resolvePromiseOrWaitForPending(`accSaberPlayer/${playerId}`, () => accSaberPlayersRepository().getAllFromIndex('accsaber-players-playerId', playerId)); const getPlayer = async (playerId) =>
const getRanking = async (category = 'overall') => accSaberPlayersRepository().getAllFromIndex('accsaber-players-category', category); resolvePromiseOrWaitForPending(`accSaberPlayer/${playerId}`, () =>
const getPlayerHistory = async playerId => resolvePromiseOrWaitForPending(`accSaberPlayerHistory/${playerId}`, () => accSaberPlayersHistoryRepository().getAllFromIndex('accsaber-players-history-playerId', playerId)) accSaberPlayersRepository().getAllFromIndex(
"accsaber-players-playerId",
playerId,
),
);
const getRanking = async (category = "overall") =>
accSaberPlayersRepository().getAllFromIndex(
"accsaber-players-category",
category,
);
const getPlayerHistory = async (playerId) =>
resolvePromiseOrWaitForPending(`accSaberPlayerHistory/${playerId}`, () =>
accSaberPlayersHistoryRepository().getAllFromIndex(
"accsaber-players-history-playerId",
playerId,
),
);
const isDataForPlayerAvailable = async playerId => (await Promise.all([getPlayer(playerId), getCategories()])).every(d => d?.length) const isDataForPlayerAvailable = async (playerId) =>
(await Promise.all([getPlayer(playerId), getCategories()])).every(
(d) => d?.length,
);
const getPlayerGain = (playerHistory, daysAgo = 1, maxDaysAgo = 7) => getServicePlayerGain(playerHistory, toAccSaberMidnight, 'accSaberDate', daysAgo, maxDaysAgo); const getPlayerGain = (playerHistory, daysAgo = 1, maxDaysAgo = 7) =>
getServicePlayerGain(
playerHistory,
toAccSaberMidnight,
"accSaberDate",
daysAgo,
maxDaysAgo,
);
const getLastUpdatedKey = type => `accSaber${capitalize(type)}LastUpdated`; const getLastUpdatedKey = (type) => `accSaber${capitalize(type)}LastUpdated`;
const getLastUpdated = async (type = 'all') => keyValueRepository().get(getLastUpdatedKey(type)); const getLastUpdated = async (type = "all") =>
const setLastUpdated = async (type = 'all', date) => keyValueRepository().set(date, getLastUpdatedKey(type)); keyValueRepository().get(getLastUpdatedKey(type));
const setLastUpdated = async (type = "all", date) =>
keyValueRepository().set(date, getLastUpdatedKey(type));
const shouldRefresh = async (type = 'all', forceUpdate = false) => { const shouldRefresh = async (type = "all", forceUpdate = false) => {
if (!forceUpdate) { if (!forceUpdate) {
const lastUpdated = await getLastUpdated(type); const lastUpdated = await getLastUpdated(type);
if (lastUpdated && lastUpdated > new Date() - REFRESH_INTERVAL) { if (lastUpdated && lastUpdated > new Date() - REFRESH_INTERVAL) {
log.debug(`Refresh interval for ${type} not yet expired, skipping. Next refresh on ${formatDate(addToDate(REFRESH_INTERVAL, lastUpdated))}`, 'AccSaberService') log.debug(
`Refresh interval for ${type} not yet expired, skipping. Next refresh on ${formatDate(
addToDate(REFRESH_INTERVAL, lastUpdated),
)}`,
"AccSaberService",
);
return false; return false;
} }
} }
return true; return true;
} };
const fetchScoresPage = async (playerId, page = 1, priority = PRIORITY.FG_LOW, {...options} = {}) => { const fetchScoresPage = async (
playerId,
page = 1,
priority = PRIORITY.FG_LOW,
{ ...options } = {},
) => {
if (!options) options = {}; if (!options) options = {};
if (!options.hasOwnProperty('cacheTtl')) options.cacheTtl = SCORES_NETWORK_TTL; if (!options.hasOwnProperty("cacheTtl"))
options.cacheTtl = SCORES_NETWORK_TTL;
const categoriesByDisplayName = convertArrayToObjectByKey(await getCategories(), 'displayName'); const categoriesByDisplayName = convertArrayToObjectByKey(
await getCategories(),
"displayName",
);
return (await resolvePromiseOrWaitForPending(`fetchPlayerScores/${playerId}/${page}`, () => accSaberScoresApiClient.getProcessed({...options, playerId, page, priority}))) return (
.map(s => ({ await resolvePromiseOrWaitForPending(
`fetchPlayerScores/${playerId}/${page}`,
() =>
accSaberScoresApiClient.getProcessed({
...options,
playerId,
page,
priority,
}),
)
).map((s) => ({
...s, ...s,
leaderboard: { leaderboard: {
...s?.leaderboard, ...s?.leaderboard,
category: categoriesByDisplayName[s?.leaderboard?.categoryDisplayName]?.name ?? null, category:
} categoriesByDisplayName[s?.leaderboard?.categoryDisplayName]?.name ??
})) null,
} },
}));
};
const getScoresHistogramDefinition = (serviceParams = {type: 'overall', sort: 'ap', order: 'desc'}) => { const getScoresHistogramDefinition = (
const scoreType = serviceParams?.type ?? 'overall'; serviceParams = { type: "overall", sort: "ap", order: "desc" },
const sort = serviceParams?.sort ?? 'ap'; ) => {
const order = serviceParams?.order ?? 'desc'; const scoreType = serviceParams?.type ?? "overall";
const sort = serviceParams?.sort ?? "ap";
const order = serviceParams?.order ?? "desc";
const commonFilterFunc = serviceFilterFunc(serviceParams); const commonFilterFunc = serviceFilterFunc(serviceParams);
@ -104,68 +164,75 @@ export default () => {
let maxBucketSize = null; let maxBucketSize = null;
let bucketSizeStep = null; let bucketSizeStep = null;
let bucketSizeValues = null; let bucketSizeValues = null;
let type = 'linear'; let type = "linear";
let valFunc = s => s; let valFunc = (s) => s;
let filterFunc = s => commonFilterFunc(s) && (scoreType === 'overall' || s?.leaderboard?.category === scoreType); let filterFunc = (s) =>
let histogramFilterFunc = s => s; commonFilterFunc(s) &&
let roundedValFunc = (s, type = type, precision = bucketSize) => type === 'linear' (scoreType === "overall" || s?.leaderboard?.category === scoreType);
let histogramFilterFunc = (s) => s;
let roundedValFunc = (s, type = type, precision = bucketSize) =>
type === "linear"
? roundToPrecision(valFunc(s), precision) ? roundToPrecision(valFunc(s), precision)
: truncateDate(valFunc(s), precision); : truncateDate(valFunc(s), precision);
let prefix = ''; let prefix = "";
let prefixLong = ''; let prefixLong = "";
let suffix = ''; let suffix = "";
let suffixLong = ''; let suffixLong = "";
switch (sort) { switch (sort) {
case 'ap': case "ap":
valFunc = s => s?.ap; valFunc = (s) => s?.ap;
type = 'linear'; type = "linear";
bucketSize = HISTOGRAM_AP_PRECISION; bucketSize = HISTOGRAM_AP_PRECISION;
minBucketSize = 1; minBucketSize = 1;
maxBucketSize = 100; maxBucketSize = 100;
bucketSizeStep = 1; bucketSizeStep = 1;
round = 0; round = 0;
suffix = ' AP'; suffix = " AP";
suffixLong = ' AP'; suffixLong = " AP";
break; break;
case 'recent': case "recent":
valFunc = s => s?.timeSet; valFunc = (s) => s?.timeSet;
type = 'time'; type = "time";
bucketSize = 'day' bucketSize = "day";
break; break;
case 'acc': case "acc":
valFunc = s => s?.acc; valFunc = (s) => s?.acc;
type = 'linear'; type = "linear";
bucketSize = 0.05; bucketSize = 0.05;
minBucketSize = 0.05; minBucketSize = 0.05;
maxBucketSize = 1; maxBucketSize = 1;
bucketSizeStep = 0.05; bucketSizeStep = 0.05;
round = 2; round = 2;
suffix = '%'; suffix = "%";
suffixLong = '%'; suffixLong = "%";
break; break;
case 'rank': case "rank":
valFunc = s => s?.score?.rank; valFunc = (s) => s?.score?.rank;
type = 'linear'; type = "linear";
bucketSize = 5; bucketSize = 5;
minBucketSize = 1; minBucketSize = 1;
maxBucketSize = 100; maxBucketSize = 100;
bucketSizeStep = 1; bucketSizeStep = 1;
round = 0; round = 0;
prefix = ''; prefix = "";
prefixLong = '#'; prefixLong = "#";
break; break;
} }
return { return {
getValue: valFunc, getValue: valFunc,
getRoundedValue: (bucketSize = bucketSize) => s => roundedValFunc(s, type, bucketSize), getRoundedValue:
(bucketSize = bucketSize) =>
(s) =>
roundedValFunc(s, type, bucketSize),
filter: filterFunc, filter: filterFunc,
histogramFilter: histogramFilterFunc, histogramFilter: histogramFilterFunc,
sort: (a, b) => order === 'asc' ? valFunc(a) - valFunc(b) : valFunc(b) - valFunc(a), sort: (a, b) =>
order === "asc" ? valFunc(a) - valFunc(b) : valFunc(b) - valFunc(a),
type, type,
bucketSize, bucketSize,
minBucketSize, minBucketSize,
@ -177,36 +244,38 @@ export default () => {
prefixLong, prefixLong,
suffix, suffix,
suffixLong, suffixLong,
order order,
} };
} };
const getPlayerScores = async playerId => { const getPlayerScores = async (playerId) => {
try { try {
return fetchScoresPage(playerId, 1); return fetchScoresPage(playerId, 1);
} } catch (err) {
catch (err) {
return []; return [];
} }
} };
const getPlayerScoresPage = async (playerId, serviceParams = {sort: 'recent', order: 'desc', page: 1}) => { const getPlayerScoresPage = async (
playerId,
serviceParams = { sort: "recent", order: "desc", page: 1 },
) => {
let page = serviceParams?.page ?? 1; let page = serviceParams?.page ?? 1;
if (page < 1) page = 1; if (page < 1) page = 1;
let playerScores; let playerScores;
try { try {
playerScores = await fetchScoresPage(playerId, page); playerScores = await fetchScoresPage(playerId, page);
} } catch (err) {
catch (err) {
return { total: 0, scores: [] }; return { total: 0, scores: [] };
} }
if (!playerScores?.length) return { total: 0, scores: [] }; if (!playerScores?.length) return { total: 0, scores: [] };
const {sort: sortFunc, filter: filterFunc} = getScoresHistogramDefinition(serviceParams); const { sort: sortFunc, filter: filterFunc } =
getScoresHistogramDefinition(serviceParams);
playerScores = playerScores.filter(filterFunc).sort(sortFunc) playerScores = playerScores.filter(filterFunc).sort(sortFunc);
const startIdx = (page - 1) * PLAYER_SCORES_PER_PAGE; const startIdx = (page - 1) * PLAYER_SCORES_PER_PAGE;
if (playerScores.length < startIdx + 1) return { total: 0, scores: [] }; if (playerScores.length < startIdx + 1) return { total: 0, scores: [] };
@ -214,127 +283,185 @@ export default () => {
return { return {
total: playerScores.length, total: playerScores.length,
itemsPerPage: PLAYER_SCORES_PER_PAGE, itemsPerPage: PLAYER_SCORES_PER_PAGE,
scores: playerScores scores: playerScores.slice(startIdx, startIdx + PLAYER_SCORES_PER_PAGE),
.slice(startIdx, startIdx + PLAYER_SCORES_PER_PAGE) };
} };
}
const fetchPlayerRankHistory = async (playerId, priority = PRIORITY.FG_LOW, {...options} = {}) => { const fetchPlayerRankHistory = async (
playerId,
priority = PRIORITY.FG_LOW,
{ ...options } = {},
) => {
if (!options) options = {}; if (!options) options = {};
if (!options.hasOwnProperty('cacheTtl')) options.cacheTtl = SCORES_NETWORK_TTL; if (!options.hasOwnProperty("cacheTtl"))
options.cacheTtl = SCORES_NETWORK_TTL;
return accSaberPlayerRankHistoryApiClient.getProcessed({...options, playerId, priority}); return accSaberPlayerRankHistoryApiClient.getProcessed({
} ...options,
playerId,
priority,
});
};
const refreshCategories = async (forceUpdate = false, priority = queues.PRIORITY.BG_NORMAL, throwErrors = false) => { const refreshCategories = async (
log.debug(`Starting AccSaber categories refreshing${forceUpdate ? ' (forced)' : ''}...`, 'AccSaberService') forceUpdate = false,
priority = queues.PRIORITY.BG_NORMAL,
throwErrors = false,
) => {
log.debug(
`Starting AccSaber categories refreshing${
forceUpdate ? " (forced)" : ""
}...`,
"AccSaberService",
);
try { try {
log.trace(`Fetching categories from DB...`, 'AccSaberService'); log.trace(`Fetching categories from DB...`, "AccSaberService");
const dbCategories = await getCategories(); const dbCategories = await getCategories();
log.trace(`DB categories fetched`, 'AccSaberService', dbCategories); log.trace(`DB categories fetched`, "AccSaberService", dbCategories);
if (!await shouldRefresh('categories', forceUpdate)) return {changed: [], all: dbCategories}; if (!(await shouldRefresh("categories", forceUpdate)))
return { changed: [], all: dbCategories };
log.trace(`Fetching current categories from AccSaber...`, 'AccSaberService'); log.trace(
`Fetching current categories from AccSaber...`,
"AccSaberService",
);
let categories = await accSaberCategoriesApiClient.getProcessed({priority}); let categories = await accSaberCategoriesApiClient.getProcessed({
priority,
});
if (!categories || !categories.length) { if (!categories || !categories.length) {
log.warn(`AccSaber returned empty categories list`, 'AccSaberService') log.warn(`AccSaber returned empty categories list`, "AccSaberService");
return null; return null;
} }
categories = categories.concat([{ categories = categories.concat([
name: 'overall', {
displayName: 'Overall', name: "overall",
displayName: "Overall",
countsTowardsOverall: null, countsTowardsOverall: null,
description: 'Overall' description: "Overall",
}]); },
]);
log.trace(`Categories fetched`, 'AccSaberService', categories); log.trace(`Categories fetched`, "AccSaberService", categories);
const dbCategoriesNames = dbCategories.map(c => c.name); const dbCategoriesNames = dbCategories.map((c) => c.name);
const newCategories = categories.filter(c => !dbCategories || !dbCategoriesNames.includes(c.name)); const newCategories = categories.filter(
(c) => !dbCategories || !dbCategoriesNames.includes(c.name),
);
if (newCategories && newCategories.length) if (newCategories && newCategories.length)
log.debug(`${newCategories.length} new categories found`, 'AccSaberService'); log.debug(
`${newCategories.length} new categories found`,
"AccSaberService",
);
await db.runInTransaction(['accsaber-categories', 'key-value'], async tx => { await db.runInTransaction(
const newCategoriesNames = categories.map(c => c.name); ["accsaber-categories", "key-value"],
async (tx) => {
const newCategoriesNames = categories.map((c) => c.name);
const accSaberCategoriesStore = tx.objectStore('accsaber-categories'); const accSaberCategoriesStore = tx.objectStore("accsaber-categories");
let cursor = await accSaberCategoriesStore.openCursor(); let cursor = await accSaberCategoriesStore.openCursor();
log.trace(`Remove old categories from DB`, 'AccSaberService'); log.trace(`Remove old categories from DB`, "AccSaberService");
while (cursor) { while (cursor) {
const category = cursor.value; const category = cursor.value;
if (!newCategoriesNames.includes(category.name)) await cursor.delete(); if (!newCategoriesNames.includes(category.name))
await cursor.delete();
cursor = await cursor.continue(); cursor = await cursor.continue();
} }
log.trace(`Old categories removed from DB`, 'AccSaberService'); log.trace(`Old categories removed from DB`, "AccSaberService");
log.trace(`Updating categories in DB...`, 'AccSaberService'); log.trace(`Updating categories in DB...`, "AccSaberService");
await Promise.all(categories.map(async c => accSaberCategoriesStore.put(c))); await Promise.all(
categories.map(async (c) => accSaberCategoriesStore.put(c)),
);
log.trace(`Categories updated`, 'AccSaberService'); log.trace(`Categories updated`, "AccSaberService");
log.trace(`Updating categories last update date in DB...`, 'AccSaberService'); log.trace(
`Updating categories last update date in DB...`,
"AccSaberService",
);
await tx.objectStore('key-value').put(new Date(), getLastUpdatedKey('categories')); await tx
.objectStore("key-value")
.put(new Date(), getLastUpdatedKey("categories"));
log.debug(`Categories last update date updated`, 'AccSaberService'); log.debug(`Categories last update date updated`, "AccSaberService");
}); },
);
accSaberCategoriesRepository().addToCache(categories); accSaberCategoriesRepository().addToCache(categories);
keyValueRepository().setCache(getLastUpdatedKey('categories'), new Date()); keyValueRepository().setCache(
getLastUpdatedKey("categories"),
new Date(),
);
log.debug(`Categories refreshing completed`, 'AccSaberService'); log.debug(`Categories refreshing completed`, "AccSaberService");
return { changed: newCategories, all: categories }; return { changed: newCategories, all: categories };
} } catch (e) {
catch(e) {
if (throwErrors) throw e; if (throwErrors) throw e;
log.debug(`Categories refreshing error`, 'AccSaberService', e) log.debug(`Categories refreshing error`, "AccSaberService", e);
return null; return null;
} }
} };
const updatePlayerHistory = async player => { const updatePlayerHistory = async (player) => {
if (!player?.playerId) return; if (!player?.playerId) return;
try { try {
log.debug(`Updating player ${player.playerId} history`, 'AccSaberService'); log.debug(
`Updating player ${player.playerId} history`,
"AccSaberService",
);
const accSaberDate = toAccSaberMidnight(new Date()); const accSaberDate = toAccSaberMidnight(new Date());
const playerIdTimestamp = `${player.playerId}_${accSaberDate.getTime()}`; const playerIdTimestamp = `${player.playerId}_${accSaberDate.getTime()}`;
const existingData = await accSaberPlayersHistoryRepository().get(playerIdTimestamp); const existingData =
await accSaberPlayersHistoryRepository().get(playerIdTimestamp);
const lastUpdated = dateFromString(existingData?.lastUpdated); const lastUpdated = dateFromString(existingData?.lastUpdated);
if (lastUpdated && lastUpdated > new Date() - REFRESH_INTERVAL) { if (lastUpdated && lastUpdated > new Date() - REFRESH_INTERVAL) {
log.debug(`Refresh interval for player ${player.playerId} history not yet expired, skipping. Next refresh on ${formatDate(addToDate(REFRESH_INTERVAL, lastUpdated))}`, 'AccSaberService') log.debug(
`Refresh interval for player ${
player.playerId
} history not yet expired, skipping. Next refresh on ${formatDate(
addToDate(REFRESH_INTERVAL, lastUpdated),
)}`,
"AccSaberService",
);
return; return;
} }
const categories = (await getCategories())?.map(c => c.name) ?? null; const categories = (await getCategories())?.map((c) => c.name) ?? null;
if (!categories) { if (!categories) {
log.trace(`No categories found, skip updating player ${player.playerId} history.`); log.trace(
`No categories found, skip updating player ${player.playerId} history.`,
);
return; return;
} }
let accStats = {}; let accStats = {};
for (const category of categories) { for (const category of categories) {
const playerAccInfo = (await getRanking(category) ?? []).find(p => p.playerId === player.playerId); const playerAccInfo = ((await getRanking(category)) ?? []).find(
(p) => p.playerId === player.playerId,
);
if (!playerAccInfo) continue; if (!playerAccInfo) continue;
const { const {
@ -358,109 +485,175 @@ export default () => {
accSaberDate, accSaberDate,
lastUpdated: new Date(), lastUpdated: new Date(),
playerIdTimestamp, playerIdTimestamp,
categories: accStats categories: accStats,
} };
await accSaberPlayersHistoryRepository().set(stats); await accSaberPlayersHistoryRepository().set(stats);
} else { } else {
log.trace(`No Acc Saber data for player ${player.playerId}, skipping history updating.`, 'AccSaberService'); log.trace(
`No Acc Saber data for player ${player.playerId}, skipping history updating.`,
"AccSaberService",
);
return; return;
} }
log.debug(`Player ${player.playerId} history updated`, 'AccSaberService'); log.debug(`Player ${player.playerId} history updated`, "AccSaberService");
} } catch (e) {
catch(e) { log.debug(
log.debug(`Player ${player.playerId} history updating error.`, 'AccSaberService', e); `Player ${player.playerId} history updating error.`,
} "AccSaberService",
e,
);
} }
};
const refreshRanking = async (category = 'overall', forceUpdate = false, priority = queues.PRIORITY.BG_NORMAL, throwErrors = false) => { const refreshRanking = async (
log.debug(`Starting AccSaber ${category} ranking refreshing${forceUpdate ? ' (forced)' : ''}...`, 'AccSaberService') category = "overall",
forceUpdate = false,
priority = queues.PRIORITY.BG_NORMAL,
throwErrors = false,
) => {
log.debug(
`Starting AccSaber ${category} ranking refreshing${
forceUpdate ? " (forced)" : ""
}...`,
"AccSaberService",
);
try { try {
log.trace(`Fetching ${category} ranking from DB...`, 'AccSaberService'); log.trace(`Fetching ${category} ranking from DB...`, "AccSaberService");
const dbRanking = await getRanking(category); const dbRanking = await getRanking(category);
log.trace(`DB ${category} ranking fetched`, 'AccSaberService', dbRanking); log.trace(`DB ${category} ranking fetched`, "AccSaberService", dbRanking);
const rankingType = `${category}Ranking` const rankingType = `${category}Ranking`;
if (!await shouldRefresh(rankingType, forceUpdate)) return dbRanking.sort((a, b) => a.rank - b.rank); if (!(await shouldRefresh(rankingType, forceUpdate)))
return dbRanking.sort((a, b) => a.rank - b.rank);
log.trace(`Fetching current ${category} ranking from AccSaber...`, 'AccSaberService'); log.trace(
`Fetching current ${category} ranking from AccSaber...`,
"AccSaberService",
);
const ranking = await accSaberRankingApiClient.getProcessed({category, priority}); const ranking = await accSaberRankingApiClient.getProcessed({
category,
priority,
});
if (!ranking || !ranking.length) { if (!ranking || !ranking.length) {
log.warn(`AccSaber returned empty ${category} ranking`, 'AccSaberService') log.warn(
`AccSaber returned empty ${category} ranking`,
"AccSaberService",
);
return null; return null;
} }
log.trace(`${capitalize(category)} ranking fetched`, 'AccSaberService', ranking); log.trace(
`${capitalize(category)} ranking fetched`,
"AccSaberService",
ranking,
);
log.trace(`Updating ${category} ranking...`, 'AccSaberService'); log.trace(`Updating ${category} ranking...`, "AccSaberService");
await db.runInTransaction(['accsaber-players', 'key-value'], async tx => { await db.runInTransaction(
const newPlayerIds = ranking.map(c => c.playerId); ["accsaber-players", "key-value"],
async (tx) => {
const newPlayerIds = ranking.map((c) => c.playerId);
const accSaberPlayersStore = tx.objectStore('accsaber-players'); const accSaberPlayersStore = tx.objectStore("accsaber-players");
let cursor = await accSaberPlayersStore.openCursor(); let cursor = await accSaberPlayersStore.openCursor();
log.trace(`Remove old players from DB for category ${category}`, 'AccSaberService'); log.trace(
`Remove old players from DB for category ${category}`,
"AccSaberService",
);
while (cursor) { while (cursor) {
const player = cursor.value; const player = cursor.value;
if (player.category === category && !newPlayerIds.includes(player.playerId)) await cursor.delete(); if (
player.category === category &&
!newPlayerIds.includes(player.playerId)
)
await cursor.delete();
cursor = await cursor.continue(); cursor = await cursor.continue();
} }
log.trace(`Old players removed from DB`, 'AccSaberService'); log.trace(`Old players removed from DB`, "AccSaberService");
log.trace(`Updating players in DB...`, 'AccSaberService'); log.trace(`Updating players in DB...`, "AccSaberService");
await Promise.all(ranking.map(async p => accSaberPlayersStore.put(p))); await Promise.all(
ranking.map(async (p) => accSaberPlayersStore.put(p)),
);
log.trace(`Players updated`, 'AccSaberService'); log.trace(`Players updated`, "AccSaberService");
log.trace(`Updating players last update date in DB...`, 'AccSaberService'); log.trace(
`Updating players last update date in DB...`,
"AccSaberService",
);
await tx.objectStore('key-value').put(new Date(), getLastUpdatedKey(rankingType)); await tx
.objectStore("key-value")
.put(new Date(), getLastUpdatedKey(rankingType));
log.debug(`Players last update date updated`, 'AccSaberService'); log.debug(`Players last update date updated`, "AccSaberService");
}); },
);
accSaberPlayersRepository().addToCache(ranking); accSaberPlayersRepository().addToCache(ranking);
keyValueRepository().setCache(getLastUpdatedKey(rankingType), new Date()); keyValueRepository().setCache(getLastUpdatedKey(rankingType), new Date());
log.debug(`${capitalize(category)} ranking refreshing completed`, 'AccSaberService'); log.debug(
`${capitalize(category)} ranking refreshing completed`,
"AccSaberService",
);
return ranking.sort((a, b) => a.rank - b.rank); return ranking.sort((a, b) => a.rank - b.rank);
} } catch (e) {
catch (e) {
if (throwErrors) throw e; if (throwErrors) throw e;
log.debug(` ${capitalize(category)} ranking refreshing error`, 'AccSaberService', e) log.debug(
` ${capitalize(category)} ranking refreshing error`,
"AccSaberService",
e,
);
return null; return null;
} }
} };
const refreshAll = async (category = 'overall', forceUpdate = false, priority = queues.PRIORITY.BG_NORMAL, throwErrors = false) => { const refreshAll = async (
log.trace(`Starting AccSaber all data refreshing${forceUpdate ? ' (forced)' : ''}...`, 'AccSaberService') category = "overall",
forceUpdate = false,
priority = queues.PRIORITY.BG_NORMAL,
throwErrors = false,
) => {
log.trace(
`Starting AccSaber all data refreshing${
forceUpdate ? " (forced)" : ""
}...`,
"AccSaberService",
);
try { try {
const dbCategories = await refreshCategories(); const dbCategories = await refreshCategories();
if (!dbCategories || !dbCategories.all) throw 'Can not refresh categories'; if (!dbCategories || !dbCategories.all)
throw "Can not refresh categories";
const allRankings = await Promise.all( const allRankings = await Promise.all(
dbCategories.all.map(c => c.name).map(async category => refreshRanking(category)) dbCategories.all
) .map((c) => c.name)
.map(async (category) => refreshRanking(category)),
);
log.debug(`All data refreshing completed.`, 'AccSaberService') log.debug(`All data refreshing completed.`, "AccSaberService");
const rankings = allRankings.reduce((cum, ranking) => { const rankings = allRankings.reduce((cum, ranking) => {
if (!ranking || !ranking.length) return cum; if (!ranking || !ranking.length) return cum;
@ -470,21 +663,28 @@ export default () => {
return cum; return cum;
}, {}); }, {});
Promise.all((await playerService.getAllActive()).map(async player => updatePlayerHistory(player))).then(_ => _); Promise.all(
(await playerService.getAllActive()).map(async (player) =>
updatePlayerHistory(player),
),
).then((_) => _);
return dbCategories.all.map(c => ({...c, ranking: rankings?.[c.name] ?? []})); return dbCategories.all.map((c) => ({
...c,
ranking: rankings?.[c.name] ?? [],
}));
} catch (e) { } catch (e) {
if (throwErrors) throw e; if (throwErrors) throw e;
log.debug(`All data refreshing error`, 'AccSaberService', e) log.debug(`All data refreshing error`, "AccSaberService", e);
return null; return null;
} }
} };
const destroyService = () => { const destroyService = () => {
service = null; service = null;
} };
service = { service = {
isDataForPlayerAvailable, isDataForPlayerAvailable,
@ -502,7 +702,7 @@ export default () => {
refreshRanking, refreshRanking,
refreshAll, refreshAll,
destroyService, destroyService,
} };
return service; return service;
} };

@ -1,28 +1,29 @@
import hashApiClient from '../network/clients/beatmaps/api-hash'; import hashApiClient from "../network/clients/beatmaps/api-hash";
import keyApiClient from '../network/clients/beatmaps/api-key'; import keyApiClient from "../network/clients/beatmaps/api-key";
import {PRIORITY} from '../network/queues/http-queue'; import { PRIORITY } from "../network/queues/http-queue";
import log from '../utils/logger' import log from "../utils/logger";
import {SsrHttpNotFoundError, SsrNetworkError} from '../network/errors' import { SsrHttpNotFoundError, SsrNetworkError } from "../network/errors";
import songsBeatMapsRepository from "../db/repository/songs-beatmaps"; import songsBeatMapsRepository from "../db/repository/songs-beatmaps";
import cacheRepository from "../db/repository/cache"; import cacheRepository from "../db/repository/cache";
import {addToDate, dateFromString, HOUR} from '../utils/date' import { addToDate, dateFromString, HOUR } from "../utils/date";
import {capitalize, opt} from '../utils/js' import { capitalize, opt } from "../utils/js";
const BM_SUSPENSION_KEY = 'bmSuspension'; const BM_SUSPENSION_KEY = "bmSuspension";
const BM_NOT_FOUND_KEY = 'bm404'; const BM_NOT_FOUND_KEY = "bm404";
const BM_NOT_FOUND_HOURS_BETWEEN_COUNTS = 1; const BM_NOT_FOUND_HOURS_BETWEEN_COUNTS = 1;
const INVALID_NOTES_COUNT_FIXES = { const INVALID_NOTES_COUNT_FIXES = {
'e738b38b594861745bfb0473c66ca5cca15072ff': [ e738b38b594861745bfb0473c66ca5cca15072ff: [
{type: 'Standard', diff: "ExpertPlus", notes: 942} { type: "Standard", diff: "ExpertPlus", notes: 942 },
] ],
} };
export default () => { export default () => {
const cacheSongInfo = async (songInfo, originalHash) => { const cacheSongInfo = async (songInfo, originalHash) => {
if (!songInfo) return null; if (!songInfo) return null;
const hash = originalHash && originalHash.length ? originalHash : songInfo.hash; const hash =
originalHash && originalHash.length ? originalHash : songInfo.hash;
if (!hash || !songInfo.key) return null; if (!hash || !songInfo.key) return null;
@ -34,30 +35,46 @@ export default () => {
await songsBeatMapsRepository().set(songInfo); await songsBeatMapsRepository().set(songInfo);
return songInfo; return songInfo;
} };
const isSuspended = bsSuspension => !!bsSuspension && bsSuspension.activeTo > new Date() && bsSuspension.started > addToDate(-24 * HOUR); const isSuspended = (bsSuspension) =>
const getCurrentSuspension = async () => cacheRepository().get(BM_SUSPENSION_KEY); !!bsSuspension &&
const prolongSuspension = async bsSuspension => { bsSuspension.activeTo > new Date() &&
bsSuspension.started > addToDate(-24 * HOUR);
const getCurrentSuspension = async () =>
cacheRepository().get(BM_SUSPENSION_KEY);
const prolongSuspension = async (bsSuspension) => {
const current = new Date(); const current = new Date();
const suspension = isSuspended(bsSuspension) ? bsSuspension : {started: current, activeTo: new Date(), count: 0}; const suspension = isSuspended(bsSuspension)
? bsSuspension
: { started: current, activeTo: new Date(), count: 0 };
suspension.activeTo = addToDate(Math.pow(2, suspension.count) * HOUR, suspension.activeTo); suspension.activeTo = addToDate(
Math.pow(2, suspension.count) * HOUR,
suspension.activeTo,
);
suspension.count++; suspension.count++;
return await cacheRepository().set(suspension, BM_SUSPENSION_KEY); return await cacheRepository().set(suspension, BM_SUSPENSION_KEY);
} };
const get404Hashes = async () => cacheRepository().get(BM_NOT_FOUND_KEY); const get404Hashes = async () => cacheRepository().get(BM_NOT_FOUND_KEY);
const set404Hashes = async hashes => cacheRepository().set(hashes, BM_NOT_FOUND_KEY); const set404Hashes = async (hashes) =>
const setHashNotFound = async hash => { cacheRepository().set(hashes, BM_NOT_FOUND_KEY);
const setHashNotFound = async (hash) => {
let songs404 = await get404Hashes(); let songs404 = await get404Hashes();
if (!songs404) songs404 = {}; if (!songs404) songs404 = {};
const item = songs404[hash] ? songs404[hash] : {firstTry: new Date(), recentTry: null, count: 0}; const item = songs404[hash]
? songs404[hash]
: { firstTry: new Date(), recentTry: null, count: 0 };
if (!item.recentTry || addToDate(BM_NOT_FOUND_HOURS_BETWEEN_COUNTS * HOUR, item.recentTry) < new Date()) { if (
!item.recentTry ||
addToDate(BM_NOT_FOUND_HOURS_BETWEEN_COUNTS * HOUR, item.recentTry) <
new Date()
) {
item.recentTry = new Date(); item.recentTry = new Date();
item.count++; item.count++;
@ -65,31 +82,40 @@ export default () => {
await set404Hashes(songs404); await set404Hashes(songs404);
} }
} };
const isHashUnavailable = async hash => { const isHashUnavailable = async (hash) => {
const songs404 = await get404Hashes(); const songs404 = await get404Hashes();
return songs404 && songs404[hash] && songs404[hash].count >= 3; return songs404 && songs404[hash] && songs404[hash].count >= 3;
} };
const fixInvalidNotesCount = (hash, songInfo) => { const fixInvalidNotesCount = (hash, songInfo) => {
if (!hash) return songInfo; if (!hash) return songInfo;
if (INVALID_NOTES_COUNT_FIXES[hash] && songInfo?.versions) if (INVALID_NOTES_COUNT_FIXES[hash] && songInfo?.versions)
songInfo.versions.forEach(si => { songInfo.versions.forEach((si) => {
if (!si?.diffs) return; if (!si?.diffs) return;
si.diffs.forEach(d => { si.diffs.forEach((d) => {
const newNotesCnt = INVALID_NOTES_COUNT_FIXES[hash].find(f => f.type === d?.characteristic && f.diff === d?.difficulty); const newNotesCnt = INVALID_NOTES_COUNT_FIXES[hash].find(
(f) => f.type === d?.characteristic && f.diff === d?.difficulty,
);
if (!newNotesCnt) return; if (!newNotesCnt) return;
d.notes = newNotesCnt.notes; d.notes = newNotesCnt.notes;
}) });
}) });
return songInfo; return songInfo;
} };
const fetchSong = async (songInfo, fetchFunc, forceUpdate = false, cacheOnly = false, errSongId = '', hash = null) => { const fetchSong = async (
songInfo,
fetchFunc,
forceUpdate = false,
cacheOnly = false,
errSongId = "",
hash = null,
) => {
if (!forceUpdate && songInfo) return fixInvalidNotesCount(hash, songInfo); if (!forceUpdate && songInfo) return fixInvalidNotesCount(hash, songInfo);
if (cacheOnly) return null; if (cacheOnly) return null;
@ -97,7 +123,11 @@ export default () => {
let bsSuspension = await getCurrentSuspension(); let bsSuspension = await getCurrentSuspension();
try { try {
if (isSuspended(bsSuspension) || (hash && await isHashUnavailable(hash))) return null; if (
isSuspended(bsSuspension) ||
(hash && (await isHashUnavailable(hash)))
)
return null;
const songInfo = await fetchFunc(); const songInfo = await fetchFunc();
if (!songInfo) { if (!songInfo) {
@ -111,36 +141,78 @@ export default () => {
await setHashNotFound(hash); await setHashNotFound(hash);
} }
if (err instanceof SsrNetworkError && err.message === 'Network error') { if (err instanceof SsrNetworkError && err.message === "Network error") {
try {await prolongSuspension(bsSuspension)} catch {} try {
await prolongSuspension(bsSuspension);
} catch {}
} }
log.warn(`Error fetching BeatSaver song "${errSongId}"`); log.warn(`Error fetching BeatSaver song "${errSongId}"`);
return null; return null;
} }
} };
const byHash = async (hash, forceUpdate = false, cacheOnly = false, signal = null, priority = PRIORITY.FG_LOW) => { const byHash = async (
hash,
forceUpdate = false,
cacheOnly = false,
signal = null,
priority = PRIORITY.FG_LOW,
) => {
hash = hash.toLowerCase(); hash = hash.toLowerCase();
const songInfo = await songsBeatMapsRepository().get(hash); const songInfo = await songsBeatMapsRepository().get(hash);
return fetchSong(songInfo, () => hashApiClient.getProcessed({hash, signal, priority}), forceUpdate, cacheOnly, hash, hash) return fetchSong(
} songInfo,
() => hashApiClient.getProcessed({ hash, signal, priority }),
forceUpdate,
cacheOnly,
hash,
hash,
);
};
const byKey = async (key, forceUpdate = false, cacheOnly = false, signal = null, priority = PRIORITY.FG_LOW) => { const byKey = async (
key,
forceUpdate = false,
cacheOnly = false,
signal = null,
priority = PRIORITY.FG_LOW,
) => {
key = key.toLowerCase(); key = key.toLowerCase();
const songInfo = await songsBeatMapsRepository().getFromIndex('songs-beatmaps-key', key); const songInfo = await songsBeatMapsRepository().getFromIndex(
"songs-beatmaps-key",
key,
);
return fetchSong(songInfo, () => keyApiClient.getProcessed({key, signal, priority}), forceUpdate, cacheOnly, key) return fetchSong(
} songInfo,
() => keyApiClient.getProcessed({ key, signal, priority }),
forceUpdate,
cacheOnly,
key,
);
};
const convertOldBeatSaverToBeatMaps = song => { const convertOldBeatSaverToBeatMaps = (song) => {
let {key, hash, name, metadata: {characteristics}} = song; let {
key,
hash,
name,
metadata: { characteristics },
} = song;
if (!key || !hash || !name || !characteristics || !Array.isArray(characteristics)) return null; if (
!key ||
!hash ||
!name ||
!characteristics ||
!Array.isArray(characteristics)
)
return null;
if (hash.toLowerCase) hash = hash.toLowerCase(); if (hash.toLowerCase) hash = hash.toLowerCase();
@ -148,25 +220,25 @@ export default () => {
if (!ch.name || !ch.difficulties) return diffs; if (!ch.name || !ch.difficulties) return diffs;
const characteristic = ch.name; const characteristic = ch.name;
return diffs.concat( return diffs
Object.entries(ch.difficulties) .concat(
.map(([difficulty, obj]) => { Object.entries(ch.difficulties).map(([difficulty, obj]) => {
if (!obj) return null; if (!obj) return null;
difficulty = capitalize(difficulty); difficulty = capitalize(difficulty);
const seconds = opt(obj, 'length', null); const seconds = opt(obj, "length", null);
const notes = opt(obj, 'notes', null) const notes = opt(obj, "notes", null);
const nps = notes && seconds ? notes / seconds : null; const nps = notes && seconds ? notes / seconds : null;
return { return {
njs: opt(obj, 'njs', null), njs: opt(obj, "njs", null),
offset: opt(obj, 'njsOffset', null), offset: opt(obj, "njsOffset", null),
notes, notes,
bombs: opt(obj, 'bombs', null), bombs: opt(obj, "bombs", null),
obstacles: opt(obj, 'obstacles', null), obstacles: opt(obj, "obstacles", null),
nps, nps,
length: opt(obj, 'duration', null), length: opt(obj, "duration", null),
characteristic, characteristic,
difficulty, difficulty,
events: null, events: null,
@ -182,41 +254,42 @@ export default () => {
}, },
stars: null, stars: null,
}; };
})) }),
.filter(diff => diff) )
.filter((diff) => diff);
}, []); }, []);
return { return {
lastUpdated: dateFromString(opt(song, 'uploaded', new Date())), lastUpdated: dateFromString(opt(song, "uploaded", new Date())),
oldBeatSaverId: opt(song, '_id', null), oldBeatSaverId: opt(song, "_id", null),
id: key, id: key,
hash, hash,
key, key,
name, name,
description: '', description: "",
uploader: { uploader: {
id: null, id: null,
name: opt(song, 'uploader.username', null), name: opt(song, "uploader.username", null),
hash: null, hash: null,
avatar: null avatar: null,
}, },
metadata: { metadata: {
bpm: opt(song, 'metadata.bpm', null), bpm: opt(song, "metadata.bpm", null),
duration: opt(song, 'metadata.duration', null), duration: opt(song, "metadata.duration", null),
songName: opt(song, 'metadata.songName', ''), songName: opt(song, "metadata.songName", ""),
songSubName: opt(song, 'metadata.songSubName', ''), songSubName: opt(song, "metadata.songSubName", ""),
songAuthorName: opt(song, 'metadata.songAuthorName', ''), songAuthorName: opt(song, "metadata.songAuthorName", ""),
levelAuthorName: opt(song, 'metadata.levelAuthorName', '') levelAuthorName: opt(song, "metadata.levelAuthorName", ""),
}, },
stats: { stats: {
plays: opt(song, 'stats.plays', 0), plays: opt(song, "stats.plays", 0),
downloads: opt(song, 'stats.downloads', 0), downloads: opt(song, "stats.downloads", 0),
upvotes: opt(song, 'stats.upVotes', 0), upvotes: opt(song, "stats.upVotes", 0),
downvotes: opt(song, 'stats.downVotes', 0), downvotes: opt(song, "stats.downVotes", 0),
score: null score: null,
}, },
uploaded: opt(song, 'uploaded', null), uploaded: opt(song, "uploaded", null),
automapper: !!opt(song, 'metadata.automapper', false), automapper: !!opt(song, "metadata.automapper", false),
ranked: null, ranked: null,
qualified: null, qualified: null,
versions: [ versions: [
@ -224,20 +297,20 @@ export default () => {
hash, hash,
key, key,
state: "Published", state: "Published",
createdAt: opt(song, 'uploaded', null), createdAt: opt(song, "uploaded", null),
sageScore: null, sageScore: null,
diffs, diffs,
downloadURL: `https://cdn.beatsaver.com/${hash}.zip`, downloadURL: `https://cdn.beatsaver.com/${hash}.zip`,
coverURL: `https://cdn.beatsaver.com/${hash}.jpg`, coverURL: `https://cdn.beatsaver.com/${hash}.jpg`,
previewURL: `https://cdn.beatsaver.com/${hash}.mp3` previewURL: `https://cdn.beatsaver.com/${hash}.mp3`,
} },
] ],
} };
} };
return { return {
byHash, byHash,
byKey, byKey,
convertOldBeatSaverToBeatMaps convertOldBeatSaverToBeatMaps,
} };
} };

@ -1,16 +1,24 @@
import {PRIORITY} from '../network/queues/http-queue'; import { PRIORITY } from "../network/queues/http-queue";
import createPlayerService from './scoresaber/player' import createPlayerService from "./scoresaber/player";
import createScoresService from './scoresaber/scores' import createScoresService from "./scoresaber/scores";
import beatSaviorApiClient from '../network/clients/beatsavior/api'; import beatSaviorApiClient from "../network/clients/beatsavior/api";
import beatSaviorRepository from '../db/repository/beat-savior' import beatSaviorRepository from "../db/repository/beat-savior";
import beatSaviorPlayersRepository from '../db/repository/beat-savior-players' import beatSaviorPlayersRepository from "../db/repository/beat-savior-players";
import {addToDate, DAY, formatDate, HOUR, MINUTE, SECOND, truncateDate} from '../utils/date' import {
import log from '../utils/logger' addToDate,
import {opt} from '../utils/js' DAY,
import makePendingPromisePool from '../utils/pending-promises' formatDate,
import {PLAYER_SCORES_PER_PAGE} from '../utils/scoresaber/consts' HOUR,
import {roundToPrecision} from '../utils/format' MINUTE,
import {serviceFilterFunc} from './utils' SECOND,
truncateDate,
} from "../utils/date";
import log from "../utils/logger";
import { opt } from "../utils/js";
import makePendingPromisePool from "../utils/pending-promises";
import { PLAYER_SCORES_PER_PAGE } from "../utils/scoresaber/consts";
import { roundToPrecision } from "../utils/format";
import { serviceFilterFunc } from "./utils";
const MAIN_PLAYER_REFRESH_INTERVAL = MINUTE * 15; const MAIN_PLAYER_REFRESH_INTERVAL = MINUTE * 15;
const CACHED_PLAYER_REFRESH_INTERVAL = HOUR * 3; const CACHED_PLAYER_REFRESH_INTERVAL = HOUR * 3;
@ -30,52 +38,73 @@ export default () => {
const playerService = createPlayerService(); const playerService = createPlayerService();
const scoresService = createScoresService(); const scoresService = createScoresService();
const getPlayerScores = async playerId => resolvePromiseOrWaitForPending(`getPlayerScores/${playerId}`, () => beatSaviorRepository().getAllFromIndex('beat-savior-playerId', playerId)); const getPlayerScores = async (playerId) =>
resolvePromiseOrWaitForPending(`getPlayerScores/${playerId}`, () =>
beatSaviorRepository().getAllFromIndex("beat-savior-playerId", playerId),
);
const getPlayerScoresWithScoreSaber = async playerId => { const getPlayerScoresWithScoreSaber = async (playerId) => {
const [beatSaviorData, playerScores] = await Promise.all([ const [beatSaviorData, playerScores] = await Promise.all([
getPlayerScores(playerId), getPlayerScores(playerId),
resolvePromiseOrWaitForPending(`getSsPlayerScores/${playerId}`, () => scoresService.getPlayerScoresAsObject( resolvePromiseOrWaitForPending(`getSsPlayerScores/${playerId}`, () =>
scoresService.getPlayerScoresAsObject(
playerId, playerId,
score => score?.leaderboard?.song?.hash?.toLowerCase() ?? null, (score) => score?.leaderboard?.song?.hash?.toLowerCase() ?? null,
true, true,
)), ),
),
]); ]);
return beatSaviorData.map(bsData => { return beatSaviorData.map((bsData) => {
if (!bsData?.hash || !playerScores?.[bsData?.hash?.toLowerCase()]) return bsData; if (!bsData?.hash || !playerScores?.[bsData?.hash?.toLowerCase()])
return bsData;
const ssScore = playerScores[bsData.hash.toLowerCase()].find(ssScore => isScoreMatchingBsData(ssScore, bsData, true)) ?? null; const ssScore =
playerScores[bsData.hash.toLowerCase()].find((ssScore) =>
isScoreMatchingBsData(ssScore, bsData, true),
) ?? null;
return { return {
...bsData, ...bsData,
ssScore ssScore,
} };
}); });
} };
const isScoreMatchingBsData = (score, bsData, exact = true) => { const isScoreMatchingBsData = (score, bsData, exact = true) => {
if (!bsData.hash || !bsData.score || !bsData.timeSet || !opt(bsData, 'stats.won')) return false; if (
!bsData.hash ||
!bsData.score ||
!bsData.timeSet ||
!opt(bsData, "stats.won")
)
return false;
const diff = opt(score, 'leaderboard.diffInfo.diff'); const diff = opt(score, "leaderboard.diffInfo.diff");
const scoreValue = opt(score, 'score.score'); const scoreValue = opt(score, "score.score");
const timeSet = opt(score, 'score.timeSet') const timeSet = opt(score, "score.timeSet");
let hash = opt(score, 'leaderboard.song.hash'); let hash = opt(score, "leaderboard.song.hash");
if (!diff || !score || !timeSet || !hash) return false; if (!diff || !score || !timeSet || !hash) return false;
hash = hash.toLowerCase(); hash = hash.toLowerCase();
if (bsData.hash === hash && bsData.diff === diff) { if (bsData.hash === hash && bsData.diff === diff) {
return !exact || (bsData.score === scoreValue && Math.abs(timeSet.getTime() - bsData.timeSet.getTime()) < MINUTE); return (
!exact ||
(bsData.score === scoreValue &&
Math.abs(timeSet.getTime() - bsData.timeSet.getTime()) < MINUTE)
);
} }
return false; return false;
} };
const getScoresHistogramDefinition = (serviceParams = {sort: 'recent', order: 'desc'}) => { const getScoresHistogramDefinition = (
const sort = serviceParams?.sort ?? 'recent'; serviceParams = { sort: "recent", order: "desc" },
const order = serviceParams?.order ?? 'desc'; ) => {
const sort = serviceParams?.sort ?? "recent";
const order = serviceParams?.order ?? "desc";
let round = 2; let round = 2;
let bucketSize = 1; let bucketSize = 1;
@ -83,57 +112,65 @@ export default () => {
let maxBucketSize = null; let maxBucketSize = null;
let bucketSizeStep = null; let bucketSizeStep = null;
let bucketSizeValues = null; let bucketSizeValues = null;
let type = 'linear'; let type = "linear";
let valFunc = s => s; let valFunc = (s) => s;
let filterFunc = serviceFilterFunc(serviceParams); let filterFunc = serviceFilterFunc(serviceParams);
let histogramFilterFunc = s => s; let histogramFilterFunc = (s) => s;
let roundedValFunc = (s, type = type, precision = bucketSize) => type === 'linear' let roundedValFunc = (s, type = type, precision = bucketSize) =>
type === "linear"
? roundToPrecision(valFunc(s), precision) ? roundToPrecision(valFunc(s), precision)
: truncateDate(valFunc(s), precision); : truncateDate(valFunc(s), precision);
let prefix = ''; let prefix = "";
let prefixLong = ''; let prefixLong = "";
let suffix = ''; let suffix = "";
let suffixLong = ''; let suffixLong = "";
switch (sort) { switch (sort) {
case 'recent': case "recent":
valFunc = s => s?.timeSet; valFunc = (s) => s?.timeSet;
type = 'time'; type = "time";
bucketSize = 'day' bucketSize = "day";
break; break;
case 'acc': case "acc":
valFunc = s => (s?.trackers?.scoreTracker?.rawRatio ?? 0) * 100; valFunc = (s) => (s?.trackers?.scoreTracker?.rawRatio ?? 0) * 100;
histogramFilterFunc = h => h?.x >= HISTOGRAM_ACC_THRESHOLD; histogramFilterFunc = (h) => h?.x >= HISTOGRAM_ACC_THRESHOLD;
type = 'linear'; type = "linear";
bucketSize = 0.25; bucketSize = 0.25;
minBucketSize = 0.05; minBucketSize = 0.05;
maxBucketSize = 10; maxBucketSize = 10;
bucketSizeStep = 0.05; bucketSizeStep = 0.05;
round = 2; round = 2;
suffix = '%'; suffix = "%";
suffixLong = '%'; suffixLong = "%";
break; break;
case 'mistakes': case "mistakes":
valFunc = s => (s?.stats?.miss ?? 0) + (s?.stats?.wallHit ?? 0) + (s?.stats?.bombHit ?? 0); valFunc = (s) =>
histogramFilterFunc = h => h?.x <= HISTOGRAM_MISTAKES_THRESHOLD; (s?.stats?.miss ?? 0) +
type = 'linear'; (s?.stats?.wallHit ?? 0) +
(s?.stats?.bombHit ?? 0);
histogramFilterFunc = (h) => h?.x <= HISTOGRAM_MISTAKES_THRESHOLD;
type = "linear";
bucketSize = 1; bucketSize = 1;
minBucketSize = 1; minBucketSize = 1;
maxBucketSize = 50; maxBucketSize = 50;
bucketSizeStep = 1; bucketSizeStep = 1;
round = 0; round = 0;
suffixLong = ' mistake(s)'; suffixLong = " mistake(s)";
break; break;
} }
return { return {
getValue: valFunc, getValue: valFunc,
getRoundedValue: (bucketSize = bucketSize) => s => roundedValFunc(s, type, bucketSize), getRoundedValue:
(bucketSize = bucketSize) =>
(s) =>
roundedValFunc(s, type, bucketSize),
filter: filterFunc, filter: filterFunc,
histogramFilter: histogramFilterFunc, histogramFilter: histogramFilterFunc,
sort: (a, b) => order === 'asc' ? valFunc(a) - valFunc(b) : valFunc(b) - valFunc(a), sort: (a, b) =>
order === "asc" ? valFunc(a) - valFunc(b) : valFunc(b) - valFunc(a),
type, type,
bucketSize, bucketSize,
minBucketSize, minBucketSize,
@ -145,11 +182,14 @@ export default () => {
prefixLong, prefixLong,
suffix, suffix,
suffixLong, suffixLong,
order order,
} };
} };
const getPlayerScoresPage = async (playerId, serviceParams = {sort: 'recent', order: 'desc', page: 1}) => { const getPlayerScoresPage = async (
playerId,
serviceParams = { sort: "recent", order: "desc", page: 1 },
) => {
let page = serviceParams?.page ?? 1; let page = serviceParams?.page ?? 1;
if (page < 1) page = 1; if (page < 1) page = 1;
@ -157,9 +197,10 @@ export default () => {
if (!playerScores || !playerScores.length) return { total: 0, scores: [] }; if (!playerScores || !playerScores.length) return { total: 0, scores: [] };
const {sort: sortFunc, filter: filterFunc} = getScoresHistogramDefinition(serviceParams); const { sort: sortFunc, filter: filterFunc } =
getScoresHistogramDefinition(serviceParams);
playerScores = playerScores.filter(filterFunc).sort(sortFunc) playerScores = playerScores.filter(filterFunc).sort(sortFunc);
const startIdx = (page - 1) * PLAYER_SCORES_PER_PAGE; const startIdx = (page - 1) * PLAYER_SCORES_PER_PAGE;
@ -169,13 +210,14 @@ export default () => {
total: playerScores.length, total: playerScores.length,
scores: playerScores scores: playerScores
.slice(startIdx, startIdx + PLAYER_SCORES_PER_PAGE) .slice(startIdx, startIdx + PLAYER_SCORES_PER_PAGE)
.map(bs => { .map((bs) => {
const leaderboard = bs.leaderboard; const leaderboard = bs.leaderboard;
if (!leaderboard.leaderboardId) leaderboard.leaderboardId = bs.beatSaviorId; if (!leaderboard.leaderboardId)
leaderboard.leaderboardId = bs.beatSaviorId;
leaderboard.leaderboardId += Math.random(); // ScoresSvelte needs different keys for each scores row leaderboard.leaderboardId += Math.random(); // ScoresSvelte needs different keys for each scores row
const rawScore = opt(bs, 'trackers.scoreTracker.rawScore', 0); const rawScore = opt(bs, "trackers.scoreTracker.rawScore", 0);
const rawRatio = opt(bs, 'trackers.scoreTracker.rawRatio', 0); const rawRatio = opt(bs, "trackers.scoreTracker.rawRatio", 0);
const maxScore = rawRatio & rawScore ? rawScore / rawRatio : 0; const maxScore = rawRatio & rawScore ? rawScore / rawRatio : 0;
return { return {
@ -188,51 +230,76 @@ export default () => {
score: { score: {
acc: rawRatio * 100, acc: rawRatio * 100,
maxScore, maxScore,
mods: opt(bs, 'trackers.scoreTracker.modifiers', null), mods: opt(bs, "trackers.scoreTracker.modifiers", null),
percentage: opt(bs, 'trackers.scoreTracker.rawRatio', 0) * 100, percentage: opt(bs, "trackers.scoreTracker.rawRatio", 0) * 100,
pp: 0, pp: 0,
ppWeighted: 0, ppWeighted: 0,
rank: null, rank: null,
score: opt(bs, 'trackers.scoreTracker.score', 0), score: opt(bs, "trackers.scoreTracker.score", 0),
scoreId: bs.beatSaviorId, scoreId: bs.beatSaviorId,
timeSet: bs.timeSet, timeSet: bs.timeSet,
unmodifiedScore: rawScore, unmodifiedScore: rawScore,
weight: 0, weight: 0,
}, },
timeSet: bs.timeSet, timeSet: bs.timeSet,
}
})
}; };
} }),
};
};
const updateData = async (playerId, data) => { const updateData = async (playerId, data) => {
log.debug(`Updating Beat Savior data for player "${playerId}"...`, 'BeatSaviorService') log.debug(
`Updating Beat Savior data for player "${playerId}"...`,
"BeatSaviorService",
);
await Promise.all(data.map(async d => beatSaviorRepository().set(d))); await Promise.all(data.map(async (d) => beatSaviorRepository().set(d)));
log.debug(`Update player "${playerId}" Beat Savior last refresh date...`, 'BeatSaviorService') log.debug(
`Update player "${playerId}" Beat Savior last refresh date...`,
"BeatSaviorService",
);
await beatSaviorPlayersRepository().set({playerId, lastRefresh: new Date()}) await beatSaviorPlayersRepository().set({
playerId,
lastRefresh: new Date(),
});
log.debug(`Beat Savior data for player "${playerId}" updated.`, 'BeatSaviorService') log.debug(
`Beat Savior data for player "${playerId}" updated.`,
"BeatSaviorService",
);
return data; return data;
} };
const fetchPlayer = async (playerId, priority = PRIORITY.BG_NORMAL) => { const fetchPlayer = async (playerId, priority = PRIORITY.BG_NORMAL) => {
try { try {
log.debug(`Fetching Beat Savior data for player "${playerId}"...`, 'BeatSaviorService'); log.debug(
`Fetching Beat Savior data for player "${playerId}"...`,
"BeatSaviorService",
);
const data = await beatSaviorApiClient.getProcessed({playerId, priority}); const data = await beatSaviorApiClient.getProcessed({
playerId,
priority,
});
if (!data) { if (!data) {
log.debug(`No Beat Savior data for player "${playerId}"`, 'BeatSaviorService') log.debug(
`No Beat Savior data for player "${playerId}"`,
"BeatSaviorService",
);
return null; return null;
} }
// TODO: check if data already exists in DB // TODO: check if data already exists in DB
log.trace(`Beat Savior data for player "${playerId}" fetched`, 'BeatSaviorService', data); log.trace(
`Beat Savior data for player "${playerId}" fetched`,
"BeatSaviorService",
data,
);
return updateData(playerId, data); return updateData(playerId, data);
} catch (err) { } catch (err) {
@ -240,62 +307,121 @@ export default () => {
return null; return null;
} }
} };
const refresh = async (playerId, force = false, priority = PRIORITY.BG_NORMAL, throwErrors = false) => { const refresh = async (
log.trace(`Starting refreshing BeatSavior for player "${playerId}" ${force ? ' (forced)' : ''}...`, 'BeatSaviorService') playerId,
force = false,
priority = PRIORITY.BG_NORMAL,
throwErrors = false,
) => {
log.trace(
`Starting refreshing BeatSavior for player "${playerId}" ${
force ? " (forced)" : ""
}...`,
"BeatSaviorService",
);
try { try {
const player = await playerService.get(playerId); const player = await playerService.get(playerId);
const REFRESH_INTERVAL = playerService.isMainPlayer(playerId) ? MAIN_PLAYER_REFRESH_INTERVAL : (player ? CACHED_PLAYER_REFRESH_INTERVAL : OTHER_PLAYER_REFRESH_INTERVAL); const REFRESH_INTERVAL = playerService.isMainPlayer(playerId)
? MAIN_PLAYER_REFRESH_INTERVAL
: player
? CACHED_PLAYER_REFRESH_INTERVAL
: OTHER_PLAYER_REFRESH_INTERVAL;
const bsPlayerInfo = await beatSaviorPlayersRepository().get(playerId); const bsPlayerInfo = await beatSaviorPlayersRepository().get(playerId);
const nextUpdate = bsPlayerInfo && bsPlayerInfo.lastRefresh ? addToDate(REFRESH_INTERVAL, bsPlayerInfo.lastRefresh) : addToDate(-SECOND); const nextUpdate =
bsPlayerInfo && bsPlayerInfo.lastRefresh
? addToDate(REFRESH_INTERVAL, bsPlayerInfo.lastRefresh)
: addToDate(-SECOND);
if (!force && bsPlayerInfo && nextUpdate > new Date()) { if (!force && bsPlayerInfo && nextUpdate > new Date()) {
log.debug(`Beat Savior data is still fresh, skipping. Next refresh on ${formatDate(nextUpdate)}`, 'BeatSaviorService') log.debug(
`Beat Savior data is still fresh, skipping. Next refresh on ${formatDate(
nextUpdate,
)}`,
"BeatSaviorService",
);
return null; return null;
if (player) { if (player) {
log.trace(`Player "${playerId}" is a cached one, checking recent play date`, 'BeatSaviorService') log.trace(
`Player "${playerId}" is a cached one, checking recent play date`,
"BeatSaviorService",
);
if (player.recentPlay && player.recentPlay < bsPlayerInfo.lastRefresh) { if (
log.debug(`Beat Savior data for player "${playerId}" was refreshed after recent play, skipping`, 'BeatSaviorService') player.recentPlay &&
player.recentPlay < bsPlayerInfo.lastRefresh
) {
log.debug(
`Beat Savior data for player "${playerId}" was refreshed after recent play, skipping`,
"BeatSaviorService",
);
return null; return null;
} }
} }
} }
return resolvePromiseOrWaitForPending(`refresh/${playerId}`, () => fetchPlayer(playerId, priority)); return resolvePromiseOrWaitForPending(`refresh/${playerId}`, () =>
fetchPlayer(playerId, priority),
);
} catch (e) { } catch (e) {
if (throwErrors) throw e; if (throwErrors) throw e;
log.debug(`Beat Savior data refreshing error${e.toString ? `: ${e.toString()}` : ''}`, 'BeatSaviorService', e) log.debug(
`Beat Savior data refreshing error${
e.toString ? `: ${e.toString()}` : ""
}`,
"BeatSaviorService",
e,
);
return null; return null;
} }
} };
const refreshAll = async (force = false, priority = PRIORITY.BG_NORMAL, throwErrors = false) => { const refreshAll = async (
log.trace(`Starting refreshing Beat Savior data for all players${force ? ' (forced)' : ''}...`, 'BeatSaviorService'); force = false,
priority = PRIORITY.BG_NORMAL,
throwErrors = false,
) => {
log.trace(
`Starting refreshing Beat Savior data for all players${
force ? " (forced)" : ""
}...`,
"BeatSaviorService",
);
const allPlayers = await playerService.getAll(); const allPlayers = await playerService.getAll();
if (!allPlayers || !allPlayers.length) { if (!allPlayers || !allPlayers.length) {
log.trace(`No players in DB, skipping.`, 'BeatSaviorService'); log.trace(`No players in DB, skipping.`, "BeatSaviorService");
return null; return null;
} }
const allRefreshed = await Promise.all(allPlayers.map(async player => ({ const allRefreshed = await Promise.all(
allPlayers.map(async (player) => ({
playerId: player.playerId, playerId: player.playerId,
beatSavior: await refresh(player.playerId, force, priority, throwErrors), beatSavior: await refresh(
}))); player.playerId,
force,
priority,
throwErrors,
),
})),
);
log.trace(`Beat Savior data for all players refreshed.`, 'BeatSaviorService', allRefreshed); log.trace(
`Beat Savior data for all players refreshed.`,
"BeatSaviorService",
allRefreshed,
);
return allRefreshed; return allRefreshed;
} };
const get = async (playerId, score) => { const get = async (playerId, score) => {
if (score && score.beatSavior) return score.beatSavior; if (score && score.beatSavior) return score.beatSavior;
@ -303,12 +429,18 @@ export default () => {
const playerBsData = await getPlayerScores(playerId); const playerBsData = await getPlayerScores(playerId);
if (!playerBsData || !playerBsData.length) return null; if (!playerBsData || !playerBsData.length) return null;
const bsData = playerBsData.find(bsData => isScoreMatchingBsData(score, bsData, true)); const bsData = playerBsData.find((bsData) =>
isScoreMatchingBsData(score, bsData, true),
);
return bsData ? bsData : null; return bsData ? bsData : null;
} };
const isDataForPlayerAvailable = async playerId => await beatSaviorRepository().getFromIndex('beat-savior-playerId', playerId) !== undefined; const isDataForPlayerAvailable = async (playerId) =>
(await beatSaviorRepository().getFromIndex(
"beat-savior-playerId",
playerId,
)) !== undefined;
const destroyService = () => { const destroyService = () => {
serviceCreationCount--; serviceCreationCount--;
@ -319,7 +451,7 @@ export default () => {
service = null; service = null;
} }
} };
service = { service = {
fetchPlayer, fetchPlayer,
@ -332,7 +464,7 @@ export default () => {
isDataForPlayerAvailable, isDataForPlayerAvailable,
getScoresHistogramDefinition, getScoresHistogramDefinition,
destroyService, destroyService,
} };
return service; return service;
} };

@ -1,7 +1,7 @@
import keyValueRepository from '../db/repository/key-value'; import keyValueRepository from "../db/repository/key-value";
import {opt} from '../utils/js' import { opt } from "../utils/js";
const STORE_CONFIG_KEY = 'config'; const STORE_CONFIG_KEY = "config";
let service = null; let service = null;
@ -9,22 +9,23 @@ export default () => {
if (service) return service; if (service) return service;
const get = async () => keyValueRepository().get(STORE_CONFIG_KEY); const get = async () => keyValueRepository().get(STORE_CONFIG_KEY);
const set = async config => keyValueRepository().set(config, STORE_CONFIG_KEY); const set = async (config) =>
keyValueRepository().set(config, STORE_CONFIG_KEY);
const getMainPlayerId = async () => { const getMainPlayerId = async () => {
const config = await get(); const config = await get();
return opt(config, 'users.main'); return opt(config, "users.main");
} };
const destroyService = () => {} const destroyService = () => {};
service = { service = {
get, get,
set, set,
getMainPlayerId, getMainPlayerId,
destroyService, destroyService,
} };
return service; return service;
} };

@ -1,14 +1,14 @@
import leaderboardPageClient from '../../network/clients/scoresaber/leaderboard/page-leaderboard' import leaderboardPageClient from "../../network/clients/scoresaber/leaderboard/page-leaderboard";
import accSaberLeaderboardApiClient from '../../network/clients/accsaber/api-leaderboard' import accSaberLeaderboardApiClient from "../../network/clients/accsaber/api-leaderboard";
import makePendingPromisePool from '../../utils/pending-promises' import makePendingPromisePool from "../../utils/pending-promises";
import createPlayersService from '../../services/scoresaber/player' import createPlayersService from "../../services/scoresaber/player";
import createScoresService from '../../services/scoresaber/scores' import createScoresService from "../../services/scoresaber/scores";
import {PRIORITY} from '../../network/queues/http-queue' import { PRIORITY } from "../../network/queues/http-queue";
import {LEADERBOARD_SCORES_PER_PAGE} from '../../utils/scoresaber/consts' import { LEADERBOARD_SCORES_PER_PAGE } from "../../utils/scoresaber/consts";
import {LEADERBOARD_SCORES_PER_PAGE as ACCSABER_LEADERBOARD_SCORES_PER_PAGE} from '../../utils/accsaber/consts' import { LEADERBOARD_SCORES_PER_PAGE as ACCSABER_LEADERBOARD_SCORES_PER_PAGE } from "../../utils/accsaber/consts";
import {formatDateRelative, MINUTE} from '../../utils/date' import { formatDateRelative, MINUTE } from "../../utils/date";
import {convertArrayToObjectByKey, opt} from '../../utils/js' import { convertArrayToObjectByKey, opt } from "../../utils/js";
import eventBus from '../../utils/broadcast-channel-pubsub' import eventBus from "../../utils/broadcast-channel-pubsub";
const ACCSABER_LEADERBOARD_NETWORK_TTL = MINUTE * 5; const ACCSABER_LEADERBOARD_NETWORK_TTL = MINUTE * 5;
@ -20,79 +20,118 @@ export default () => {
const scoresService = createScoresService(); const scoresService = createScoresService();
let friendsPromise = Promise.resolve([]); let friendsPromise = Promise.resolve([]);
const refreshFriends = async () => friendsPromise = playersService.getAll(); const refreshFriends = async () => (friendsPromise = playersService.getAll());
eventBus.on('player-profile-removed', playerId => refreshFriends()); eventBus.on("player-profile-removed", (playerId) => refreshFriends());
eventBus.on('player-profile-added', player => refreshFriends()); eventBus.on("player-profile-added", (player) => refreshFriends());
eventBus.on('player-profile-changed', player => refreshFriends()); eventBus.on("player-profile-changed", (player) => refreshFriends());
refreshFriends().then(_ => {}); refreshFriends().then((_) => {});
const resolvePromiseOrWaitForPending = makePendingPromisePool(); const resolvePromiseOrWaitForPending = makePendingPromisePool();
const fetchPage = async (leaderboardId, page = 1, priority = PRIORITY.FG_LOW, signal = null, force = false) => resolvePromiseOrWaitForPending( const fetchPage = async (
leaderboardId,
page = 1,
priority = PRIORITY.FG_LOW,
signal = null,
force = false,
) =>
resolvePromiseOrWaitForPending(
`pageClient/leaderboard/${leaderboardId}/${page}`, `pageClient/leaderboard/${leaderboardId}/${page}`,
() => leaderboardPageClient.getProcessed({ () =>
leaderboardPageClient.getProcessed({
leaderboardId, leaderboardId,
page, page,
signal, signal,
priority, priority,
cacheTtl: MINUTE, cacheTtl: MINUTE,
})); }),
);
const fetchAccSaberPage = async (leaderboardId, page = 1, priority = PRIORITY.FG_LOW, signal = null, force = false) => { const fetchAccSaberPage = async (
leaderboardId,
page = 1,
priority = PRIORITY.FG_LOW,
signal = null,
force = false,
) => {
if (page < 1) page = 1; if (page < 1) page = 1;
const data = await resolvePromiseOrWaitForPending( const data = await resolvePromiseOrWaitForPending(
`accSaberApiClient/leaderboard/${leaderboardId}/${page}`, `accSaberApiClient/leaderboard/${leaderboardId}/${page}`,
() => accSaberLeaderboardApiClient.getProcessed({ () =>
accSaberLeaderboardApiClient.getProcessed({
leaderboardId, leaderboardId,
page, page,
signal, signal,
priority, priority,
cacheTtl: ACCSABER_LEADERBOARD_NETWORK_TTL, cacheTtl: ACCSABER_LEADERBOARD_NETWORK_TTL,
})); }),
);
if (!data || !data.scores) return data if (!data || !data.scores) return data;
const startIdx = (page - 1) * ACCSABER_LEADERBOARD_SCORES_PER_PAGE; const startIdx = (page - 1) * ACCSABER_LEADERBOARD_SCORES_PER_PAGE;
if (data.scores.length < startIdx + 1) return data; if (data.scores.length < startIdx + 1) return data;
return { return {
...data, ...data,
scores: data.scores scores: data.scores.slice(
.slice(startIdx, startIdx + ACCSABER_LEADERBOARD_SCORES_PER_PAGE) startIdx,
} startIdx + ACCSABER_LEADERBOARD_SCORES_PER_PAGE,
} ),
};
};
const getFriendsLeaderboard = async (leaderboardId, priority = PRIORITY.FG_LOW, signal = null) => { const getFriendsLeaderboard = async (
const leaderboard = await resolvePromiseOrWaitForPending(`pageClient/leaderboard/${leaderboardId}/1`, () => leaderboardPageClient.getProcessed({leaderboardId, page: 1, signal, priority, cacheTtl: MINUTE})); leaderboardId,
priority = PRIORITY.FG_LOW,
signal = null,
) => {
const leaderboard = await resolvePromiseOrWaitForPending(
`pageClient/leaderboard/${leaderboardId}/1`,
() =>
leaderboardPageClient.getProcessed({
leaderboardId,
page: 1,
signal,
priority,
cacheTtl: MINUTE,
}),
);
const friends = convertArrayToObjectByKey(await friendsPromise, 'playerId'); const friends = convertArrayToObjectByKey(await friendsPromise, "playerId");
const scores = (await scoresService.getLeaderboardScores(leaderboardId)) const scores = (await scoresService.getLeaderboardScores(leaderboardId))
.map(score => { .map((score) => {
if (!score || !score.playerId || !friends[score.playerId]) return null; if (!score || !score.playerId || !friends[score.playerId]) return null;
const player = friends[score.playerId]; const player = friends[score.playerId];
return { return {
player: {playerId: player.playerId, name: player.name, playerInfo: {...player.playerInfo}}, player: {
playerId: player.playerId,
name: player.name,
playerInfo: { ...player.playerInfo },
},
score: { ...score.score }, score: { ...score.score },
} };
}) })
.filter(s => s) .filter((s) => s)
.sort((a, b) => opt(b, 'score.score', 0) - opt(a, 'score.score', 0)) .sort((a, b) => opt(b, "score.score", 0) - opt(a, "score.score", 0))
.map((score, idx) => ({ .map((score, idx) => ({
player: score.player, player: score.player,
score: {...score.score, rank: idx + 1, timeSetString: formatDateRelative(score.score.timeSet)}, score: {
})) ...score.score,
; rank: idx + 1,
timeSetString: formatDateRelative(score.score.timeSet),
},
}));
return { ...leaderboard, scores, pageQty: 1, totalItems: scores.length }; return { ...leaderboard, scores, pageQty: 1, totalItems: scores.length };
} };
const destroyService = () => { const destroyService = () => {
service = null; service = null;
} };
service = { service = {
fetchPage, fetchPage,
@ -100,7 +139,7 @@ export default () => {
getFriendsLeaderboard, getFriendsLeaderboard,
LEADERBOARD_SCORES_PER_PAGE, LEADERBOARD_SCORES_PER_PAGE,
destroyService, destroyService,
} };
return service; return service;
} };

@ -1,12 +1,12 @@
import eventBus from '../../utils/broadcast-channel-pubsub' import eventBus from "../../utils/broadcast-channel-pubsub";
import {configStore} from '../../stores/config' import { configStore } from "../../stores/config";
import playerApiClient from '../../network/clients/scoresaber/player/api' import playerApiClient from "../../network/clients/scoresaber/player/api";
import playerFindApiClient from '../../network/clients/scoresaber/players/api-player-find' import playerFindApiClient from "../../network/clients/scoresaber/players/api-player-find";
import playerPageClient from '../../network/clients/scoresaber/player/page' import playerPageClient from "../../network/clients/scoresaber/player/page";
import {PRIORITY} from '../../network/queues/http-queue' import { PRIORITY } from "../../network/queues/http-queue";
import playersRepository from '../../db/repository/players' import playersRepository from "../../db/repository/players";
import playersHistoryRepository from '../../db/repository/players-history' import playersHistoryRepository from "../../db/repository/players-history";
import log from '../../utils/logger' import log from "../../utils/logger";
import { import {
addToDate, addToDate,
formatDate, formatDate,
@ -14,12 +14,12 @@ import {
SECOND, SECOND,
toSsMidnight, toSsMidnight,
truncateDate, truncateDate,
} from '../../utils/date' } from "../../utils/date";
import {opt} from '../../utils/js' import { opt } from "../../utils/js";
import {db} from '../../db/db' import { db } from "../../db/db";
import makePendingPromisePool from '../../utils/pending-promises' import makePendingPromisePool from "../../utils/pending-promises";
import {worker} from '../../utils/worker-wrappers' import { worker } from "../../utils/worker-wrappers";
import {getServicePlayerGain} from '../utils' import { getServicePlayerGain } from "../utils";
const MAIN_PLAYER_REFRESH_INTERVAL = MINUTE * 3; const MAIN_PLAYER_REFRESH_INTERVAL = MINUTE * 3;
const PLAYER_REFRESH_INTERVAL = MINUTE * 20; const PLAYER_REFRESH_INTERVAL = MINUTE * 20;
@ -34,91 +34,128 @@ export default () => {
const resolvePromiseOrWaitForPending = makePendingPromisePool(); const resolvePromiseOrWaitForPending = makePendingPromisePool();
const configStoreUnsubscribe = configStore.subscribe(config => { const configStoreUnsubscribe = configStore.subscribe((config) => {
const newMainPlayerId = opt(config, 'users.main') const newMainPlayerId = opt(config, "users.main");
if (mainPlayerId !== newMainPlayerId) { if (mainPlayerId !== newMainPlayerId) {
mainPlayerId = newMainPlayerId; mainPlayerId = newMainPlayerId;
log.debug(`Main player changed to ${mainPlayerId}`, 'PlayerService') log.debug(`Main player changed to ${mainPlayerId}`, "PlayerService");
} }
}) });
const isMainPlayer = playerId => mainPlayerId && playerId === mainPlayerId; const isMainPlayer = (playerId) => mainPlayerId && playerId === mainPlayerId;
const getAll = async (force = false) => playersRepository().getAll(force); const getAll = async (force = false) => playersRepository().getAll(force);
// TODO: just for now // TODO: just for now
const getFriends = async () => (await getAll()).filter(player => player && player.playerId && !isPlayerMain(player.playerId)).map(p => p.playerId); const getFriends = async () =>
(await getAll())
.filter(
(player) => player && player.playerId && !isPlayerMain(player.playerId),
)
.map((p) => p.playerId);
const getAllActive = async () => { const getAllActive = async () => {
const players = await getAll(); const players = await getAll();
if (!players) return []; if (!players) return [];
return players.filter(player => player && player.playerInfo && !player.playerInfo.inactive && !player.playerInfo.banned); return players.filter(
} (player) =>
player &&
player.playerInfo &&
!player.playerInfo.inactive &&
!player.playerInfo.banned,
);
};
const getPlayer = async playerId => await playersRepository().get(playerId); const getPlayer = async (playerId) => await playersRepository().get(playerId);
const removePlayer = async (playerId, purgeScores = false) => { const removePlayer = async (playerId, purgeScores = false) => {
await playersRepository().delete(playerId); await playersRepository().delete(playerId);
// TODO: purge scores if requested // TODO: purge scores if requested
eventBus.publish('player-profile-removed', playerId); eventBus.publish("player-profile-removed", playerId);
} };
const addPlayer = async (playerId, priority = PRIORITY.FG_LOW) => { const addPlayer = async (playerId, priority = PRIORITY.FG_LOW) => {
log.trace(`Starting to add a player "${playerId}"...`, 'PlayerService'); log.trace(`Starting to add a player "${playerId}"...`, "PlayerService");
const player = await refresh(playerId, true, priority, false, true); const player = await refresh(playerId, true, priority, false, true);
if (!player) { if (!player) {
log.warn(`Can not add player "${playerId}"`, 'PlayerService'); log.warn(`Can not add player "${playerId}"`, "PlayerService");
return null; return null;
} }
eventBus.publish('player-profile-added', player); eventBus.publish("player-profile-added", player);
eventBus.publish('player-profile-changed', player); eventBus.publish("player-profile-changed", player);
log.trace(`Player "${playerId}" added.`, 'PlayerService') log.trace(`Player "${playerId}" added.`, "PlayerService");
return player; return player;
} };
const setPlayer = async (player) => { const setPlayer = async (player) => {
await playersRepository().set(player); await playersRepository().set(player);
eventBus.publish('player-profile-changed', player); eventBus.publish("player-profile-changed", player);
return player; return player;
} };
const updatePlayer = async (player, waitForSaving = true, forceAdd = false) => { const updatePlayer = async (
player,
waitForSaving = true,
forceAdd = false,
) => {
if (!player || !player.playerId) { if (!player || !player.playerId) {
log.warn(`Can not update player, empty playerId`, 'PlayerService', player) log.warn(
`Can not update player, empty playerId`,
"PlayerService",
player,
);
} }
const dbPlayer = await getPlayer(player.playerId); const dbPlayer = await getPlayer(player.playerId);
if (!dbPlayer && !forceAdd) return player; if (!dbPlayer && !forceAdd) return player;
const finalPlayer = {...dbPlayer, ...player} const finalPlayer = { ...dbPlayer, ...player };
if (!waitForSaving) { if (!waitForSaving) {
setPlayer(finalPlayer).then(_ => _) setPlayer(finalPlayer).then((_) => _);
return finalPlayer; return finalPlayer;
} }
return await setPlayer(finalPlayer); return await setPlayer(finalPlayer);
} };
const getPlayerHistory = async playerId => resolvePromiseOrWaitForPending(`playerHistory/${playerId}`, () => playersHistoryRepository().getAllFromIndex('players-history-playerId', playerId)) const getPlayerHistory = async (playerId) =>
resolvePromiseOrWaitForPending(`playerHistory/${playerId}`, () =>
playersHistoryRepository().getAllFromIndex(
"players-history-playerId",
playerId,
),
);
const getPlayerGain = (playerHistory, daysAgo = 1, maxDaysAgo = 7) => getServicePlayerGain(playerHistory, toSsMidnight, 'ssDate', daysAgo, maxDaysAgo); const getPlayerGain = (playerHistory, daysAgo = 1, maxDaysAgo = 7) =>
getServicePlayerGain(
playerHistory,
toSsMidnight,
"ssDate",
daysAgo,
maxDaysAgo,
);
const updatePlayerHistory = async player => { const updatePlayerHistory = async (player) => {
if (!player) return null; if (!player) return null;
const {playerId, profileLastUpdated, playerInfo: {banned, countries, inactive, pp, rank}, scoreStats} = player; const {
playerId,
profileLastUpdated,
playerInfo: { banned, countries, inactive, pp, rank },
scoreStats,
} = player;
if (!playerId) return null; if (!playerId) return null;
@ -130,8 +167,9 @@ export default () => {
const playerIdLocalTimestamp = `${playerId}_${localDate.getTime()}`; const playerIdLocalTimestamp = `${playerId}_${localDate.getTime()}`;
const playerIdSsTimestamp = `${playerId}_${ssDate.getTime()}`; const playerIdSsTimestamp = `${playerId}_${ssDate.getTime()}`;
return playersHistoryRepository().getFromIndex('players-history-playerIdSsTimestamp', playerIdSsTimestamp) return playersHistoryRepository()
.then(async ph => { .getFromIndex("players-history-playerIdSsTimestamp", playerIdSsTimestamp)
.then(async (ph) => {
if (ph && ph._idbId) { if (ph && ph._idbId) {
await playersHistoryRepository().delete(ph._idbId); await playersHistoryRepository().delete(ph._idbId);
@ -142,53 +180,74 @@ export default () => {
return null; return null;
}) })
.then(async previous => { .then(async (previous) => {
let accStats = {}; let accStats = {};
if (worker) { if (worker) {
const stats = await worker.calcPlayerStats(playerId); const stats = await worker.calcPlayerStats(playerId);
const ppBoundary = await worker.calcPpBoundary(playerId) ?? null; const ppBoundary = (await worker.calcPpBoundary(playerId)) ?? null;
const { badges, totalScore, playCount, ...playerStats } = stats ?? {}; const { badges, totalScore, playCount, ...playerStats } = stats ?? {};
accStats = {...playerStats} accStats = { ...playerStats };
if (ppBoundary) accStats.ppBoundary = ppBoundary; if (ppBoundary) accStats.ppBoundary = ppBoundary;
if (badges?.length) accStats.accBadges = badges.reduce((cum, b) => ({...cum, [b.label]: b.value}), {}); if (badges?.length)
accStats.accBadges = badges.reduce(
(cum, b) => ({ ...cum, [b.label]: b.value }),
{},
);
} }
return playersHistoryRepository().set({ return playersHistoryRepository().set({
...previous, ...previous,
...accStats, ...accStats,
playerId, banned, countries, inactive, pp, rank, ...scoreStats, playerId,
localDate, ssDate, banned,
countries,
inactive,
pp,
rank,
...scoreStats,
localDate,
ssDate,
playerIdLocalTimestamp, playerIdLocalTimestamp,
playerIdSsTimestamp, playerIdSsTimestamp,
});
}) })
}) .catch((err) => {}); // swallow error
.catch(err => {}) // swallow error };
}
const isPlayerMain = playerId => playerId === mainPlayerId; const isPlayerMain = (playerId) => playerId === mainPlayerId;
const getProfileFreshnessDate = (player, refreshInterval = null) => { const getProfileFreshnessDate = (player, refreshInterval = null) => {
const lastUpdated = player && player.profileLastUpdated ? player.profileLastUpdated : null; const lastUpdated =
player && player.profileLastUpdated ? player.profileLastUpdated : null;
if (!lastUpdated) return addToDate(-SECOND); if (!lastUpdated) return addToDate(-SECOND);
const REFRESH_INTERVAL = refreshInterval ? refreshInterval : (isPlayerMain(player.playerId) ? MAIN_PLAYER_REFRESH_INTERVAL : PLAYER_REFRESH_INTERVAL); const REFRESH_INTERVAL = refreshInterval
? refreshInterval
: isPlayerMain(player.playerId)
? MAIN_PLAYER_REFRESH_INTERVAL
: PLAYER_REFRESH_INTERVAL;
return addToDate(REFRESH_INTERVAL, lastUpdated); return addToDate(REFRESH_INTERVAL, lastUpdated);
} };
const isProfileFresh = (player, refreshInterval = null) => getProfileFreshnessDate(player, refreshInterval) > new Date(); const isProfileFresh = (player, refreshInterval = null) =>
getProfileFreshnessDate(player, refreshInterval) > new Date();
const updatePlayerRecentPlay = async (playerId, recentPlay, recentPlayLastUpdated = new Date()) => { const updatePlayerRecentPlay = async (
playerId,
recentPlay,
recentPlayLastUpdated = new Date(),
) => {
let player; let player;
try { try {
await db.runInTransaction(['players'], async tx => { await db.runInTransaction(["players"], async (tx) => {
const playersStore = tx.objectStore('players') const playersStore = tx.objectStore("players");
player = await playersStore.get(playerId); player = await playersStore.get(playerId);
if (player) { if (player) {
player.recentPlayLastUpdated = recentPlayLastUpdated; player.recentPlayLastUpdated = recentPlayLastUpdated;
@ -200,61 +259,135 @@ export default () => {
if (player) { if (player) {
playersRepository().addToCache([player]); playersRepository().addToCache([player]);
eventBus.publish('player-profile-changed', player); eventBus.publish("player-profile-changed", player);
eventBus.publish('player-recent-play-updated', {playerId, player, recentPlay, recentPlayLastUpdated}); eventBus.publish("player-recent-play-updated", {
playerId,
player,
recentPlay,
recentPlayLastUpdated,
});
} }
}
catch(err) {
// swallow error
}
}
const fetchPlayerAndUpdateRecentPlay = async playerId => {
try {
const player = await resolvePromiseOrWaitForPending(`pageClient/${playerId}`, () =>playerPageClient.getProcessed({playerId}));
const recentPlay = opt(player, 'playerInfo.recentPlay');
const recentPlayLastUpdated = opt(player, 'playerInfo.recentPlayLastUpdated');
if (!recentPlay || !recentPlayLastUpdated) return null;
return updatePlayerRecentPlay(playerId, recentPlay, recentPlayLastUpdated);
} catch (err) { } catch (err) {
// swallow error // swallow error
} }
};
const fetchPlayerAndUpdateRecentPlay = async (playerId) => {
try {
const player = await resolvePromiseOrWaitForPending(
`pageClient/${playerId}`,
() => playerPageClient.getProcessed({ playerId }),
);
const recentPlay = opt(player, "playerInfo.recentPlay");
const recentPlayLastUpdated = opt(
player,
"playerInfo.recentPlayLastUpdated",
);
if (!recentPlay || !recentPlayLastUpdated) return null;
return updatePlayerRecentPlay(
playerId,
recentPlay,
recentPlayLastUpdated,
);
} catch (err) {
// swallow error
} }
};
const isResponseCached = response => playerApiClient.isResponseCached(response); const isResponseCached = (response) =>
const getDataFromResponse = response => playerApiClient.getDataFromResponse(response); playerApiClient.isResponseCached(response);
const getDataFromResponse = (response) =>
playerApiClient.getDataFromResponse(response);
const fetchPlayer = async (playerId, priority = PRIORITY.FG_LOW, {fullResponse = false, ...options} = {}) => resolvePromiseOrWaitForPending(`apiClient/${playerId}/${fullResponse}`, () => playerApiClient.getProcessed({...options, playerId, priority, fullResponse})); const fetchPlayer = async (
playerId,
priority = PRIORITY.FG_LOW,
{ fullResponse = false, ...options } = {},
) =>
resolvePromiseOrWaitForPending(
`apiClient/${playerId}/${fullResponse}`,
() =>
playerApiClient.getProcessed({
...options,
playerId,
priority,
fullResponse,
}),
);
const findPlayer = async (query, priority = PRIORITY.FG_LOW, {fullResponse = false, ...options} = {}) => resolvePromiseOrWaitForPending(`apiClient/find/${query}/${fullResponse}`, () => playerFindApiClient.getProcessed({...options, query, priority, fullResponse})); const findPlayer = async (
query,
priority = PRIORITY.FG_LOW,
{ fullResponse = false, ...options } = {},
) =>
resolvePromiseOrWaitForPending(
`apiClient/find/${query}/${fullResponse}`,
() =>
playerFindApiClient.getProcessed({
...options,
query,
priority,
fullResponse,
}),
);
const fetchPlayerOrGetFromCache = async (playerId, refreshInterval = MINUTE, priority = PRIORITY.FG_LOW, signal = null, force = false) => { const fetchPlayerOrGetFromCache = async (
playerId,
refreshInterval = MINUTE,
priority = PRIORITY.FG_LOW,
signal = null,
force = false,
) => {
const player = await getPlayer(playerId); const player = await getPlayer(playerId);
if (!player || !isProfileFresh(player, refreshInterval)) { if (!player || !isProfileFresh(player, refreshInterval)) {
const fetchedPlayerResponse = await fetchPlayer(playerId, priority, {signal, cacheTtl: MINUTE, maxAge: force ? 0 : refreshInterval, fullResponse: true}); const fetchedPlayerResponse = await fetchPlayer(playerId, priority, {
if (isResponseCached(fetchedPlayerResponse)) return getDataFromResponse(fetchedPlayerResponse); signal,
cacheTtl: MINUTE,
maxAge: force ? 0 : refreshInterval,
fullResponse: true,
});
if (isResponseCached(fetchedPlayerResponse))
return getDataFromResponse(fetchedPlayerResponse);
return updatePlayer({...player, ...getDataFromResponse(fetchedPlayerResponse), profileLastUpdated: new Date()}, false) return updatePlayer(
.then(player => { {
...player,
...getDataFromResponse(fetchedPlayerResponse),
profileLastUpdated: new Date(),
},
false,
).then((player) => {
fetchPlayerAndUpdateRecentPlay(player.playerId); fetchPlayerAndUpdateRecentPlay(player.playerId);
updatePlayerHistory(player); updatePlayerHistory(player);
return player; return player;
}) });
} }
return player; return player;
} };
const refresh = async (playerId, force = false, priority = PRIORITY.BG_NORMAL, throwErrors = false, addIfNotExists = false) => { const refresh = async (
log.trace(`Starting refreshing player "${playerId}" ${force ? ' (forced)' : ''}...`, 'PlayerService') playerId,
force = false,
priority = PRIORITY.BG_NORMAL,
throwErrors = false,
addIfNotExists = false,
) => {
log.trace(
`Starting refreshing player "${playerId}" ${force ? " (forced)" : ""}...`,
"PlayerService",
);
if (!playerId) { if (!playerId) {
log.warn(`Can not refresh player if an empty playerId is given`, 'PlayerService'); log.warn(
`Can not refresh player if an empty playerId is given`,
"PlayerService",
);
return null; return null;
} }
@ -262,66 +395,101 @@ export default () => {
try { try {
let player = await getPlayer(playerId); let player = await getPlayer(playerId);
if (!player && !addIfNotExists) { if (!player && !addIfNotExists) {
log.debug(`Profile is not added to DB, skipping.`, 'PlayerService') log.debug(`Profile is not added to DB, skipping.`, "PlayerService");
return null; return null;
} }
log.trace(`Player fetched from DB`, 'PlayerService', player); log.trace(`Player fetched from DB`, "PlayerService", player);
if (!force) { if (!force) {
const profileFreshnessDate = getProfileFreshnessDate(player); const profileFreshnessDate = getProfileFreshnessDate(player);
if (profileFreshnessDate > new Date()) { if (profileFreshnessDate > new Date()) {
log.debug(
log.debug(`Profile is still fresh, skipping. Next refresh on ${formatDate(profileFreshnessDate)}`, 'PlayerService') `Profile is still fresh, skipping. Next refresh on ${formatDate(
profileFreshnessDate,
)}`,
"PlayerService",
);
return player; return player;
} }
} }
log.trace(`Fetching player ${playerId} from ScoreSaber...`, 'PlayerService') log.trace(
`Fetching player ${playerId} from ScoreSaber...`,
"PlayerService",
);
const fetchedPlayer = await fetchPlayer(playerId, priority); const fetchedPlayer = await fetchPlayer(playerId, priority);
if (!fetchedPlayer || !fetchedPlayer.playerId || !fetchedPlayer.name || !fetchedPlayer.playerInfo || !fetchedPlayer.scoreStats) { if (
log.warn(`ScoreSaber returned empty info for player ${playerId}`, 'PlayerService') !fetchedPlayer ||
!fetchedPlayer.playerId ||
!fetchedPlayer.name ||
!fetchedPlayer.playerInfo ||
!fetchedPlayer.scoreStats
) {
log.warn(
`ScoreSaber returned empty info for player ${playerId}`,
"PlayerService",
);
return null; return null;
} }
log.trace(`Player fetched`, 'PlayerService', fetchedPlayer); log.trace(`Player fetched`, "PlayerService", fetchedPlayer);
player = await updatePlayer({...fetchedPlayer, profileLastUpdated: new Date()}, true, addIfNotExists); player = await updatePlayer(
{ ...fetchedPlayer, profileLastUpdated: new Date() },
true,
addIfNotExists,
);
updatePlayerHistory(player).then(_ => _); updatePlayerHistory(player).then((_) => _);
log.debug(`Player refreshed.`, 'PlayerService', player); log.debug(`Player refreshed.`, "PlayerService", player);
return player; return player;
} catch (e) { } catch (e) {
if (throwErrors) throw e; if (throwErrors) throw e;
log.debug(`Player refreshing error${e.toString ? `: ${e.toString()}` : ''}`, 'PlayerService', e) log.debug(
`Player refreshing error${e.toString ? `: ${e.toString()}` : ""}`,
"PlayerService",
e,
);
return null; return null;
} }
} };
const refreshAll = async (force = false, priority = PRIORITY.BG_NORMAL, throwErrors = false) => { const refreshAll = async (
log.trace(`Starting refreshing all players${force ? ' (forced)' : ''}...`, 'PlayerService'); force = false,
priority = PRIORITY.BG_NORMAL,
throwErrors = false,
) => {
log.trace(
`Starting refreshing all players${force ? " (forced)" : ""}...`,
"PlayerService",
);
const allPlayers = await getAll(); const allPlayers = await getAll();
if (!allPlayers || !allPlayers.length) { if (!allPlayers || !allPlayers.length) {
log.trace(`No players in DB, skipping.`, 'PlayerService'); log.trace(`No players in DB, skipping.`, "PlayerService");
return null; return null;
} }
const allRefreshed = await Promise.all(allPlayers.map(player => refresh(player.playerId, force, priority, throwErrors))); const allRefreshed = await Promise.all(
allPlayers.map((player) =>
refresh(player.playerId, force, priority, throwErrors),
),
);
log.trace(`All players refreshed.`, 'PlayerService', allRefreshed); log.trace(`All players refreshed.`, "PlayerService", allRefreshed);
return allRefreshed; return allRefreshed;
} };
const destroyService = () => { const destroyService = () => {
serviceCreationCount--; serviceCreationCount--;
@ -331,7 +499,7 @@ export default () => {
service = null; service = null;
} }
} };
service = { service = {
isMainPlayer, isMainPlayer,
@ -356,7 +524,7 @@ export default () => {
destroyService, destroyService,
isResponseCached, isResponseCached,
getDataFromResponse, getDataFromResponse,
} };
return service; return service;
} };

@ -1,6 +1,6 @@
import createScoresService from './scores' import createScoresService from "./scores";
import makePendingPromisePool from '../../utils/pending-promises' import makePendingPromisePool from "../../utils/pending-promises";
import {getTotalPpFromSortedPps} from '../../utils/scoresaber/pp' import { getTotalPpFromSortedPps } from "../../utils/scoresaber/pp";
let service = null; let service = null;
let serviceCreationCount = 0; let serviceCreationCount = 0;
@ -12,18 +12,22 @@ export default () => {
const resolvePromiseOrWaitForPending = makePendingPromisePool(); const resolvePromiseOrWaitForPending = makePendingPromisePool();
const getTotalPp = scores => scores && Array.isArray(scores) const getTotalPp = (scores) =>
scores && Array.isArray(scores)
? getTotalPpFromSortedPps( ? getTotalPpFromSortedPps(
scores scores
.filter(s => s.pp > 0) .filter((s) => s.pp > 0)
.map(s => s.pp) .map((s) => s.pp)
.sort((a, b) => b - a), .sort((a, b) => b - a),
) )
: null; : null;
const getTotalPlayerPp = async (playerId, modifiedScores = {}) => getTotalPp( const getTotalPlayerPp = async (playerId, modifiedScores = {}) =>
getTotalPp(
Object.values({ Object.values({
...(await resolvePromiseOrWaitForPending(`scores/${playerId}`, () => scoresService.getPlayerScoresAsObject(playerId))), ...(await resolvePromiseOrWaitForPending(`scores/${playerId}`, () =>
scoresService.getPlayerScoresAsObject(playerId),
)),
...modifiedScores, ...modifiedScores,
}), }),
); );
@ -67,7 +71,7 @@ export default () => {
if (!acc || acc <= 0) { if (!acc || acc <= 0) {
return 0; return 0;
} }
let index = ppCurve.findIndex(o => o.at >= acc); let index = ppCurve.findIndex((o) => o.at >= acc);
if (index === -1) { if (index === -1) {
return ppCurve[ppCurve.length - 1].value; return ppCurve[ppCurve.length - 1].value;
} }
@ -83,7 +87,7 @@ export default () => {
function accFromPpFactor(ppFactor) { function accFromPpFactor(ppFactor) {
if (!ppFactor || ppFactor <= 0) return 0; if (!ppFactor || ppFactor <= 0) return 0;
const idx = ppCurve.findIndex(o => o.value >= ppFactor); const idx = ppCurve.findIndex((o) => o.value >= ppFactor);
if (idx < 0) return ppCurve[ppCurve.length - 1].at; if (idx < 0) return ppCurve[ppCurve.length - 1].at;
const from = ppCurve[idx - 1]; const from = ppCurve[idx - 1];
@ -101,7 +105,7 @@ export default () => {
service = null; service = null;
} }
} };
service = { service = {
getWhatIfScore, getWhatIfScore,
@ -111,7 +115,7 @@ export default () => {
accFromPpFactor, accFromPpFactor,
PP_PER_STAR, PP_PER_STAR,
destroyService, destroyService,
} };
return service; return service;
} };

@ -1,13 +1,17 @@
import {db} from '../../db/db' import { db } from "../../db/db";
import queues from '../../network/queues/queues'; import queues from "../../network/queues/queues";
import rankedsPageClient from '../../network/clients/scoresaber/rankeds/page'; import rankedsPageClient from "../../network/clients/scoresaber/rankeds/page";
import eventBus from '../../utils/broadcast-channel-pubsub' import eventBus from "../../utils/broadcast-channel-pubsub";
import {arrayDifference, convertArrayToObjectByKey, opt} from '../../utils/js' import {
import rankedsRepository from '../../db/repository/rankeds' arrayDifference,
import rankedsChangesRepository from '../../db/repository/rankeds-changes' convertArrayToObjectByKey,
import keyValueRepository from '../../db/repository/key-value' opt,
import log from '../../utils/logger' } from "../../utils/js";
import {addToDate, formatDate, HOUR} from '../../utils/date' import rankedsRepository from "../../db/repository/rankeds";
import rankedsChangesRepository from "../../db/repository/rankeds-changes";
import keyValueRepository from "../../db/repository/key-value";
import log from "../../utils/logger";
import { addToDate, formatDate, HOUR } from "../../utils/date";
const REFRESH_INTERVAL = HOUR; const REFRESH_INTERVAL = HOUR;
@ -16,16 +20,27 @@ export default () => {
if (service) return service; if (service) return service;
const getRankeds = async () => { const getRankeds = async () => {
const dbRankeds = await rankedsRepository().getAll() const dbRankeds = await rankedsRepository().getAll();
return dbRankeds ? convertArrayToObjectByKey(dbRankeds, 'leaderboardId') : {} return dbRankeds
} ? convertArrayToObjectByKey(dbRankeds, "leaderboardId")
: {};
};
const getLastUpdated = async () => keyValueRepository().get('rankedsLastUpdated'); const getLastUpdated = async () =>
const setLastUpdated = async date => keyValueRepository().set(date, 'rankedsLastUpdated'); keyValueRepository().get("rankedsLastUpdated");
const setLastUpdated = async (date) =>
keyValueRepository().set(date, "rankedsLastUpdated");
const refreshRankeds = async (forceUpdate = false, priority = queues.PRIORITY.BG_NORMAL, throwErrors = false) => { const refreshRankeds = async (
log.trace(`Starting rankeds refreshing${forceUpdate ? ' (forced)' : ''}...`, 'RankedsService') forceUpdate = false,
priority = queues.PRIORITY.BG_NORMAL,
throwErrors = false,
) => {
log.trace(
`Starting rankeds refreshing${forceUpdate ? " (forced)" : ""}...`,
"RankedsService",
);
try { try {
let fetchedRankedSongs; let fetchedRankedSongs;
@ -33,40 +48,50 @@ export default () => {
if (!forceUpdate) { if (!forceUpdate) {
const lastUpdated = await getLastUpdated(); const lastUpdated = await getLastUpdated();
if (lastUpdated && lastUpdated > new Date() - REFRESH_INTERVAL) { if (lastUpdated && lastUpdated > new Date() - REFRESH_INTERVAL) {
log.debug(`Refresh interval not yet expired, skipping. Next refresh on ${formatDate(addToDate(REFRESH_INTERVAL, lastUpdated))}`, 'RankedsService') log.debug(
`Refresh interval not yet expired, skipping. Next refresh on ${formatDate(
addToDate(REFRESH_INTERVAL, lastUpdated),
)}`,
"RankedsService",
);
return null; return null;
} }
} }
log.trace(`Fetching current rankeds from ScoreSaber...`, 'RankedsService') log.trace(
`Fetching current rankeds from ScoreSaber...`,
"RankedsService",
);
fetchedRankedSongs = await rankedsPageClient.getProcessed({ priority }); fetchedRankedSongs = await rankedsPageClient.getProcessed({ priority });
if (!fetchedRankedSongs || !fetchedRankedSongs.length) { if (!fetchedRankedSongs || !fetchedRankedSongs.length) {
log.warn(`ScoreSaber returned empty rankeds list`, 'RankedsService') log.warn(`ScoreSaber returned empty rankeds list`, "RankedsService");
return null; return null;
} }
log.trace('Fetching rankeds from DB', 'RankedsService'); log.trace("Fetching rankeds from DB", "RankedsService");
const oldRankedSongs = await getRankeds(); const oldRankedSongs = await getRankeds();
// add firstSeen & oldStars properties // add firstSeen & oldStars properties
fetchedRankedSongs = convertArrayToObjectByKey( fetchedRankedSongs = convertArrayToObjectByKey(
fetchedRankedSongs.map(s => { fetchedRankedSongs.map((s) => {
const firstSeen = oldRankedSongs[s.leaderboardId] && oldRankedSongs[s.leaderboardId].firstSeen const firstSeen =
oldRankedSongs[s.leaderboardId] &&
oldRankedSongs[s.leaderboardId].firstSeen
? oldRankedSongs[s.leaderboardId].firstSeen ? oldRankedSongs[s.leaderboardId].firstSeen
: new Date(); : new Date();
return {...s, firstSeen, oldStars: null} return { ...s, firstSeen, oldStars: null };
}), }),
'leaderboardId', "leaderboardId",
); );
// find differences between old and new ranked songs // find differences between old and new ranked songs
const newRankeds = arrayDifference( const newRankeds = arrayDifference(
Object.keys(fetchedRankedSongs), Object.keys(fetchedRankedSongs),
Object.keys(oldRankedSongs), Object.keys(oldRankedSongs),
).map(leaderboardId => ({ ).map((leaderboardId) => ({
leaderboardId: parseInt(leaderboardId, 10), leaderboardId: parseInt(leaderboardId, 10),
oldStars: null, oldStars: null,
stars: fetchedRankedSongs[leaderboardId].stars, stars: fetchedRankedSongs[leaderboardId].stars,
@ -74,77 +99,99 @@ export default () => {
})); }));
if (newRankeds && newRankeds.length) if (newRankeds && newRankeds.length)
log.debug(`${newRankeds.length} ranked(s) found`, 'RankedsService'); log.debug(`${newRankeds.length} ranked(s) found`, "RankedsService");
const changed = const changed =
// concat new rankeds with changed rankeds // concat new rankeds with changed rankeds
newRankeds newRankeds.concat(
.concat(
Object.values(oldRankedSongs) Object.values(oldRankedSongs)
.filter(s => s.stars !== (fetchedRankedSongs[s.leaderboardId] ? opt(fetchedRankedSongs[s.leaderboardId], 'stars', null) : null)) .filter(
.map(s => ({ (s) =>
s.stars !==
(fetchedRankedSongs[s.leaderboardId]
? opt(fetchedRankedSongs[s.leaderboardId], "stars", null)
: null),
)
.map((s) => ({
leaderboardId: s.leaderboardId, leaderboardId: s.leaderboardId,
oldStars: s.stars, oldStars: s.stars,
stars: opt(fetchedRankedSongs[s.leaderboardId], 'stars', null), stars: opt(fetchedRankedSongs[s.leaderboardId], "stars", null),
timestamp: Date.now(), timestamp: Date.now(),
}), })),
)
); );
if (newRankeds && changed && changed.length - newRankeds.length > 0) if (newRankeds && changed && changed.length - newRankeds.length > 0)
log.debug(`${changed.length - newRankeds.length} changed ranked(s) found`, 'RankedsService'); log.debug(
`${changed.length - newRankeds.length} changed ranked(s) found`,
"RankedsService",
);
const changedLeaderboards = changed const changedLeaderboards = changed
.map(s => { .map((s) => {
const ranked = fetchedRankedSongs[s.leaderboardId] ? fetchedRankedSongs[s.leaderboardId] : oldRankedSongs[s.leaderboardId]; const ranked = fetchedRankedSongs[s.leaderboardId]
? fetchedRankedSongs[s.leaderboardId]
: oldRankedSongs[s.leaderboardId];
return { return {
...ranked, ...ranked,
...s, ...s,
} };
}, })
) .filter((s) => s && s.hash)
.filter(s => s && s.hash) .map((l) => {
.map(l => {
const { oldStars, timestamp, ...leaderboard } = l; const { oldStars, timestamp, ...leaderboard } = l;
return leaderboard; return leaderboard;
}); });
log.trace('Saving rankeds to DB...', 'RankedsService'); log.trace("Saving rankeds to DB...", "RankedsService");
await db.runInTransaction(['rankeds', 'rankeds-changes', 'key-value'], async tx => { await db.runInTransaction(
await Promise.all(changedLeaderboards.map(async ranked => rankedsRepository().set(ranked, undefined, tx))); ["rankeds", "rankeds-changes", "key-value"],
await Promise.all(changed.map(async rc => rankedsChangesRepository().set(rc, undefined, tx))); async (tx) => {
await setLastUpdated(new Date()) await Promise.all(
}); changedLeaderboards.map(async (ranked) =>
rankedsRepository().set(ranked, undefined, tx),
),
);
await Promise.all(
changed.map(async (rc) =>
rankedsChangesRepository().set(rc, undefined, tx),
),
);
await setLastUpdated(new Date());
},
);
log.trace('Rankeds saved', 'RankedsService'); log.trace("Rankeds saved", "RankedsService");
if (changed.length) { if (changed.length) {
eventBus.publish('rankeds-changed', {changed, allRankeds: fetchedRankedSongs}); eventBus.publish("rankeds-changed", {
changed,
allRankeds: fetchedRankedSongs,
});
} }
log.debug(`Rankeds refreshing complete.`, 'RankedsService') log.debug(`Rankeds refreshing complete.`, "RankedsService");
return changed; return changed;
} catch (e) { } catch (e) {
if (throwErrors) throw e; if (throwErrors) throw e;
log.debug(`Rankeds refreshing error`, 'RankedsService', e) log.debug(`Rankeds refreshing error`, "RankedsService", e);
return null; return null;
} }
} };
const destroyService = () => { const destroyService = () => {
service = null; service = null;
} };
service = { service = {
get: getRankeds, get: getRankeds,
refresh: refreshRankeds, refresh: refreshRankeds,
destroyService, destroyService,
} };
return service; return service;
} };

@ -1,10 +1,10 @@
import playersGlobalRankingApiClient from '../../network/clients/scoresaber/players/api-ranking-global' import playersGlobalRankingApiClient from "../../network/clients/scoresaber/players/api-ranking-global";
import playersGlobalRankingPagesApiClient from '../../network/clients/scoresaber/players/api-ranking-global-pages' import playersGlobalRankingPagesApiClient from "../../network/clients/scoresaber/players/api-ranking-global-pages";
import playersCountryRankingPageClient from '../../network/clients/scoresaber/players/page-ranking-country' import playersCountryRankingPageClient from "../../network/clients/scoresaber/players/page-ranking-country";
import makePendingPromisePool from '../../utils/pending-promises' import makePendingPromisePool from "../../utils/pending-promises";
import {PRIORITY} from '../../network/queues/http-queue' import { PRIORITY } from "../../network/queues/http-queue";
import {PLAYERS_PER_PAGE} from '../../utils/scoresaber/consts' import { PLAYERS_PER_PAGE } from "../../utils/scoresaber/consts";
import {opt} from '../../utils/js' import { opt } from "../../utils/js";
let service = null; let service = null;
export default () => { export default () => {
@ -12,24 +12,52 @@ export default () => {
const resolvePromiseOrWaitForPending = makePendingPromisePool(); const resolvePromiseOrWaitForPending = makePendingPromisePool();
const fetchGlobal = async (page = 1, priority = PRIORITY.FG_LOW, signal = null) => resolvePromiseOrWaitForPending(`apiClient/ranking/global/${page}`, () => playersGlobalRankingApiClient.getProcessed({page, signal, priority})); const fetchGlobal = async (
page = 1,
priority = PRIORITY.FG_LOW,
signal = null,
) =>
resolvePromiseOrWaitForPending(`apiClient/ranking/global/${page}`, () =>
playersGlobalRankingApiClient.getProcessed({ page, signal, priority }),
);
const fetchCountry = async (country, page = 1, priority = PRIORITY.FG_LOW, signal = null) => resolvePromiseOrWaitForPending(`pageClient/ranking/${country}/${page}`, () => playersCountryRankingPageClient.getProcessed({country, page, signal, priority})); const fetchCountry = async (
country,
page = 1,
priority = PRIORITY.FG_LOW,
signal = null,
) =>
resolvePromiseOrWaitForPending(
`pageClient/ranking/${country}/${page}`,
() =>
playersCountryRankingPageClient.getProcessed({
country,
page,
signal,
priority,
}),
);
const fetchGlobalPages = async (priority = PRIORITY.FG_LOW, signal = null) => resolvePromiseOrWaitForPending(`apiClient/rankingGlobalPages`, () => playersGlobalRankingPagesApiClient.getProcessed({signal, priority})); const fetchGlobalPages = async (priority = PRIORITY.FG_LOW, signal = null) =>
resolvePromiseOrWaitForPending(`apiClient/rankingGlobalPages`, () =>
playersGlobalRankingPagesApiClient.getProcessed({ signal, priority }),
);
const fetchGlobalCount = async (priority = PRIORITY.FG_LOW, signal = null) => { const fetchGlobalCount = async (
priority = PRIORITY.FG_LOW,
signal = null,
) => {
const pages = await fetchGlobalPages(priority, signal); const pages = await fetchGlobalPages(priority, signal);
if (!pages || !Number.isFinite(pages)) return 0; if (!pages || !Number.isFinite(pages)) return 0;
return pages * PLAYERS_PER_PAGE; return pages * PLAYERS_PER_PAGE;
} };
async function fetchMiniRanking(rank, country = null, numOfPlayers = 5) { async function fetchMiniRanking(rank, country = null, numOfPlayers = 5) {
try { try {
if (!Number.isFinite(numOfPlayers)) numOfPlayers = 5; if (!Number.isFinite(numOfPlayers)) numOfPlayers = 5;
const getPage = rank => Math.floor((rank - 1) / PLAYERS_PER_PAGE) + 1; const getPage = (rank) => Math.floor((rank - 1) / PLAYERS_PER_PAGE) + 1;
const playerPage = getPage(rank); const playerPage = getPage(rank);
let firstPlayerRank = rank - (numOfPlayers - (numOfPlayers > 2 ? 2 : 1)); let firstPlayerRank = rank - (numOfPlayers - (numOfPlayers > 2 ? 2 : 1));
@ -38,15 +66,23 @@ export default () => {
const lastPlayerRank = firstPlayerRank + numOfPlayers - 1; const lastPlayerRank = firstPlayerRank + numOfPlayers - 1;
const lastPlayerRankPage = getPage(lastPlayerRank); const lastPlayerRankPage = getPage(lastPlayerRank);
const pages = [...new Set([playerPage, firstPlayerRankPage, lastPlayerRankPage])].filter(p => p); const pages = [
...new Set([playerPage, firstPlayerRankPage, lastPlayerRankPage]),
].filter((p) => p);
const ranking = (await Promise.all(pages.map(async page => (country ? fetchCountry(country, page) : fetchGlobal(page))))) const ranking = (
await Promise.all(
pages.map(async (page) =>
country ? fetchCountry(country, page) : fetchGlobal(page),
),
)
)
.reduce((cum, arr) => cum.concat(arr), []) .reduce((cum, arr) => cum.concat(arr), [])
.filter(player => { .filter((player) => {
const rank = opt(player, 'playerInfo.rank') const rank = opt(player, "playerInfo.rank");
return rank >= firstPlayerRank && rank <= lastPlayerRank; return rank >= firstPlayerRank && rank <= lastPlayerRank;
}) })
.sort((a,b) => opt(a, 'playerInfo.rank') - opt(b, 'playerInfo.rank')) .sort((a, b) => opt(a, "playerInfo.rank") - opt(b, "playerInfo.rank"));
return ranking; return ranking;
} catch (err) { } catch (err) {
@ -56,7 +92,7 @@ export default () => {
const destroyService = () => { const destroyService = () => {
service = null; service = null;
} };
service = { service = {
getGlobal: fetchGlobal, getGlobal: fetchGlobal,
@ -66,7 +102,7 @@ export default () => {
getMiniRanking: fetchMiniRanking, getMiniRanking: fetchMiniRanking,
PLAYERS_PER_PAGE, PLAYERS_PER_PAGE,
destroyService, destroyService,
} };
return service; return service;
} };

File diff suppressed because it is too large Load Diff

@ -1,16 +1,23 @@
import queues from '../network/queues/queues'; import queues from "../network/queues/queues";
import keyValueRepository from '../db/repository/key-value' import keyValueRepository from "../db/repository/key-value";
import twitchRepository from '../db/repository/twitch' import twitchRepository from "../db/repository/twitch";
import createPlayerService from '../services/scoresaber/player' import createPlayerService from "../services/scoresaber/player";
import profileApiClient from '../network/clients/twitch/api-profile' import profileApiClient from "../network/clients/twitch/api-profile";
import videosApiClient from '../network/clients/twitch/api-videos' import videosApiClient from "../network/clients/twitch/api-videos";
import eventBus from '../utils/broadcast-channel-pubsub' import eventBus from "../utils/broadcast-channel-pubsub";
import log from '../utils/logger' import log from "../utils/logger";
import {addToDate, dateFromString, durationToMillis, formatDate, millisToDuration, MINUTE} from '../utils/date' import {
import {PRIORITY} from '../network/queues/http-queue' addToDate,
import makePendingPromisePool from '../utils/pending-promises' dateFromString,
durationToMillis,
formatDate,
millisToDuration,
MINUTE,
} from "../utils/date";
import { PRIORITY } from "../network/queues/http-queue";
import makePendingPromisePool from "../utils/pending-promises";
const TWITCH_TOKEN_KEY = 'twitchToken'; const TWITCH_TOKEN_KEY = "twitchToken";
const REFRESH_INTERVAL = 5 * MINUTE; const REFRESH_INTERVAL = 5 * MINUTE;
@ -24,31 +31,38 @@ export default () => {
const playerService = createPlayerService(); const playerService = createPlayerService();
const getAuthUrl = (state = '', scopes = '') => queues.TWITCH.getAuthUrl(state, scopes) const getAuthUrl = (state = "", scopes = "") =>
queues.TWITCH.getAuthUrl(state, scopes);
const getTwitchTokenFromUrl = () => { const getTwitchTokenFromUrl = () => {
const url = (new URL(document.location)); const url = new URL(document.location);
const error = url.searchParams.get('error') const error = url.searchParams.get("error");
if (error) { if (error) {
const errorMsg = url.searchParams.get('error_description'); const errorMsg = url.searchParams.get("error_description");
throw new Error(errorMsg ? errorMsg : error); throw new Error(errorMsg ? errorMsg : error);
} }
const hash = url.hash; const hash = url.hash;
if (!hash || !hash.length) throw new Error("Twitch did not return access token") if (!hash || !hash.length)
throw new Error("Twitch did not return access token");
const accessTokenMatch = /access_token=(.*?)(&|$)/.exec(hash); const accessTokenMatch = /access_token=(.*?)(&|$)/.exec(hash);
if (!accessTokenMatch) throw new Error("Twitch did not return access token") if (!accessTokenMatch)
throw new Error("Twitch did not return access token");
const stateMatch = /state=(.*?)(&|$)/.exec(hash); const stateMatch = /state=(.*?)(&|$)/.exec(hash);
return {accessToken: accessTokenMatch[1], url: stateMatch ? decodeURIComponent(stateMatch[1]) : ''}; return {
} accessToken: accessTokenMatch[1],
url: stateMatch ? decodeURIComponent(stateMatch[1]) : "",
};
};
const processToken = async accessToken => { const processToken = async (accessToken) => {
// validate token // validate token
const tokenValidation = (await queues.TWITCH.validateToken(accessToken)).body; const tokenValidation = (await queues.TWITCH.validateToken(accessToken))
.body;
const expiresIn = tokenValidation.expires_in * 1000; const expiresIn = tokenValidation.expires_in * 1000;
@ -58,39 +72,77 @@ export default () => {
obtained: new Date(), obtained: new Date(),
expires: new Date(Date.now() + expiresIn), expires: new Date(Date.now() + expiresIn),
expires_in: expiresIn, expires_in: expiresIn,
} };
await keyValueRepository().set(twitchToken, TWITCH_TOKEN_KEY); await keyValueRepository().set(twitchToken, TWITCH_TOKEN_KEY);
eventBus.publish('twitch-token-refreshed', twitchToken) eventBus.publish("twitch-token-refreshed", twitchToken);
return twitchToken return twitchToken;
} };
const getCurrentToken = async () => keyValueRepository().get(TWITCH_TOKEN_KEY, true); const getCurrentToken = async () =>
keyValueRepository().get(TWITCH_TOKEN_KEY, true);
const fetchProfile = async (login, priority = PRIORITY.FG_LOW, {fullResponse = false, ...options} = {}) => { const fetchProfile = async (
login,
priority = PRIORITY.FG_LOW,
{ fullResponse = false, ...options } = {},
) => {
const token = await getCurrentToken(); const token = await getCurrentToken();
if (!token || !token.expires || token.expires <= new Date()) return null; if (!token || !token.expires || token.expires <= new Date()) return null;
return resolvePromiseOrWaitForPending(`profileApiClient/${login}/${fullResponse}`, () => profileApiClient.getProcessed({...options, accessToken: token.accessToken, login, priority, fullResponse})); return resolvePromiseOrWaitForPending(
} `profileApiClient/${login}/${fullResponse}`,
() =>
profileApiClient.getProcessed({
...options,
accessToken: token.accessToken,
login,
priority,
fullResponse,
}),
);
};
const fetchVideos = async (userId, priority = PRIORITY.FG_LOW, {fullResponse = false, ...options} = {}) => { const fetchVideos = async (
userId,
priority = PRIORITY.FG_LOW,
{ fullResponse = false, ...options } = {},
) => {
const token = await getCurrentToken(); const token = await getCurrentToken();
if (!token || !token.expires || token.expires <= new Date()) return null; if (!token || !token.expires || token.expires <= new Date()) return null;
return resolvePromiseOrWaitForPending(`videosApiClient/${userId}/${fullResponse}`, () => videosApiClient.getProcessed({...options, accessToken: token.accessToken, userId, priority, fullResponse})); return resolvePromiseOrWaitForPending(
} `videosApiClient/${userId}/${fullResponse}`,
() =>
videosApiClient.getProcessed({
...options,
accessToken: token.accessToken,
userId,
priority,
fullResponse,
}),
);
};
const getPlayerProfile = async playerId => twitchRepository().get(playerId); const getPlayerProfile = async (playerId) => twitchRepository().get(playerId);
const updatePlayerProfile = async twitchProfile => twitchRepository().set(twitchProfile); const updatePlayerProfile = async (twitchProfile) =>
twitchRepository().set(twitchProfile);
const refresh = async (playerId, forceUpdate = false, priority = queues.PRIORITY.FG_LOW, throwErrors = false) => { const refresh = async (
log.trace(`Starting Twitch videos refreshing${forceUpdate ? ' (forced)' : ''}...`, 'TwitchService') playerId,
forceUpdate = false,
priority = queues.PRIORITY.FG_LOW,
throwErrors = false,
) => {
log.trace(
`Starting Twitch videos refreshing${forceUpdate ? " (forced)" : ""}...`,
"TwitchService",
);
if (!playerId) { if (!playerId) {
log.debug(`No playerId provided, skipping`, 'TwitchService') log.debug(`No playerId provided, skipping`, "TwitchService");
return null; return null;
} }
@ -98,7 +150,10 @@ export default () => {
try { try {
let twitchProfile = await twitchRepository().get(playerId); let twitchProfile = await twitchRepository().get(playerId);
if (!twitchProfile || !twitchProfile.login) { if (!twitchProfile || !twitchProfile.login) {
log.debug(`Twitch profile for player ${playerId} is not set, skipping`, 'TwitchService') log.debug(
`Twitch profile for player ${playerId} is not set, skipping`,
"TwitchService",
);
return null; return null;
} }
@ -106,7 +161,12 @@ export default () => {
const lastUpdated = twitchProfile.lastUpdated; const lastUpdated = twitchProfile.lastUpdated;
if (!forceUpdate) { if (!forceUpdate) {
if (lastUpdated && lastUpdated > new Date() - REFRESH_INTERVAL) { if (lastUpdated && lastUpdated > new Date() - REFRESH_INTERVAL) {
log.debug(`Refresh interval not yet expired, skipping. Next refresh on ${formatDate(addToDate(REFRESH_INTERVAL, lastUpdated))}`, 'TwitchService') log.debug(
`Refresh interval not yet expired, skipping. Next refresh on ${formatDate(
addToDate(REFRESH_INTERVAL, lastUpdated),
)}`,
"TwitchService",
);
return twitchProfile; return twitchProfile;
} }
@ -115,7 +175,10 @@ export default () => {
const player = playerService.get(playerId); const player = playerService.get(playerId);
if (player && player.recentPlay) { if (player && player.recentPlay) {
if (lastUpdated && lastUpdated > player.recentPlay) { if (lastUpdated && lastUpdated > player.recentPlay) {
log.debug(`Twitch updated after recent player play, skipping`, 'TwitchService') log.debug(
`Twitch updated after recent player play, skipping`,
"TwitchService",
);
return twitchProfile; return twitchProfile;
} }
@ -124,7 +187,10 @@ export default () => {
if (!twitchProfile.id) { if (!twitchProfile.id) {
const fetchedProfile = await fetchProfile(twitchProfile.login); const fetchedProfile = await fetchProfile(twitchProfile.login);
if (!fetchedProfile) { if (!fetchedProfile) {
log.debug(`Can not fetch Twitch profile for player ${playerId}, skipping`, 'TwitchService') log.debug(
`Can not fetch Twitch profile for player ${playerId}, skipping`,
"TwitchService",
);
return twitchProfile; return twitchProfile;
} }
@ -141,7 +207,7 @@ export default () => {
await updatePlayerProfile(twitchProfile); await updatePlayerProfile(twitchProfile);
if (videos && videos.length) { if (videos && videos.length) {
eventBus.publish('player-twitch-videos-updated', { eventBus.publish("player-twitch-videos-updated", {
playerId, playerId,
twitchProfile, twitchProfile,
}); });
@ -151,25 +217,46 @@ export default () => {
} catch (e) { } catch (e) {
if (throwErrors) throw e; if (throwErrors) throw e;
log.debug(`Twitch player ${playerId} refreshing error`, 'TwitchService', e) log.debug(
`Twitch player ${playerId} refreshing error`,
"TwitchService",
e,
);
return null; return null;
} }
} };
async function findTwitchVideo(playerTwitchProfile, timeset, songLength) { async function findTwitchVideo(playerTwitchProfile, timeset, songLength) {
if (!playerTwitchProfile || !playerTwitchProfile.videos || !timeset || !songLength) return null; if (
!playerTwitchProfile ||
!playerTwitchProfile.videos ||
!timeset ||
!songLength
)
return null;
const songStarted = addToDate(-songLength * 1000, timeset) const songStarted = addToDate(-songLength * 1000, timeset);
const video = playerTwitchProfile.videos const video = playerTwitchProfile.videos
.map(v => ({ .map((v) => ({
...v, ...v,
created_at: dateFromString(v.created_at), created_at: dateFromString(v.created_at),
ended_at: addToDate(durationToMillis(v.duration), dateFromString(v.created_at)), ended_at: addToDate(
durationToMillis(v.duration),
dateFromString(v.created_at),
),
})) }))
.find(v => v.created_at <= songStarted && songStarted < v.ended_at); .find((v) => v.created_at <= songStarted && songStarted < v.ended_at);
return video ? {...video, url: video.url + '?t=' + millisToDuration(songStarted - video.created_at)} : null; return video
? {
...video,
url:
video.url +
"?t=" +
millisToDuration(songStarted - video.created_at),
}
: null;
} }
const destroyService = () => { const destroyService = () => {
@ -179,7 +266,7 @@ export default () => {
service = null; service = null;
playerService.destroyService(); playerService.destroyService();
} }
} };
service = { service = {
getAuthUrl, getAuthUrl,
@ -192,7 +279,7 @@ export default () => {
findTwitchVideo, findTwitchVideo,
refresh, refresh,
destroyService, destroyService,
} };
return service; return service;
} };

@ -1,43 +1,67 @@
import {DateTime} from 'luxon'; import { DateTime } from "luxon";
export const getServicePlayerGain = (playerHistory, dateTruncFunc, dateKey, daysAgo = 1, maxDaysAgo = 7) => { export const getServicePlayerGain = (
playerHistory,
dateTruncFunc,
dateKey,
daysAgo = 1,
maxDaysAgo = 7,
) => {
if (!playerHistory?.length) return null; if (!playerHistory?.length) return null;
let todayServiceMidnightDate = dateTruncFunc(new Date()); let todayServiceMidnightDate = dateTruncFunc(new Date());
const compareToDate = DateTime.fromJSDate(todayServiceMidnightDate).minus({days: daysAgo}).toJSDate(); const compareToDate = DateTime.fromJSDate(todayServiceMidnightDate)
const maxServiceDate = DateTime.fromJSDate(todayServiceMidnightDate).minus({days: maxDaysAgo}).toJSDate(); .minus({ days: daysAgo })
.toJSDate();
const maxServiceDate = DateTime.fromJSDate(todayServiceMidnightDate)
.minus({ days: maxDaysAgo })
.toJSDate();
return playerHistory return playerHistory
.sort((a, b) => b?.[dateKey]?.getTime() - a?.[dateKey]?.getTime()) .sort((a, b) => b?.[dateKey]?.getTime() - a?.[dateKey]?.getTime())
.find(h => h?.[dateKey] <= compareToDate && h?.[dateKey] >= maxServiceDate); .find(
} (h) => h?.[dateKey] <= compareToDate && h?.[dateKey] >= maxServiceDate,
);
};
export const serviceFilterFunc = serviceParams => s => { export const serviceFilterFunc = (serviceParams) => (s) => {
// accept score if there is no non-empty filter // accept score if there is no non-empty filter
if (!Object.entries(serviceParams?.filters ?? {})?.filter(([key, val]) => val)?.length) return true; if (
!Object.entries(serviceParams?.filters ?? {})?.filter(([key, val]) => val)
?.length
)
return true;
let filterVal = true; let filterVal = true;
if (serviceParams?.filters?.search?.length) { if (serviceParams?.filters?.search?.length) {
const song = s?.leaderboard?.song ?? null; const song = s?.leaderboard?.song ?? null;
if (song) { if (song) {
const name = `${song?.name?.toLowerCase() ?? ''} ${song?.subName?.toLowerCase() ?? ''} ${song?.authorName?.toLowerCase() ?? ''} ${song?.levelAuthorName?.toLowerCase() ?? ''}` const name = `${song?.name?.toLowerCase() ?? ""} ${
song?.subName?.toLowerCase() ?? ""
} ${song?.authorName?.toLowerCase() ?? ""} ${
song?.levelAuthorName?.toLowerCase() ?? ""
}`;
filterVal &= name.indexOf(serviceParams.filters.search.toLowerCase()) >= 0; filterVal &=
name.indexOf(serviceParams.filters.search.toLowerCase()) >= 0;
} else { } else {
filterVal &= false; filterVal &= false;
} }
} }
if (serviceParams?.filters.diff?.length) { if (serviceParams?.filters.diff?.length) {
filterVal &= s?.leaderboard?.diffInfo?.diff?.toLowerCase() === serviceParams.filters.diff?.toLowerCase() filterVal &=
s?.leaderboard?.diffInfo?.diff?.toLowerCase() ===
serviceParams.filters.diff?.toLowerCase();
} }
if (serviceParams?.filters?.songType?.length) { if (serviceParams?.filters?.songType?.length) {
filterVal &= (serviceParams.filters.songType === 'ranked' && s?.pp > 0) || filterVal &=
(serviceParams.filters.songType === 'unranked' && (s?.pp ?? 0) === 0) (serviceParams.filters.songType === "ranked" && s?.pp > 0) ||
(serviceParams.filters.songType === "unranked" && (s?.pp ?? 0) === 0);
} }
return filterVal; return filterVal;
} };

@ -1,21 +1,22 @@
import {writable} from 'svelte/store' import { writable } from "svelte/store";
import keyValueRepository from '../db/repository/key-value'; import keyValueRepository from "../db/repository/key-value";
import {opt} from '../utils/js' import { opt } from "../utils/js";
const STORE_CONFIG_KEY = 'config'; const STORE_CONFIG_KEY = "config";
export const DEFAULT_LOCALE = 'en-US'; export const DEFAULT_LOCALE = "en-US";
export let configStore = null; export let configStore = null;
const locales = { const locales = {
'de-DE': {id: 'de-DE', name: 'Deutschland'}, "de-DE": { id: "de-DE", name: "Deutschland" },
'es-ES': {id: 'es-ES', name: 'España'}, "es-ES": { id: "es-ES", name: "España" },
'pl-PL': {id: 'pl-PL', name: 'Polska'}, "pl-PL": { id: "pl-PL", name: "Polska" },
'en-GB': {id: 'en-GB', name: 'United Kingdom'}, "en-GB": { id: "en-GB", name: "United Kingdom" },
'en-US': {id: 'en-US', name: 'United States'}, "en-US": { id: "en-US", name: "United States" },
}; };
export const getCurrentLocale = () => configStore ? configStore.getLocale() : DEFAULT_LOCALE; export const getCurrentLocale = () =>
configStore ? configStore.getLocale() : DEFAULT_LOCALE;
export const getSupportedLocales = () => Object.values(locales); export const getSupportedLocales = () => Object.values(locales);
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
@ -24,21 +25,21 @@ const DEFAULT_CONFIG = {
country: null, country: null,
}, },
scoreComparison: { scoreComparison: {
method: 'in-place', method: "in-place",
}, },
preferences: { preferences: {
secondaryPp: 'attribution', secondaryPp: "attribution",
avatarIcons: 'only-if-needed', avatarIcons: "only-if-needed",
}, },
locale: DEFAULT_LOCALE, locale: DEFAULT_LOCALE,
} };
const newSettingsAvailableDefinition = { const newSettingsAvailableDefinition = {
'scoreComparison.method': 'Method of displaying the comparison of scores', "scoreComparison.method": "Method of displaying the comparison of scores",
'preferences.secondaryPp': 'Setting the second PP metric', "preferences.secondaryPp": "Setting the second PP metric",
'preferences.avatarIcons': 'Showing icons on avatars', "preferences.avatarIcons": "Showing icons on avatars",
'locale': 'Locale selection', locale: "Locale selection",
} };
export default async () => { export default async () => {
if (configStore) return configStore; if (configStore) return configStore;
@ -49,16 +50,17 @@ export default async () => {
const { subscribe, set: storeSet } = writable(currentConfig); const { subscribe, set: storeSet } = writable(currentConfig);
const get = key => key ? (currentConfig[key] ? currentConfig[key] : null) : currentConfig; const get = (key) =>
key ? (currentConfig[key] ? currentConfig[key] : null) : currentConfig;
const set = async (config, persist = true) => { const set = async (config, persist = true) => {
const newConfig = { ...DEFAULT_CONFIG }; const newConfig = { ...DEFAULT_CONFIG };
Object.keys(config).forEach(key => { Object.keys(config).forEach((key) => {
if (key === 'locale') { if (key === "locale") {
newConfig[key] = config?.[key] ?? newConfig?.[key] ?? DEFAULT_LOCALE; newConfig[key] = config?.[key] ?? newConfig?.[key] ?? DEFAULT_LOCALE;
return; return;
} }
newConfig[key] = {...newConfig?.[key], ...config?.[key]} newConfig[key] = { ...newConfig?.[key], ...config?.[key] };
}); });
if (persist) await keyValueRepository().set(newConfig, STORE_CONFIG_KEY); if (persist) await keyValueRepository().set(newConfig, STORE_CONFIG_KEY);
@ -69,27 +71,31 @@ export default async () => {
storeSet(newConfig); storeSet(newConfig);
return newConfig; return newConfig;
} };
const getLocale = () => opt(currentConfig, 'locale', DEFAULT_LOCALE); const getLocale = () => opt(currentConfig, "locale", DEFAULT_LOCALE);
const determineNewSettingsAvailable = dbConfig => Object.entries(newSettingsAvailableDefinition) const determineNewSettingsAvailable = (dbConfig) =>
.map(([key, description]) => opt(dbConfig, key) === undefined ? description : null) Object.entries(newSettingsAvailableDefinition)
.filter(d => d) .map(([key, description]) =>
opt(dbConfig, key) === undefined ? description : null,
)
.filter((d) => d);
const dbConfig = await keyValueRepository().get(STORE_CONFIG_KEY); const dbConfig = await keyValueRepository().get(STORE_CONFIG_KEY);
const newSettings = determineNewSettingsAvailable(dbConfig); const newSettings = determineNewSettingsAvailable(dbConfig);
if (dbConfig) await set(dbConfig, false); if (dbConfig) await set(dbConfig, false);
newSettingsAvailable = newSettings && newSettings.length ? newSettings : undefined; newSettingsAvailable =
newSettings && newSettings.length ? newSettings : undefined;
configStore = { configStore = {
subscribe, subscribe,
set, set,
get, get,
getMainPlayerId: () => opt(currentConfig, 'users.main'), getMainPlayerId: () => opt(currentConfig, "users.main"),
getLocale, getLocale,
getNewSettingsAvailable: () => newSettingsAvailable, getNewSettingsAvailable: () => newSettingsAvailable,
} };
return configStore; return configStore;
} };

@ -1,7 +1,9 @@
import {writable} from 'svelte/store' import { writable } from "svelte/store";
export default (sizes = {phone: 0, tablet: 768, desktop: 1024, xxl: 1749}) => { export default (
const defaultValue = {name: null, width: null, nodeWidth: null, rect: null} sizes = { phone: 0, tablet: 768, desktop: 1024, xxl: 1749 },
) => {
const defaultValue = { name: null, width: null, nodeWidth: null, rect: null };
const { subscribe, unsubscribe, set } = writable(defaultValue); const { subscribe, unsubscribe, set } = writable(defaultValue);
let ro = null; let ro = null;
@ -10,12 +12,12 @@ export default (sizes = {phone: 0, tablet: 768, desktop: 1024, xxl: 1749}) => {
const unobserve = () => { const unobserve = () => {
if (!node) return; if (!node) return;
ro.unobserve(node) ro.unobserve(node);
node = null; node = null;
} };
const observe = nodeToObserve => { const observe = (nodeToObserve) => {
if (!nodeToObserve) return null; if (!nodeToObserve) return null;
if (node) unobserve(); if (node) unobserve();
@ -34,19 +36,25 @@ export default (sizes = {phone: 0, tablet: 768, desktop: 1024, xxl: 1749}) => {
set( set(
Object.entries(sizes) Object.entries(sizes)
.sort((a, b) => a[1] - b[1]) .sort((a, b) => a[1] - b[1])
.reduce((cum, item) => item[1] <= nodeWidth ? {name: item[0], width: item[1], nodeWidth, rect} : cum, defaultValue), .reduce(
) (cum, item) =>
item[1] <= nodeWidth
? { name: item[0], width: item[1], nodeWidth, rect }
: cum,
defaultValue,
),
);
}); });
ro.observe(node) ro.observe(node);
return node; return node;
} };
return { return {
subscribe, subscribe,
unsubscribe, unsubscribe,
observe, observe,
unobserve, unobserve,
} };
}; };

@ -1,12 +1,18 @@
import {getFixedLeaderboardMaxScore, getMaxScore} from '../../../../utils/scoresaber/song' import {
getFixedLeaderboardMaxScore,
getMaxScore,
} from "../../../../utils/scoresaber/song";
export default (score, bmStats, leaderboardId) => { export default (score, bmStats, leaderboardId) => {
let maxScore; let maxScore;
if (bmStats && bmStats.notes) { if (bmStats && bmStats.notes) {
maxScore = getMaxScore(bmStats.notes) maxScore = getMaxScore(bmStats.notes);
} else if (leaderboardId) { } else if (leaderboardId) {
maxScore = getFixedLeaderboardMaxScore(leaderboardId, score?.maxScore ?? null) maxScore = getFixedLeaderboardMaxScore(
leaderboardId,
score?.maxScore ?? null,
);
} }
if (maxScore) { if (maxScore) {
@ -17,14 +23,14 @@ export default (score, bmStats, leaderboardId) => {
if (!unmodifiedScore) unmodifiedScore = score?.score ?? null; if (!unmodifiedScore) unmodifiedScore = score?.score ?? null;
if (unmodifiedScore && score.maxScore) { if (unmodifiedScore && score.maxScore) {
score.acc = unmodifiedScore ? unmodifiedScore / maxScore * 100 : null; score.acc = unmodifiedScore ? (unmodifiedScore / maxScore) * 100 : null;
if (score.score) score.percentage = score.score / score.maxScore * 100; if (score.score) score.percentage = (score.score / score.maxScore) * 100;
} }
if (score?.score && score?.maxScore) { if (score?.score && score?.maxScore) {
score.percentage = score.score / score.maxScore * 100; score.percentage = (score.score / score.maxScore) * 100;
} }
return score; return score;
} };

@ -1,10 +1,14 @@
import createBeatMapsService from '../../../../services/beatmaps' import createBeatMapsService from "../../../../services/beatmaps";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
const beatMaps = createBeatMapsService(); const beatMaps = createBeatMapsService();
export default async (data, cachedOnly = false) => { export default async (data, cachedOnly = false) => {
if (!opt(data, 'leaderboard.song.hash.length')) return; if (!opt(data, "leaderboard.song.hash.length")) return;
data.leaderboard.beatMaps = await beatMaps.byHash(data.leaderboard.song.hash, false, cachedOnly); data.leaderboard.beatMaps = await beatMaps.byHash(
} data.leaderboard.song.hash,
false,
cachedOnly,
);
};

@ -1,5 +1,5 @@
import createRankedsStore from '../../../../stores/scoresaber/rankeds' import createRankedsStore from "../../../../stores/scoresaber/rankeds";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
let rankeds; let rankeds;
@ -10,8 +10,11 @@ export default async (data) => {
if (!rankeds) return; if (!rankeds) return;
const leaderboardId = opt(data, 'leaderboard.leaderboardId'); const leaderboardId = opt(data, "leaderboard.leaderboardId");
if (!leaderboardId) return; if (!leaderboardId) return;
data.leaderboard.stars = rankeds[leaderboardId] && rankeds[leaderboardId].stars ? rankeds[leaderboardId].stars : null; data.leaderboard.stars =
} rankeds[leaderboardId] && rankeds[leaderboardId].stars
? rankeds[leaderboardId].stars
: null;
};

@ -1,16 +1,22 @@
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
import calculateAcc from '../common/acc-calc' import calculateAcc from "../common/acc-calc";
import {findDiffInfoWithDiffAndTypeFromBeatMaps} from '../../../../utils/scoresaber/song' import { findDiffInfoWithDiffAndTypeFromBeatMaps } from "../../../../utils/scoresaber/song";
export default async (data) => { export default async (data) => {
if (!data || !data.score) return; if (!data || !data.score) return;
const leaderboardId = opt(data, 'leaderboard.leaderboardId') const leaderboardId = opt(data, "leaderboard.leaderboardId");
const diffInfo = opt(data, 'leaderboard.diffInfo'); const diffInfo = opt(data, "leaderboard.diffInfo");
const versions = opt(data, 'leaderboard.beatMaps.versions') const versions = opt(data, "leaderboard.beatMaps.versions");
const versionsLastIdx = versions && Array.isArray(versions) && versions.length ? versions.length - 1 : 0; const versionsLastIdx =
const bmStats = findDiffInfoWithDiffAndTypeFromBeatMaps(opt(data, `leaderboard.beatMaps.versions.${versionsLastIdx}.diffs`), diffInfo); versions && Array.isArray(versions) && versions.length
? versions.length - 1
: 0;
const bmStats = findDiffInfoWithDiffAndTypeFromBeatMaps(
opt(data, `leaderboard.beatMaps.versions.${versionsLastIdx}.diffs`),
diffInfo,
);
data.score = calculateAcc(data.score, bmStats, leaderboardId); data.score = calculateAcc(data.score, bmStats, leaderboardId);
} };

@ -1,6 +1,6 @@
import createBeatSaviorService from '../../../../services/beatsavior' import createBeatSaviorService from "../../../../services/beatsavior";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
import {PRIORITY} from '../../../../network/queues/http-queue' import { PRIORITY } from "../../../../network/queues/http-queue";
let beatSaviorService; let beatSaviorService;
@ -15,19 +15,19 @@ export default async (data, playerId = null) => {
if (!bsData) return; if (!bsData) return;
if (bsData?.stats) if (bsData?.stats)
['left', 'right'].forEach(hand => { ["left", "right"].forEach((hand) => {
['Preswing', 'Postswing'].forEach(stat => { ["Preswing", "Postswing"].forEach((stat) => {
const key = `${hand}${stat}`; const key = `${hand}${stat}`;
if (!bsData?.stats?.[key]) if (!bsData?.stats?.[key])
bsData.stats[key] = bsData?.trackers?.accuracyTracker?.[key] ?? null; bsData.stats[key] = bsData?.trackers?.accuracyTracker?.[key] ?? null;
}) });
}) });
const acc = opt(bsData, 'trackers.scoreTracker.rawRatio'); const acc = opt(bsData, "trackers.scoreTracker.rawRatio");
if (acc) data.score.acc = acc * 100; if (acc) data.score.acc = acc * 100;
const percentage = opt(bsData, 'trackers.scoreTracker.modifiedRatio'); const percentage = opt(bsData, "trackers.scoreTracker.modifiedRatio");
if (percentage) data.score.percentage = percentage * 100; if (percentage) data.score.percentage = percentage * 100;
data.beatSavior = bsData; data.beatSavior = bsData;
} };

@ -1,10 +1,10 @@
import {configStore} from '../../../config' import { configStore } from "../../../config";
import createScoresService from '../../../../services/scoresaber/scores' import createScoresService from "../../../../services/scoresaber/scores";
import accEnhancer from './acc' import accEnhancer from "./acc";
import beatSaviorEnhancer from './beatsavior' import beatSaviorEnhancer from "./beatsavior";
import beatMapsEnhancer from '../common/beatmaps' import beatMapsEnhancer from "../common/beatmaps";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
import produce from 'immer' import produce from "immer";
let scoresService = null; let scoresService = null;
let mainPlayerId = null; let mainPlayerId = null;
@ -17,20 +17,29 @@ export const initCompareEnhancer = async () => {
scoresService = createScoresService(); scoresService = createScoresService();
configStoreUnsubscribe = configStore.subscribe(async config => { configStoreUnsubscribe = configStore.subscribe(async (config) => {
const newMainPlayerId = opt(config, 'users.main') const newMainPlayerId = opt(config, "users.main");
if (mainPlayerId !== newMainPlayerId) { if (mainPlayerId !== newMainPlayerId) {
mainPlayerId = newMainPlayerId; mainPlayerId = newMainPlayerId;
if (!playerScores[mainPlayerId]) playerScores[mainPlayerId] = await scoresService.getPlayerScoresAsObject(mainPlayerId); if (!playerScores[mainPlayerId])
} playerScores[mainPlayerId] =
}) await scoresService.getPlayerScoresAsObject(mainPlayerId);
} }
});
};
export default async (data, playerId = null) => { export default async (data, playerId = null) => {
if (!data || !data.score || data.comparePlayers || !mainPlayerId || mainPlayerId === playerId) return; if (
!data ||
!data.score ||
data.comparePlayers ||
!mainPlayerId ||
mainPlayerId === playerId
)
return;
const leaderboardId = opt(data, 'leaderboard.leaderboardId'); const leaderboardId = opt(data, "leaderboard.leaderboardId");
if (!leaderboardId) return; if (!leaderboardId) return;
const comparePlayerScores = await playerScores[mainPlayerId]; const comparePlayerScores = await playerScores[mainPlayerId];
@ -38,14 +47,15 @@ export default async (data, playerId = null) => {
const mainPlayerScore = await produce( const mainPlayerScore = await produce(
await produce( await produce(
await produce( await produce(comparePlayerScores[leaderboardId], (draft) =>
comparePlayerScores[leaderboardId], beatMapsEnhancer(draft),
draft => beatMapsEnhancer(draft),
), ),
draft => accEnhancer(draft, true), (draft) => accEnhancer(draft, true),
), ),
draft => beatSaviorEnhancer(draft, mainPlayerId), (draft) => beatSaviorEnhancer(draft, mainPlayerId),
); );
data.comparePlayers = [{...mainPlayerScore, playerId: mainPlayerId, playerName: 'Me'}]; data.comparePlayers = [
} { ...mainPlayerScore, playerId: mainPlayerId, playerName: "Me" },
];
};

@ -1,7 +1,7 @@
import createScoresService from '../../../../services/scoresaber/scores'; import createScoresService from "../../../../services/scoresaber/scores";
import calculateAcc from '../common/acc-calc' import calculateAcc from "../common/acc-calc";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
import {findDiffInfoWithDiffAndTypeFromBeatMaps} from '../../../../utils/scoresaber/song' import { findDiffInfoWithDiffAndTypeFromBeatMaps } from "../../../../utils/scoresaber/song";
let scoresService; let scoresService;
@ -10,27 +10,39 @@ export default async (data, playerId = null) => {
if (data.prevScore) delete data.prevScore; if (data.prevScore) delete data.prevScore;
const leaderboardId = opt(data, 'leaderboard.leaderboardId'); const leaderboardId = opt(data, "leaderboard.leaderboardId");
if (!scoresService) scoresService = createScoresService(); if (!scoresService) scoresService = createScoresService();
const playerScores = scoresService.convertScoresToObject(await scoresService.getPlayerScores(playerId)); const playerScores = scoresService.convertScoresToObject(
await scoresService.getPlayerScores(playerId),
);
// skip if no cached score // skip if no cached score
if (!playerScores[leaderboardId]) return; if (!playerScores[leaderboardId]) return;
// compare to cached score if cached is equal to current or to cached history score otherwise // compare to cached score if cached is equal to current or to cached history score otherwise
let prevScore = playerScores[leaderboardId].score.score === data.score.score let prevScore =
? (playerScores[leaderboardId].history && playerScores[leaderboardId].history.length ? playerScores[leaderboardId].history[0] : null) playerScores[leaderboardId].score.score === data.score.score
? playerScores[leaderboardId].history &&
playerScores[leaderboardId].history.length
? playerScores[leaderboardId].history[0]
: null
: playerScores[leaderboardId].score; : playerScores[leaderboardId].score;
// skip if no score to compare // skip if no score to compare
if (!prevScore) return; if (!prevScore) return;
const diffInfo = opt(data, 'leaderboard.diffInfo'); const diffInfo = opt(data, "leaderboard.diffInfo");
const versions = opt(data, 'leaderboard.beatMaps.versions') const versions = opt(data, "leaderboard.beatMaps.versions");
const versionsLastIdx = versions && Array.isArray(versions) && versions.length ? versions.length - 1 : 0; const versionsLastIdx =
const bmStats = findDiffInfoWithDiffAndTypeFromBeatMaps(opt(data, `leaderboard.beatMaps.versions.${versionsLastIdx}.diffs`), diffInfo); versions && Array.isArray(versions) && versions.length
? versions.length - 1
: 0;
const bmStats = findDiffInfoWithDiffAndTypeFromBeatMaps(
opt(data, `leaderboard.beatMaps.versions.${versionsLastIdx}.diffs`),
diffInfo,
);
data.prevScore = calculateAcc(prevScore, bmStats, leaderboardId); data.prevScore = calculateAcc(prevScore, bmStats, leaderboardId);
} };

@ -1,24 +1,28 @@
import createPpService from '../../../../services/scoresaber/pp' import createPpService from "../../../../services/scoresaber/pp";
import {configStore} from '../../../config' import { configStore } from "../../../config";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
let ppService; let ppService;
export default async (data, playerId = null, whatIfOnly = false) => { export default async (data, playerId = null, whatIfOnly = false) => {
if (!playerId) return; if (!playerId) return;
const leaderboardId = opt(data, 'leaderboard.leaderboardId'); const leaderboardId = opt(data, "leaderboard.leaderboardId");
if (!leaderboardId) return; if (!leaderboardId) return;
const pp = opt(data, 'score.pp'); const pp = opt(data, "score.pp");
if (!pp) return; if (!pp) return;
if (!ppService) ppService = createPpService(); if (!ppService) ppService = createPpService();
const mainPlayerId = configStore.getMainPlayerId(); const mainPlayerId = configStore.getMainPlayerId();
if (mainPlayerId && mainPlayerId !== playerId) { if (mainPlayerId && mainPlayerId !== playerId) {
const whatIfPp = await ppService.getWhatIfScore(mainPlayerId, leaderboardId, pp) const whatIfPp = await ppService.getWhatIfScore(
if (whatIfPp && whatIfPp.diff >= 0.01) data.score.whatIfPp = whatIfPp mainPlayerId,
leaderboardId,
pp,
);
if (whatIfPp && whatIfPp.diff >= 0.01) data.score.whatIfPp = whatIfPp;
} }
if (whatIfOnly) return; if (whatIfOnly) return;
@ -27,4 +31,4 @@ export default async (data, playerId = null, whatIfOnly = false) => {
if (!ppAttribution) return; if (!ppAttribution) return;
data.score.ppAttribution = -ppAttribution.diff; data.score.ppAttribution = -ppAttribution.diff;
} };

@ -1,15 +1,22 @@
import createTwitchService from '../../../../services/twitch' import createTwitchService from "../../../../services/twitch";
import {findDiffInfoWithDiffAndTypeFromBeatMaps} from '../../../../utils/scoresaber/song' import { findDiffInfoWithDiffAndTypeFromBeatMaps } from "../../../../utils/scoresaber/song";
import {opt} from '../../../../utils/js' import { opt } from "../../../../utils/js";
let twitchService; let twitchService;
export default async (data, playerId = null) => { export default async (data, playerId = null) => {
if (!data || !data.score || !data.leaderboard || !data.leaderboard.beatMaps) return; if (!data || !data.score || !data.leaderboard || !data.leaderboard.beatMaps)
return;
const versions = opt(data, 'leaderboard.beatMaps.versions') const versions = opt(data, "leaderboard.beatMaps.versions");
const versionsLastIdx = versions && Array.isArray(versions) && versions.length ? versions.length - 1 : 0; const versionsLastIdx =
const bmStats = findDiffInfoWithDiffAndTypeFromBeatMaps(opt(data, `leaderboard.beatMaps.versions.${versionsLastIdx}.diffs`), data.leaderboard.diffInfo); versions && Array.isArray(versions) && versions.length
? versions.length - 1
: 0;
const bmStats = findDiffInfoWithDiffAndTypeFromBeatMaps(
opt(data, `leaderboard.beatMaps.versions.${versionsLastIdx}.diffs`),
data.leaderboard.diffInfo,
);
if (!bmStats || !bmStats.seconds) return; if (!bmStats || !bmStats.seconds) return;
if (!twitchService) twitchService = createTwitchService(); if (!twitchService) twitchService = createTwitchService();
@ -17,8 +24,12 @@ export default async (data, playerId = null) => {
const twitchProfile = await twitchService.refresh(playerId); const twitchProfile = await twitchService.refresh(playerId);
if (!twitchProfile) return; if (!twitchProfile) return;
const video = await twitchService.findTwitchVideo(twitchProfile, data.score.timeSet, bmStats.seconds); const video = await twitchService.findTwitchVideo(
twitchProfile,
data.score.timeSet,
bmStats.seconds,
);
if (!video || !video.url) return; if (!video || !video.url) return;
data.twitchVideo = video; data.twitchVideo = video;
} };

@ -1,44 +1,53 @@
import createHttpStore from './http-store'; import createHttpStore from "./http-store";
import beatMapsEnhancer from './enhancers/common/beatmaps' import beatMapsEnhancer from "./enhancers/common/beatmaps";
import accEnhancer from './enhancers/scores/acc' import accEnhancer from "./enhancers/scores/acc";
import createLeaderboardPageProvider from './providers/page-leaderboard' import createLeaderboardPageProvider from "./providers/page-leaderboard";
import {writable} from 'svelte/store' import { writable } from "svelte/store";
import {findDiffInfoWithDiffAndTypeFromBeatMaps} from '../../utils/scoresaber/song' import { findDiffInfoWithDiffAndTypeFromBeatMaps } from "../../utils/scoresaber/song";
import {debounce} from '../../utils/debounce' import { debounce } from "../../utils/debounce";
import produce, {applyPatches} from 'immer' import produce, { applyPatches } from "immer";
import ppAttributionEnhancer from './enhancers/scores/pp-attribution' import ppAttributionEnhancer from "./enhancers/scores/pp-attribution";
export default (leaderboardId, type = 'global', page = 1, initialState = null, initialStateType = 'initial') => { export default (
leaderboardId,
type = "global",
page = 1,
initialState = null,
initialStateType = "initial",
) => {
let currentLeaderboardId = leaderboardId ? leaderboardId : null; let currentLeaderboardId = leaderboardId ? leaderboardId : null;
let currentType = type ? type : 'global'; let currentType = type ? type : "global";
let currentPage = page ? page : 1; let currentPage = page ? page : 1;
const { subscribe: subscribeEnhanced, set: setEnhanced } = writable(null); const { subscribe: subscribeEnhanced, set: setEnhanced } = writable(null);
const getCurrentEnhanceTaskId = () => `${currentLeaderboardId}/${currentPage}/${currentType}`; const getCurrentEnhanceTaskId = () =>
const getPatchId = (leaderboardId, scoreRow) => `${leaderboardId}/${scoreRow?.player?.playerId}` `${currentLeaderboardId}/${currentPage}/${currentType}`;
const getPatchId = (leaderboardId, scoreRow) =>
`${leaderboardId}/${scoreRow?.player?.playerId}`;
let enhancePatches = {}; let enhancePatches = {};
let currentEnhanceTaskId = null; let currentEnhanceTaskId = null;
const onNewData = ({ fetchParams, state, set }) => { const onNewData = ({ fetchParams, state, set }) => {
currentLeaderboardId = fetchParams?.leaderboardId ?? null; currentLeaderboardId = fetchParams?.leaderboardId ?? null;
currentType = fetchParams?.type ?? 'global'; currentType = fetchParams?.type ?? "global";
currentPage = fetchParams?.page ?? 1; currentPage = fetchParams?.page ?? 1;
if (!state) return; if (!state) return;
const enhanceTaskId = getCurrentEnhanceTaskId(); const enhanceTaskId = getCurrentEnhanceTaskId();
if (currentEnhanceTaskId !== enhanceTaskId) { if (currentEnhanceTaskId !== enhanceTaskId) {
enhancePatches = {} enhancePatches = {};
currentEnhanceTaskId = enhanceTaskId; currentEnhanceTaskId = enhanceTaskId;
} }
const stateProduce = (state, patchId, producer) => produce(state, producer, patches => { const stateProduce = (state, patchId, producer) =>
produce(state, producer, (patches) => {
if (!enhancePatches[patchId]) enhancePatches[patchId] = []; if (!enhancePatches[patchId]) enhancePatches[patchId] = [];
enhancePatches[patchId].push(...patches) enhancePatches[patchId].push(...patches);
}) });
const debouncedSetState = debounce((enhanceTaskId, state) => { const debouncedSetState = debounce((enhanceTaskId, state) => {
if (currentEnhanceTaskId !== enhanceTaskId) return; if (currentEnhanceTaskId !== enhanceTaskId) return;
@ -51,49 +60,78 @@ export default (leaderboardId, type = 'global', page = 1, initialState = null, i
const setStateRow = (enhanceTaskId, scoreRow) => { const setStateRow = (enhanceTaskId, scoreRow) => {
if (currentEnhanceTaskId !== enhanceTaskId) return null; if (currentEnhanceTaskId !== enhanceTaskId) return null;
const patchId = getPatchId(currentLeaderboardId, scoreRow) const patchId = getPatchId(currentLeaderboardId, scoreRow);
const stateRowIdx = newState.scores.findIndex(s => getPatchId(currentLeaderboardId, s) === patchId) const stateRowIdx = newState.scores.findIndex(
(s) => getPatchId(currentLeaderboardId, s) === patchId,
);
if (stateRowIdx < 0) return; if (stateRowIdx < 0) return;
newState.scores[stateRowIdx] = applyPatches(newState.scores[stateRowIdx], enhancePatches[patchId]); newState.scores[stateRowIdx] = applyPatches(
newState.scores[stateRowIdx],
enhancePatches[patchId],
);
debouncedSetState(enhanceTaskId, newState); debouncedSetState(enhanceTaskId, newState);
return newState.scores[stateRowIdx]; return newState.scores[stateRowIdx];
} };
if (newState.leaderboard) if (newState.leaderboard)
beatMapsEnhancer(newState) beatMapsEnhancer(newState)
.then(_ => { .then((_) => {
const versions = newState?.leaderboard?.beatMaps?.versions ?? null const versions = newState?.leaderboard?.beatMaps?.versions ?? null;
const versionsLastIdx = versions && Array.isArray(versions) && versions.length ? versions.length - 1 : 0; const versionsLastIdx =
versions && Array.isArray(versions) && versions.length
? versions.length - 1
: 0;
const bpm = newState?.leaderboard?.beatMaps?.metadata?.bpm ?? null; const bpm = newState?.leaderboard?.beatMaps?.metadata?.bpm ?? null;
const bmStats = findDiffInfoWithDiffAndTypeFromBeatMaps(newState?.leaderboard?.beatMaps?.versions?.[versionsLastIdx]?.diffs, newState?.leaderboard?.diffInfo); const bmStats = findDiffInfoWithDiffAndTypeFromBeatMaps(
newState?.leaderboard?.beatMaps?.versions?.[versionsLastIdx]?.diffs,
newState?.leaderboard?.diffInfo,
);
if (!bmStats) return null; if (!bmStats) return null;
newState.leaderboard.stats = {...newState.leaderboard.stats, ...bmStats, bpm}; newState.leaderboard.stats = {
...newState.leaderboard.stats,
...bmStats,
bpm,
};
setEnhanced({leaderboardId, type, page, enhancedAt: new Date()}) setEnhanced({ leaderboardId, type, page, enhancedAt: new Date() });
debouncedSetState(enhanceTaskId, newState); debouncedSetState(enhanceTaskId, newState);
return newState.leaderboard.beatMaps; return newState.leaderboard.beatMaps;
}) })
.then(_ => { .then((_) => {
if (!newState.scores || !newState.scores.length) return; if (!newState.scores || !newState.scores.length) return;
for (const scoreRow of newState.scores) { for (const scoreRow of newState.scores) {
stateProduce({ stateProduce(
{
...scoreRow, ...scoreRow,
leaderboard: newState.leaderboard leaderboard: newState.leaderboard,
}, getPatchId(currentLeaderboardId, scoreRow), draft => accEnhancer(draft)) },
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) getPatchId(currentLeaderboardId, scoreRow),
.then(scoreRow => stateProduce({...scoreRow, leaderboard: newState.leaderboard}, getPatchId(currentLeaderboardId, scoreRow), draft => ppAttributionEnhancer(draft, scoreRow?.player?.playerId, true)) (draft) => accEnhancer(draft),
) )
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) .then((scoreRow) => setStateRow(enhanceTaskId, scoreRow))
} .then((scoreRow) =>
}) stateProduce(
{ ...scoreRow, leaderboard: newState.leaderboard },
getPatchId(currentLeaderboardId, scoreRow),
(draft) =>
ppAttributionEnhancer(
draft,
scoreRow?.player?.playerId,
true,
),
),
)
.then((scoreRow) => setStateRow(enhanceTaskId, scoreRow));
} }
});
};
const provider = createLeaderboardPageProvider(); const provider = createLeaderboardPageProvider();
@ -106,18 +144,30 @@ export default (leaderboardId, type = 'global', page = 1, initialState = null, i
onAfterStateChange: onNewData, onAfterStateChange: onNewData,
onSetPending: ({ fetchParams }) => ({ ...fetchParams }), onSetPending: ({ fetchParams }) => ({ ...fetchParams }),
}, },
initialStateType initialStateType,
); );
const fetch = async (leaderboardId = currentLeaderboardId, type = currentType, page = currentPage, force = false) => { const fetch = async (
leaderboardId = currentLeaderboardId,
type = currentType,
page = currentPage,
force = false,
) => {
if (!leaderboardId) return false; if (!leaderboardId) return false;
if (leaderboardId === currentLeaderboardId && (!type || type === currentType) && (!page || page === currentPage) && !force) return false; if (
leaderboardId === currentLeaderboardId &&
(!type || type === currentType) &&
(!page || page === currentPage) &&
!force
)
return false;
return httpStore.fetch({ leaderboardId, type, page }, force, provider); return httpStore.fetch({ leaderboardId, type, page }, force, provider);
} };
const refresh = async () => fetch(currentLeaderboardId, currentType, currentPage, true); const refresh = async () =>
fetch(currentLeaderboardId, currentType, currentPage, true);
return { return {
...httpStore, ...httpStore,
@ -127,6 +177,5 @@ export default (leaderboardId, type = 'global', page = 1, initialState = null, i
getType: () => currentType, getType: () => currentType,
getPage: () => currentPage, getPage: () => currentPage,
enhanced: { subscribe: subscribeEnhanced }, enhanced: { subscribe: subscribeEnhanced },
} };
} };

@ -1,12 +1,16 @@
import createHttpStore from './http-store'; import createHttpStore from "./http-store";
import playerApiClient from '../../network/clients/scoresaber/player/api' import playerApiClient from "../../network/clients/scoresaber/player/api";
export default (playerId = null, initialState = null, initialStateType = 'initial') => { export default (
playerId = null,
initialState = null,
initialStateType = "initial",
) => {
let currentPlayerId = playerId; let currentPlayerId = playerId;
const onNewData = ({ fetchParams }) => { const onNewData = ({ fetchParams }) => {
currentPlayerId = fetchParams?.playerId ?? null; currentPlayerId = fetchParams?.playerId ?? null;
} };
const httpStore = createHttpStore( const httpStore = createHttpStore(
playerApiClient, playerApiClient,
@ -23,12 +27,11 @@ export default (playerId = null, initialState = null, initialStateType = 'initia
if (!playerId || (playerId === currentPlayerId && !force)) return false; if (!playerId || (playerId === currentPlayerId && !force)) return false;
return httpStore.fetch({ playerId }, force, playerApiClient); return httpStore.fetch({ playerId }, force, playerApiClient);
} };
return { return {
...httpStore, ...httpStore,
fetch, fetch,
getPlayerId: () => currentPlayerId, getPlayerId: () => currentPlayerId,
} };
} };

@ -1,12 +1,18 @@
import stringify from 'json-stable-stringify'; import stringify from "json-stable-stringify";
import eventBus from '../../utils/broadcast-channel-pubsub' import eventBus from "../../utils/broadcast-channel-pubsub";
import createHttpStore from './http-store'; import createHttpStore from "./http-store";
import createApiPlayerWithScoresProvider from './providers/api-player-with-scores' import createApiPlayerWithScoresProvider from "./providers/api-player-with-scores";
import createPlayerService from '../../services/scoresaber/player' import createPlayerService from "../../services/scoresaber/player";
import {addToDate, MINUTE} from '../../utils/date' import { addToDate, MINUTE } from "../../utils/date";
import {writable} from 'svelte/store' import { writable } from "svelte/store";
export default (playerId = null, service = 'scoresaber', serviceParams = {type: 'recent', page: 1}, initialState = null, initialStateType = 'initial') => { export default (
playerId = null,
service = "scoresaber",
serviceParams = { type: "recent", page: 1 },
initialState = null,
initialStateType = "initial",
) => {
let currentPlayerId = playerId; let currentPlayerId = playerId;
let currentService = service; let currentService = service;
let currentServiceParams = serviceParams; let currentServiceParams = serviceParams;
@ -23,8 +29,8 @@ export default (playerId = null, service = 'scoresaber', serviceParams = {type:
currentService = fetchParams?.service ?? null; currentService = fetchParams?.service ?? null;
currentServiceParams = fetchParams?.serviceParams ?? null; currentServiceParams = fetchParams?.serviceParams ?? null;
setParams({currentPlayerId, currentService, currentServiceParams}) setParams({ currentPlayerId, currentService, currentServiceParams });
} };
const provider = createApiPlayerWithScoresProvider(); const provider = createApiPlayerWithScoresProvider();
@ -36,14 +42,20 @@ export default (playerId = null, service = 'scoresaber', serviceParams = {type:
onInitialized: onNewData, onInitialized: onNewData,
onAfterStateChange: onNewData, onAfterStateChange: onNewData,
}, },
initialStateType initialStateType,
); );
const fetch = async (playerId = currentPlayerId, service = currentService, serviceParams = currentServiceParams, force = false) => { const fetch = async (
playerId = currentPlayerId,
service = currentService,
serviceParams = currentServiceParams,
force = false,
) => {
if ( if (
(!playerId || playerId === currentPlayerId) && (!playerId || playerId === currentPlayerId) &&
(!service || stringify(service) === stringify(currentService)) && (!service || stringify(service) === stringify(currentService)) &&
(!serviceParams || stringify(serviceParams) === stringify(currentServiceParams)) && (!serviceParams ||
stringify(serviceParams) === stringify(currentServiceParams)) &&
!force !force
) )
return false; return false;
@ -54,12 +66,20 @@ export default (playerId = null, service = 'scoresaber', serviceParams = {type:
playerForLastRecentPlay = playerId; playerForLastRecentPlay = playerId;
} }
return httpStore.fetch({playerId, service, serviceParams}, force, provider, !playerId || playerId !== currentPlayerId || force); return httpStore.fetch(
} { playerId, service, serviceParams },
force,
provider,
!playerId || playerId !== currentPlayerId || force,
);
};
const refresh = async () => fetch(currentPlayerId, currentService, currentServiceParams, true); const refresh = async () =>
fetch(currentPlayerId, currentService, currentServiceParams, true);
const playerRecentPlayUpdatedUnsubscribe = eventBus.on('player-recent-play-updated', async ({playerId, recentPlay}) => { const playerRecentPlayUpdatedUnsubscribe = eventBus.on(
"player-recent-play-updated",
async ({ playerId, recentPlay }) => {
if (!playerId || !currentPlayerId || playerId !== currentPlayerId) return; if (!playerId || !currentPlayerId || playerId !== currentPlayerId) return;
if (!recentPlay || !lastRecentPlay || recentPlay <= lastRecentPlay) { if (!recentPlay || !lastRecentPlay || recentPlay <= lastRecentPlay) {
@ -74,37 +94,44 @@ export default (playerId = null, service = 'scoresaber', serviceParams = {type:
playerForLastRecentPlay = playerId; playerForLastRecentPlay = playerId;
await refresh(); await refresh();
}); },
);
const subscribe = fn => { const subscribe = (fn) => {
const storeUnsubscribe = httpStore.subscribe(fn); const storeUnsubscribe = httpStore.subscribe(fn);
return () => { return () => {
storeUnsubscribe(); storeUnsubscribe();
playerRecentPlayUpdatedUnsubscribe(); playerRecentPlayUpdatedUnsubscribe();
} };
} };
const DEFAULT_RECENT_PLAY_REFRESH_INTERVAL = MINUTE; const DEFAULT_RECENT_PLAY_REFRESH_INTERVAL = MINUTE;
const enqueueRecentPlayRefresh = async () => { const enqueueRecentPlayRefresh = async () => {
if (!currentPlayerId) { if (!currentPlayerId) {
setTimeout(() => enqueueRecentPlayRefresh(), DEFAULT_RECENT_PLAY_REFRESH_INTERVAL); setTimeout(
() => enqueueRecentPlayRefresh(),
DEFAULT_RECENT_PLAY_REFRESH_INTERVAL,
);
return; return;
} }
await playerService.fetchPlayerAndUpdateRecentPlay(currentPlayerId); await playerService.fetchPlayerAndUpdateRecentPlay(currentPlayerId);
const refreshInterval = !lastRecentPlay || lastRecentPlay >= addToDate(-30 * MINUTE, new Date()) const refreshInterval =
!lastRecentPlay || lastRecentPlay >= addToDate(-30 * MINUTE, new Date())
? DEFAULT_RECENT_PLAY_REFRESH_INTERVAL ? DEFAULT_RECENT_PLAY_REFRESH_INTERVAL
: 15 * MINUTE; : 15 * MINUTE;
setTimeout(() => enqueueRecentPlayRefresh(), refreshInterval); setTimeout(() => enqueueRecentPlayRefresh(), refreshInterval);
};
} setTimeout(
() => enqueueRecentPlayRefresh(),
setTimeout(() => enqueueRecentPlayRefresh(), DEFAULT_RECENT_PLAY_REFRESH_INTERVAL); DEFAULT_RECENT_PLAY_REFRESH_INTERVAL,
);
return { return {
...httpStore, ...httpStore,
@ -114,15 +141,14 @@ export default (playerId = null, service = 'scoresaber', serviceParams = {type:
params: { subscribe: subscribeParams }, params: { subscribe: subscribeParams },
getPlayerId: () => currentPlayerId, getPlayerId: () => currentPlayerId,
getService: () => currentService, getService: () => currentService,
setService: type => { setService: (type) => {
currentService = type; currentService = type;
setParams({currentPlayerId, currentService, currentServiceParams}) setParams({ currentPlayerId, currentService, currentServiceParams });
}, },
getServiceParams: () => currentServiceParams, getServiceParams: () => currentServiceParams,
setServiceParams: page => { setServiceParams: (page) => {
currentServiceParams = page currentServiceParams = page;
setParams({currentPlayerId, currentService, currentServiceParams}) setParams({ currentPlayerId, currentService, currentServiceParams });
}, },
} };
} };

@ -1,14 +1,19 @@
import createHttpStore from './http-store'; import createHttpStore from "./http-store";
import createApiRankingProvider from './providers/api-ranking' import createApiRankingProvider from "./providers/api-ranking";
export default (type = 'global', page = 1, initialState = null, initialStateType = 'initial') => { export default (
let currentType = type ? type : 'global'; type = "global",
page = 1,
initialState = null,
initialStateType = "initial",
) => {
let currentType = type ? type : "global";
let currentPage = page ? page : 1; let currentPage = page ? page : 1;
const onNewData = ({ fetchParams }) => { const onNewData = ({ fetchParams }) => {
currentType = fetchParams?.type ?? 'global'; currentType = fetchParams?.type ?? "global";
currentPage = fetchParams?.page ?? 1; currentPage = fetchParams?.page ?? 1;
} };
const provider = createApiRankingProvider(); const provider = createApiRankingProvider();
@ -21,14 +26,23 @@ export default (type = 'global', page = 1, initialState = null, initialStateType
onAfterStateChange: onNewData, onAfterStateChange: onNewData,
onSetPending: ({ fetchParams }) => ({ ...fetchParams }), onSetPending: ({ fetchParams }) => ({ ...fetchParams }),
}, },
initialStateType initialStateType,
); );
const fetch = async (type = currentType, page = currentPage, force = false) => { const fetch = async (
if ((!type || type === currentType) && (!page || page === currentPage) && !force) return false; type = currentType,
page = currentPage,
force = false,
) => {
if (
(!type || type === currentType) &&
(!page || page === currentPage) &&
!force
)
return false;
return httpStore.fetch({ type, page }, force, provider); return httpStore.fetch({ type, page }, force, provider);
} };
const refresh = async () => fetch(currentType, currentPage, true); const refresh = async () => fetch(currentType, currentPage, true);
@ -38,6 +52,5 @@ export default (type = 'global', page = 1, initialState = null, initialStateType
refresh, refresh,
getType: () => currentType, getType: () => currentType,
getPage: () => currentPage, getPage: () => currentPage,
} };
} };

@ -1,26 +1,34 @@
import createHttpStore from './http-store'; import createHttpStore from "./http-store";
import beatMapsEnhancer from './enhancers/common/beatmaps' import beatMapsEnhancer from "./enhancers/common/beatmaps";
import accEnhancer from './enhancers/scores/acc' import accEnhancer from "./enhancers/scores/acc";
import beatSaviorEnhancer from './enhancers/scores/beatsavior' import beatSaviorEnhancer from "./enhancers/scores/beatsavior";
import rankedsEnhancer from './enhancers/leaderboard/rankeds' import rankedsEnhancer from "./enhancers/leaderboard/rankeds";
import compareEnhancer from './enhancers/scores/compare' import compareEnhancer from "./enhancers/scores/compare";
import diffEnhancer from './enhancers/scores/diff' import diffEnhancer from "./enhancers/scores/diff";
import twitchEnhancer from './enhancers/scores/twitch' import twitchEnhancer from "./enhancers/scores/twitch";
import ppAttributionEnhancer from './enhancers/scores/pp-attribution' import ppAttributionEnhancer from "./enhancers/scores/pp-attribution";
import {debounce} from '../../utils/debounce' import { debounce } from "../../utils/debounce";
import createApiScoresProvider from './providers/api-scores' import createApiScoresProvider from "./providers/api-scores";
import produce, {applyPatches} from 'immer' import produce, { applyPatches } from "immer";
import stringify from 'json-stable-stringify' import stringify from "json-stable-stringify";
export default (playerId = null, service = 'scoresaber', serviceParams = {type: 'recent', page: 1}, initialState = null, initialStateType = 'initial') => { export default (
playerId = null,
service = "scoresaber",
serviceParams = { type: "recent", page: 1 },
initialState = null,
initialStateType = "initial",
) => {
let currentPlayerId = playerId; let currentPlayerId = playerId;
let currentService = service; let currentService = service;
let currentServiceParams = serviceParams; let currentServiceParams = serviceParams;
let totalScores = null; let totalScores = null;
const getCurrentEnhanceTaskId = () => `${currentPlayerId}/${currentService}/${stringify(currentServiceParams)}`; const getCurrentEnhanceTaskId = () =>
const getPatchId = (playerId, scoreRow) => `${playerId}/${scoreRow?.leaderboard?.leaderboardId}` `${currentPlayerId}/${currentService}/${stringify(currentServiceParams)}`;
const getPatchId = (playerId, scoreRow) =>
`${playerId}/${scoreRow?.leaderboard?.leaderboardId}`;
let enhancePatches = {}; let enhancePatches = {};
let currentEnhanceTaskId = null; let currentEnhanceTaskId = null;
@ -34,7 +42,7 @@ export default (playerId = null, service = 'scoresaber', serviceParams = {type:
totalScores = state !== null ? null : 0; totalScores = state !== null ? null : 0;
return state; return state;
} };
const onNewData = ({ fetchParams, state, stateType, set }) => { const onNewData = ({ fetchParams, state, stateType, set }) => {
currentPlayerId = fetchParams?.playerId ?? null; currentPlayerId = fetchParams?.playerId ?? null;
@ -50,15 +58,16 @@ export default (playerId = null, service = 'scoresaber', serviceParams = {type:
const enhanceTaskId = getCurrentEnhanceTaskId(); const enhanceTaskId = getCurrentEnhanceTaskId();
if (currentEnhanceTaskId !== enhanceTaskId) { if (currentEnhanceTaskId !== enhanceTaskId) {
enhancePatches = {} enhancePatches = {};
currentEnhanceTaskId = enhanceTaskId; currentEnhanceTaskId = enhanceTaskId;
} }
const stateProduce = (state, patchId, producer) => produce(state, producer, patches => { const stateProduce = (state, patchId, producer) =>
produce(state, producer, (patches) => {
if (!enhancePatches[patchId]) enhancePatches[patchId] = []; if (!enhancePatches[patchId]) enhancePatches[patchId] = [];
enhancePatches[patchId].push(...patches) enhancePatches[patchId].push(...patches);
}) });
const debouncedSetState = debounce((enhanceTaskId, state) => { const debouncedSetState = debounce((enhanceTaskId, state) => {
if (currentEnhanceTaskId !== enhanceTaskId) return; if (currentEnhanceTaskId !== enhanceTaskId) return;
@ -71,48 +80,98 @@ export default (playerId = null, service = 'scoresaber', serviceParams = {type:
const setStateRow = (enhanceTaskId, scoreRow) => { const setStateRow = (enhanceTaskId, scoreRow) => {
if (currentEnhanceTaskId !== enhanceTaskId) return null; if (currentEnhanceTaskId !== enhanceTaskId) return null;
const patchId = getPatchId(currentPlayerId, scoreRow) const patchId = getPatchId(currentPlayerId, scoreRow);
const stateRowIdx = newState.findIndex(s => getPatchId(currentPlayerId, s) === patchId) const stateRowIdx = newState.findIndex(
(s) => getPatchId(currentPlayerId, s) === patchId,
);
if (stateRowIdx < 0) return; if (stateRowIdx < 0) return;
newState[stateRowIdx] = applyPatches(newState[stateRowIdx], enhancePatches[patchId]); newState[stateRowIdx] = applyPatches(
newState[stateRowIdx],
enhancePatches[patchId],
);
debouncedSetState(enhanceTaskId, newState); debouncedSetState(enhanceTaskId, newState);
return newState[stateRowIdx]; return newState[stateRowIdx];
} };
for (const scoreRow of newState) { for (const scoreRow of newState) {
if (currentService !== 'accsaber') { if (currentService !== "accsaber") {
stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => beatMapsEnhancer(draft)) stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), (draft) =>
.then(scoreRow => stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => accEnhancer(draft))) beatMapsEnhancer(draft),
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) )
.then(scoreRow => stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => diffEnhancer(draft, currentPlayerId))) .then((scoreRow) =>
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) stateProduce(
.then(scoreRow => stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => compareEnhancer(draft, currentPlayerId))) scoreRow,
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) getPatchId(currentPlayerId, scoreRow),
.then(scoreRow => stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => twitchEnhancer(draft, currentPlayerId))) (draft) => accEnhancer(draft),
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) ),
)
.then((scoreRow) => setStateRow(enhanceTaskId, scoreRow))
.then((scoreRow) =>
stateProduce(
scoreRow,
getPatchId(currentPlayerId, scoreRow),
(draft) => diffEnhancer(draft, currentPlayerId),
),
)
.then((scoreRow) => setStateRow(enhanceTaskId, scoreRow))
.then((scoreRow) =>
stateProduce(
scoreRow,
getPatchId(currentPlayerId, scoreRow),
(draft) => compareEnhancer(draft, currentPlayerId),
),
)
.then((scoreRow) => setStateRow(enhanceTaskId, scoreRow))
.then((scoreRow) =>
stateProduce(
scoreRow,
getPatchId(currentPlayerId, scoreRow),
(draft) => twitchEnhancer(draft, currentPlayerId),
),
)
.then((scoreRow) => setStateRow(enhanceTaskId, scoreRow));
stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => rankedsEnhancer(draft)) stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), (draft) =>
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) rankedsEnhancer(draft),
).then((scoreRow) => setStateRow(enhanceTaskId, scoreRow));
stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => ppAttributionEnhancer(draft, currentPlayerId)) stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), (draft) =>
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) ppAttributionEnhancer(draft, currentPlayerId),
).then((scoreRow) => setStateRow(enhanceTaskId, scoreRow));
if (stateType && stateType === 'live') if (stateType && stateType === "live")
stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => beatSaviorEnhancer(draft, currentPlayerId)) stateProduce(
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) scoreRow,
getPatchId(currentPlayerId, scoreRow),
(draft) => beatSaviorEnhancer(draft, currentPlayerId),
).then((scoreRow) => setStateRow(enhanceTaskId, scoreRow));
} else { } else {
stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => beatMapsEnhancer(draft)) stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), (draft) =>
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) beatMapsEnhancer(draft),
.then(scoreRow => stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => twitchEnhancer(draft, currentPlayerId))) )
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) .then((scoreRow) => setStateRow(enhanceTaskId, scoreRow))
.then(scoreRow => stateProduce(scoreRow, getPatchId(currentPlayerId, scoreRow), draft => beatSaviorEnhancer(draft, currentPlayerId))) .then((scoreRow) =>
.then(scoreRow => setStateRow(enhanceTaskId, scoreRow)) stateProduce(
} scoreRow,
getPatchId(currentPlayerId, scoreRow),
(draft) => twitchEnhancer(draft, currentPlayerId),
),
)
.then((scoreRow) => setStateRow(enhanceTaskId, scoreRow))
.then((scoreRow) =>
stateProduce(
scoreRow,
getPatchId(currentPlayerId, scoreRow),
(draft) => beatSaviorEnhancer(draft, currentPlayerId),
),
)
.then((scoreRow) => setStateRow(enhanceTaskId, scoreRow));
} }
} }
};
const provider = createApiScoresProvider(); const provider = createApiScoresProvider();
@ -126,22 +185,34 @@ export default (playerId = null, service = 'scoresaber', serviceParams = {type:
onAfterStateChange: onNewData, onAfterStateChange: onNewData,
onSetPending: ({ fetchParams }) => ({ ...fetchParams }), onSetPending: ({ fetchParams }) => ({ ...fetchParams }),
}, },
initialStateType initialStateType,
); );
const fetch = async (serviceParams = currentServiceParams, service = currentService, playerId = currentPlayerId, force = false) => { const fetch = async (
serviceParams = currentServiceParams,
service = currentService,
playerId = currentPlayerId,
force = false,
) => {
if ( if (
(!playerId || playerId === currentPlayerId) && (!playerId || playerId === currentPlayerId) &&
(!service || stringify(service) === stringify(currentService)) && (!service || stringify(service) === stringify(currentService)) &&
(!serviceParams || stringify(serviceParams) === stringify(currentServiceParams)) && (!serviceParams ||
stringify(serviceParams) === stringify(currentServiceParams)) &&
!force !force
) )
return false; return false;
return httpStore.fetch({playerId, service, serviceParams}, force, provider, !playerId || playerId !== currentPlayerId || force); return httpStore.fetch(
} { playerId, service, serviceParams },
force,
provider,
!playerId || playerId !== currentPlayerId || force,
);
};
const refresh = async () => fetch(currentServiceParams, currentService, currentPlayerId, true); const refresh = async () =>
fetch(currentServiceParams, currentService, currentPlayerId, true);
return { return {
...httpStore, ...httpStore,
@ -151,6 +222,5 @@ export default (playerId = null, service = 'scoresaber', serviceParams = {type:
getService: () => currentService, getService: () => currentService,
getServiceParams: () => currentServiceParams, getServiceParams: () => currentServiceParams,
getTotalScores: () => totalScores, getTotalScores: () => totalScores,
} };
} };

Some files were not shown because too many files have changed in this diff Show More