diff --git a/src/lib/game-tracker/OsuGoals.svelte b/src/lib/game-tracker/OsuGoals.svelte
index d5f9144..529bd5b 100644
--- a/src/lib/game-tracker/OsuGoals.svelte
+++ b/src/lib/game-tracker/OsuGoals.svelte
@@ -156,7 +156,11 @@
{#if g.starsHr != null}HR {nf(g.starsHr, 2)}★{/if}
- {nf(g.bpm)}
+ = 1
+ ? `BPM ${nf(g.bpmMin)}–${nf(g.bpmMax)}`
+ : 'BPM'}>{nf(g.bpm)}
{formatLength(g.lengthSeconds)}
diff --git a/src/lib/game-tracker/osu-goals.test.ts b/src/lib/game-tracker/osu-goals.test.ts
index 96f29ae..10b544d 100644
--- a/src/lib/game-tracker/osu-goals.test.ts
+++ b/src/lib/game-tracker/osu-goals.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
bestScore,
+ bpmRange,
focusTags,
goalAchieved,
goalProgress,
@@ -21,6 +22,21 @@ const osu = (objects: string[]) =>
...objects
].join('\n');
+describe('bpmRange', () => {
+ it('reads uninherited timing points only', () => {
+ const text = [
+ '[TimingPoints]',
+ '0,300,4,2,0,60,1,0',
+ '1000,-50,4,2,0,60,0,0',
+ '2000,500,4,2,0,60,1,0',
+ '',
+ '[HitObjects]'
+ ].join('\n');
+ expect(bpmRange(text)).toEqual({ min: 120, max: 200 });
+ expect(bpmRange('osu file format v14')).toBe(null);
+ });
+});
+
describe('streamStats', () => {
it('counts runs of circles a 1/4 beat apart', () => {
// 200 BPM: beat 300 ms, 1/4 = 75 ms. A run of 6, a gap, then a run of 3.
diff --git a/src/lib/game-tracker/osu-goals.ts b/src/lib/game-tracker/osu-goals.ts
index 5c6a267..9304261 100644
--- a/src/lib/game-tracker/osu-goals.ts
+++ b/src/lib/game-tracker/osu-goals.ts
@@ -22,6 +22,21 @@ export const FAST_BPM = 200;
* Streams in a .osu file: consecutive circles at most a 1/4 beat apart (5% slack) form a run.
* Returns the longest run and the number of circles in runs of 5 or more.
*/
+/** BPM range from the uninherited timing points of an .osu file (null when there are none). */
+export function bpmRange(osu: string): { min: number; max: number } | null {
+ const start = osu.indexOf('[TimingPoints]');
+ if (start < 0) return null;
+ const rest = osu.slice(start + 14);
+ const end = rest.search(/^\[/m);
+ const bpms = (end < 0 ? rest : rest.slice(0, end))
+ .split(/\r?\n/)
+ .map((l) => l.trim().split(','))
+ .filter((p) => p.length >= 2 && Number(p[1]) > 0 && (p.length < 7 || p[6] !== '0'))
+ .map((p) => Math.round((60000 / Number(p[1])) * 10) / 10)
+ .filter((b) => Number.isFinite(b) && b > 0);
+ return bpms.length ? { min: Math.min(...bpms), max: Math.max(...bpms) } : null;
+}
+
export function streamStats(osu: string): { longest: number; notes: number } {
const section = (name: string) => {
const start = osu.indexOf(`[${name}]`);
diff --git a/src/lib/server/osu-goals/load.ts b/src/lib/server/osu-goals/load.ts
index 479a443..6ade0fe 100644
--- a/src/lib/server/osu-goals/load.ts
+++ b/src/lib/server/osu-goals/load.ts
@@ -27,6 +27,8 @@ export async function loadOsuGoals() {
version: r.version,
status: r.status,
bpm: r.bpm,
+ bpmMin: r.bpmMin,
+ bpmMax: r.bpmMax,
lengthSeconds: r.lengthSeconds,
ar: r.ar,
od: r.od,
diff --git a/src/lib/server/osu-goals/refresh.ts b/src/lib/server/osu-goals/refresh.ts
index 42901d2..68c4506 100644
--- a/src/lib/server/osu-goals/refresh.ts
+++ b/src/lib/server/osu-goals/refresh.ts
@@ -9,6 +9,7 @@ import {
goalAchieved,
goalProgress,
parseScore,
+ bpmRange,
streamStats,
type OsuScore
} from '#lib/game-tracker/osu-goals.js';
@@ -95,17 +96,21 @@ export async function fetchMetadata(beatmapId: number) {
return typeof v === 'number' ? Math.round(v * 100) / 100 : null;
};
let stream = { longest: null as number | null, notes: null as number | null };
+ let range: { min: number; max: number } | null = null;
try {
const res = await fetch(`https://osu.ppy.sh/osu/${beatmapId}`, {
signal: AbortSignal.timeout(12000)
});
- if (res.ok) stream = streamStats(await res.text());
+ if (res.ok) {
+ const text = await res.text();
+ stream = streamStats(text);
+ range = bpmRange(text);
+ }
} catch {
// Streams just stay unknown.
}
const covers = (set.covers ?? {}) as Json;
const owners = Array.isArray(diff.owners) ? (diff.owners as Json[]) : [];
- const bpms = Array.isArray(diff.bpm_list) ? (diff.bpm_list as number[]) : [];
return {
beatmapsetId: setId,
beatmapId,
@@ -127,8 +132,8 @@ export async function fetchMetadata(beatmapId: number) {
tags: (str(set.tags) ?? '').split(/\s+/).filter(Boolean),
userTags: await communityTags(diff.top_tag_ids),
bpm: num(diff.bpm) ?? num(set.bpm),
- bpmMin: bpms.length ? Math.min(...bpms) : null,
- bpmMax: bpms.length ? Math.max(...bpms) : null,
+ bpmMin: range?.min ?? null,
+ bpmMax: range?.max ?? null,
lengthSeconds: int(diff.total_length),
drainSeconds: int(diff.hit_length),
ar: num(diff.ar),
@@ -173,8 +178,24 @@ export async function fetchMyScores(beatmapId: number): Promise {
}
}
+/** How often I played each of `ids` (users/{id}/beatmapsets/most_played, highest first). */
+export async function fetchMyPlayCounts(ids: Set): Promise