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
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
This commit is contained in:
@@ -156,7 +156,11 @@
|
||||
{#if g.starsHr != null}<span class="dim" title="star rating with HR"
|
||||
>HR {nf(g.starsHr, 2)}★</span
|
||||
>{/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"
|
||||
><Clock size={12} aria-hidden="true" />{formatLength(g.lengthSeconds)}</span
|
||||
>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}]`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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. */
|
||||
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;
|
||||
try {
|
||||
const stale =
|
||||
@@ -199,7 +220,7 @@ export async function refreshGoal(row: OsuGoalRow, force = false) {
|
||||
streamNotes: meta ? meta.streamNotes : row.streamNotes
|
||||
}),
|
||||
scores,
|
||||
myPlayCount: scores.length,
|
||||
myPlayCount: plays ? (plays.get(row.beatmapId) ?? 0) : row.myPlayCount,
|
||||
bestScoreId: best?.id ?? null,
|
||||
bestScore: best?.score ?? null,
|
||||
bestAccuracy: best?.accuracy ?? null,
|
||||
|
||||
Reference in New Issue
Block a user