osu! goals: real play counts from my most-played list (weekly), BPM range from the .osu timing points
All checks were successful
Deploy production / deploy (push) Successful in 1m14s

This commit is contained in:
2026-10-26 00:44:12 +00:00
parent 9f8d54d9e3
commit 5e10ecf525
5 changed files with 65 additions and 7 deletions

View File

@@ -156,7 +156,11 @@
{#if g.starsHr != null}<span class="dim" title="star rating with HR" {#if g.starsHr != null}<span class="dim" title="star rating with HR"
>HR {nf(g.starsHr, 2)}★</span >HR {nf(g.starsHr, 2)}★</span
>{/if} >{/if}
<span title="BPM"><Music size={12} aria-hidden="true" />{nf(g.bpm)}</span> <span
title={g.bpmMin != null && g.bpmMax != null && g.bpmMax - g.bpmMin >= 1
? `BPM ${nf(g.bpmMin)}–${nf(g.bpmMax)}`
: 'BPM'}><Music size={12} aria-hidden="true" />{nf(g.bpm)}</span
>
<span title="length" <span title="length"
><Clock size={12} aria-hidden="true" />{formatLength(g.lengthSeconds)}</span ><Clock size={12} aria-hidden="true" />{formatLength(g.lengthSeconds)}</span
> >

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
bestScore, bestScore,
bpmRange,
focusTags, focusTags,
goalAchieved, goalAchieved,
goalProgress, goalProgress,
@@ -21,6 +22,21 @@ const osu = (objects: string[]) =>
...objects ...objects
].join('\n'); ].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', () => { describe('streamStats', () => {
it('counts runs of circles a 1/4 beat apart', () => { 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. // 200 BPM: beat 300 ms, 1/4 = 75 ms. A run of 6, a gap, then a run of 3.

View File

@@ -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. * 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. * 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 } { export function streamStats(osu: string): { longest: number; notes: number } {
const section = (name: string) => { const section = (name: string) => {
const start = osu.indexOf(`[${name}]`); const start = osu.indexOf(`[${name}]`);

View File

@@ -27,6 +27,8 @@ export async function loadOsuGoals() {
version: r.version, version: r.version,
status: r.status, status: r.status,
bpm: r.bpm, bpm: r.bpm,
bpmMin: r.bpmMin,
bpmMax: r.bpmMax,
lengthSeconds: r.lengthSeconds, lengthSeconds: r.lengthSeconds,
ar: r.ar, ar: r.ar,
od: r.od, od: r.od,

View File

@@ -9,6 +9,7 @@ import {
goalAchieved, goalAchieved,
goalProgress, goalProgress,
parseScore, parseScore,
bpmRange,
streamStats, streamStats,
type OsuScore type OsuScore
} from '#lib/game-tracker/osu-goals.js'; } 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; return typeof v === 'number' ? Math.round(v * 100) / 100 : null;
}; };
let stream = { longest: null as number | null, notes: null as number | null }; let stream = { longest: null as number | null, notes: null as number | null };
let range: { min: number; max: number } | null = null;
try { try {
const res = await fetch(`https://osu.ppy.sh/osu/${beatmapId}`, { const res = await fetch(`https://osu.ppy.sh/osu/${beatmapId}`, {
signal: AbortSignal.timeout(12000) 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 { } catch {
// Streams just stay unknown. // Streams just stay unknown.
} }
const covers = (set.covers ?? {}) as Json; const covers = (set.covers ?? {}) as Json;
const owners = Array.isArray(diff.owners) ? (diff.owners 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 { return {
beatmapsetId: setId, beatmapsetId: setId,
beatmapId, beatmapId,
@@ -127,8 +132,8 @@ export async function fetchMetadata(beatmapId: number) {
tags: (str(set.tags) ?? '').split(/\s+/).filter(Boolean), tags: (str(set.tags) ?? '').split(/\s+/).filter(Boolean),
userTags: await communityTags(diff.top_tag_ids), userTags: await communityTags(diff.top_tag_ids),
bpm: num(diff.bpm) ?? num(set.bpm), bpm: num(diff.bpm) ?? num(set.bpm),
bpmMin: bpms.length ? Math.min(...bpms) : null, bpmMin: range?.min ?? null,
bpmMax: bpms.length ? Math.max(...bpms) : null, bpmMax: range?.max ?? null,
lengthSeconds: int(diff.total_length), lengthSeconds: int(diff.total_length),
drainSeconds: int(diff.hit_length), drainSeconds: int(diff.hit_length),
ar: num(diff.ar), ar: num(diff.ar),
@@ -173,8 +178,24 @@ export async function fetchMyScores(beatmapId: number): Promise<OsuScore[]> {
} }
} }
/** How often I played each of `ids` (users/{id}/beatmapsets/most_played, highest first). */
export async function fetchMyPlayCounts(ids: Set<number>): Promise<Map<number, number>> {
const found = new Map<number, number>();
for (let offset = 0; offset < 20000 && found.size < ids.size; offset += 100) {
const page = (await api(
`users/${OSU_USER_ID}/beatmapsets/most_played?limit=100&offset=${offset}`
)) as Json[];
if (!Array.isArray(page) || !page.length) break;
for (const p of page) {
const id = int(p.beatmap_id);
if (id && ids.has(id)) found.set(id, int(p.count) ?? 0);
}
}
return found;
}
/** Refresh one goal: metadata when missing or older than a week (or `force`), always scores. */ /** Refresh one goal: metadata when missing or older than a week (or `force`), always scores. */
export async function refreshGoal(row: OsuGoalRow, force = false) { export async function refreshGoal(row: OsuGoalRow, force = false, plays?: Map<number, number>) {
if (row.matchStatus !== 'matched' || !row.beatmapId) return row; if (row.matchStatus !== 'matched' || !row.beatmapId) return row;
try { try {
const stale = const stale =
@@ -199,7 +220,7 @@ export async function refreshGoal(row: OsuGoalRow, force = false) {
streamNotes: meta ? meta.streamNotes : row.streamNotes streamNotes: meta ? meta.streamNotes : row.streamNotes
}), }),
scores, scores,
myPlayCount: scores.length, myPlayCount: plays ? (plays.get(row.beatmapId) ?? 0) : row.myPlayCount,
bestScoreId: best?.id ?? null, bestScoreId: best?.id ?? null,
bestScore: best?.score ?? null, bestScore: best?.score ?? null,
bestAccuracy: best?.accuracy ?? null, bestAccuracy: best?.accuracy ?? null,