refactor utils

This commit is contained in:
pleb 2025-10-29 11:54:00 -07:00
parent ef2db550db
commit 0a031469cc
5 changed files with 572 additions and 349 deletions

View File

@ -4,13 +4,10 @@
"path": "." "path": "."
}, },
{ {
"path": "../../../src/beatleader-website" "path": "../../../src/beatleader/beatleader-website"
}, },
{ {
"path": "../../../src/beatleader-server" "path": "../../../src/beatleader/beatleader-server"
},
{
"path": "../../../src/beatleader-mod"
} }
], ],
"settings": {} "settings": {}

View File

@ -29,7 +29,7 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else}
<div class="h-full w-full flex items-center justify-center text-xs text-muted">No cover</div> <div class="h-full w-full flex items-center justify-center text-2xl">☁️</div>
{/if} {/if}
</div> </div>
<div class="p-3"> <div class="p-3">

View File

@ -0,0 +1,486 @@
/**
* Shared utilities for PlebSaber tools
*/
// ============================================================================
// 1. Type Definitions
// ============================================================================
export type MapMeta = {
songName?: string;
key?: string;
coverURL?: string;
mapper?: string;
};
export type StarInfo = {
stars?: number;
accRating?: number;
passRating?: number;
techRating?: number;
status?: number;
};
export type BeatLeaderScore = {
timeset?: string | number;
accuracy?: number;
acc?: number;
rank?: number;
leaderboard?: {
id?: string | number | null;
leaderboardId?: string | number | null;
song?: { hash?: string | null };
difficulty?: { value?: number | string | null; modeName?: string | null };
};
};
export type BeatLeaderScoresResponse = {
data?: BeatLeaderScore[];
metadata?: { page?: number; itemsPerPage?: number; total?: number };
};
export type Difficulty = {
name: string;
characteristic: string;
};
// ============================================================================
// 2. Constants
// ============================================================================
export const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
export const DIFFICULTIES = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'] as const;
export const MODES = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'] as const;
// ============================================================================
// 3. BeatSaver & BeatLeader API Functions
// ============================================================================
/**
* Fetch BeatSaver metadata for a given song hash
*/
export async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> {
try {
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
if (!res.ok) throw new Error(String(res.status));
const data: any = await res.json();
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg`;
return {
songName: data?.metadata?.songName ?? data?.name ?? undefined,
key: data?.id ?? undefined,
coverURL: cover,
mapper: data?.uploader?.name ?? undefined
};
} catch {
// Fallback to CDN cover only
return { coverURL: `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg` };
}
}
/**
* Fetch BeatLeader star ratings for a given song hash
* Returns a map keyed by `${hash}|${difficultyName}|${modeName}`
*/
export async function fetchBeatLeaderStarsByHash(
hash: string,
normalizeDifficultyName: (value: number | string | null | undefined) => string
): Promise<Record<string, StarInfo>> {
try {
const res = await fetch(`/api/beatleader?path=/leaderboards/hash/${encodeURIComponent(hash)}`);
if (!res.ok) return {};
const data: any = await res.json();
const leaderboards: any[] = Array.isArray(data?.leaderboards) ? data.leaderboards : Array.isArray(data) ? data : [];
const result: Record<string, StarInfo> = {};
for (const lb of leaderboards) {
const diffName: string | undefined = lb?.difficulty?.difficultyName ?? lb?.difficulty?.name ?? undefined;
const modeName: string | undefined = lb?.difficulty?.modeName ?? lb?.modeName ?? 'Standard';
if (!diffName || !modeName) continue;
const normalized = normalizeDifficultyName(diffName);
const key = `${hash}|${normalized}|${modeName}`;
const info: StarInfo = {
stars: lb?.difficulty?.stars ?? lb?.stars,
accRating: lb?.difficulty?.accRating,
passRating: lb?.difficulty?.passRating,
techRating: lb?.difficulty?.techRating,
status: lb?.difficulty?.status
};
result[key] = info;
}
return result;
} catch {
return {};
}
}
/**
* Load metadata for a list of items with unique hashes
* Only loads metadata that isn't already in the cache
*/
export async function loadMetaForHashes(
hashes: string[],
existingCache: Record<string, MapMeta>,
onProgress?: (loaded: number, total: number) => void
): Promise<Record<string, MapMeta>> {
const uniqueHashes = Array.from(new Set(hashes));
const needed = uniqueHashes.filter((h) => !existingCache[h]);
if (needed.length === 0) return existingCache;
const newCache = { ...existingCache };
for (let i = 0; i < needed.length; i++) {
const h = needed[i];
const meta = await fetchBeatSaverMeta(h);
if (meta) newCache[h] = meta;
if (onProgress) onProgress(i + 1, needed.length);
}
return newCache;
}
/**
* Load star ratings for a list of hashes
* Only loads ratings that aren't already in the cache
*/
export async function loadStarsForHashes(
hashes: string[],
existingCache: Record<string, StarInfo>,
normalizeFn: (value: number | string | null | undefined) => string,
onProgress?: (loaded: number, total: number) => void
): Promise<Record<string, StarInfo>> {
const uniqueHashes = Array.from(new Set(hashes));
// Check if we need to load stars for these hashes
const needed = uniqueHashes.filter((h) => {
// Check if we have any star data for this hash
return !Object.keys(existingCache).some(key => key.startsWith(`${h}|`));
});
if (needed.length === 0) return existingCache;
const newCache = { ...existingCache };
for (let i = 0; i < needed.length; i++) {
const h = needed[i];
const stars = await fetchBeatLeaderStarsByHash(h, normalizeFn);
Object.assign(newCache, stars);
if (onProgress) onProgress(i + 1, needed.length);
}
return newCache;
}
/**
* Fetch recent scores for a player filtered by difficulty
*/
export async function fetchAllRecentScoresForDiff(
playerId: string,
cutoffEpoch: number,
reqDiff: string,
maxPages = 100
): Promise<BeatLeaderScore[]> {
const qs = new URLSearchParams({ diff: reqDiff, cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
const data = (await res.json()) as BeatLeaderScoresResponse;
return data.data ?? [];
}
/**
* Fetch recent scores for a player across all difficulties
*/
export async function fetchAllRecentScoresAllDiffs(
playerId: string,
cutoffEpoch: number,
normalizeFn: (value: number | string | null | undefined) => string,
parseFn: (ts: string | number | undefined) => number
): Promise<BeatLeaderScore[]> {
const arrays = await Promise.all(
DIFFICULTIES.map((d) => fetchAllRecentScoresForDiff(playerId, cutoffEpoch, d))
);
// Merge and dedupe by leaderboard key (hash|diff|mode) and timeset
const merged = new Map<string, BeatLeaderScore>();
for (const arr of arrays) {
for (const s of arr) {
const rawHash = s.leaderboard?.song?.hash ?? undefined;
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
if (!rawHash) continue;
const hashLower = String(rawHash).toLowerCase();
const diffName = normalizeFn(s.leaderboard?.difficulty?.value ?? undefined);
const key = `${hashLower}|${diffName}|${modeName}`;
const prev = merged.get(key);
if (!prev || parseFn(prev.timeset) < parseFn(s.timeset)) merged.set(key, s);
}
}
return Array.from(merged.values());
}
// ============================================================================
// 4. Data Processing Helpers
// ============================================================================
/**
* Normalize difficulty names to standard format
*/
export function normalizeDifficultyName(value: number | string | null | undefined): string {
if (value === null || value === undefined) return 'ExpertPlus';
if (typeof value === 'string') {
const v = value.toLowerCase();
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
if (v.includes('expert')) return 'Expert';
if (v.includes('hard')) return 'Hard';
if (v.includes('normal')) return 'Normal';
if (v.includes('easy')) return 'Easy';
return value;
}
switch (value) {
case 1: return 'Easy';
case 3: return 'Normal';
case 5: return 'Hard';
case 7: return 'Expert';
case 9: return 'ExpertPlus';
default: return 'ExpertPlus';
}
}
/**
* Parse a timeset value to a number
*/
export function parseTimeset(ts: string | number | undefined): number {
if (ts === undefined) return 0;
if (typeof ts === 'number') return ts;
const n = Number(ts);
return Number.isFinite(n) ? n : 0;
}
/**
* Get cutoff epoch timestamp from months ago
*/
export function getCutoffEpochFromMonths(months: number | string): number {
const m = Number(months) || 0;
const seconds = Math.max(0, m) * 30 * 24 * 60 * 60; // approx 30 days per month
return Math.floor(Date.now() / 1000) - seconds;
}
/**
* Normalize accuracy value to percentage (0-100)
*/
export function normalizeAccuracy(value: number | undefined): number | null {
if (value === undefined || value === null) return null;
return value <= 1 ? value * 100 : value;
}
/**
* Build a map of latest scores by key (hash|diff|mode)
*/
export function buildLatestByKey(
scores: BeatLeaderScore[],
cutoffEpoch: number,
normalizeFn: (value: number | string | null | undefined) => string,
parseFn: (ts: string | number | undefined) => number
): Map<string, BeatLeaderScore> {
const byKey = new Map<string, BeatLeaderScore>();
for (const s of scores) {
const t = parseFn(s.timeset);
if (!t || t < cutoffEpoch - ONE_YEAR_SECONDS) continue; // sanity guard
const rawHash = s.leaderboard?.song?.hash ?? undefined;
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
if (!rawHash) continue;
const hashLower = String(rawHash).toLowerCase();
const diffName = normalizeFn(s.leaderboard?.difficulty?.value ?? undefined);
const key = `${hashLower}|${diffName}|${modeName}`;
const prev = byKey.get(key);
if (!prev || parseFn(prev.timeset) < t) byKey.set(key, s);
}
return byKey;
}
// ============================================================================
// 5. Statistical Functions
// ============================================================================
/**
* Calculate the mean (average) of an array of numbers
*/
export function mean(values: number[]): number {
if (!values.length) return 0;
return values.reduce((a, b) => a + b, 0) / values.length;
}
/**
* Calculate the median of an array of numbers
*/
export function median(values: number[]): number {
if (!values.length) return 0;
const v = [...values].sort((a, b) => a - b);
const mid = Math.floor(v.length / 2);
return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
}
/**
* Calculate a percentile of an array of numbers
*/
export function percentile(values: number[], p: number): number {
if (!values.length) return 0;
const v = [...values].sort((a, b) => a - b);
const idx = Math.min(v.length - 1, Math.max(0, Math.floor((p / 100) * (v.length - 1))));
return v[idx];
}
// ============================================================================
// 6. Playlist Generation
// ============================================================================
/**
* Increment and return the playlist count for a given key
*/
export function incrementPlaylistCount(key: string): number {
try {
const raw = localStorage.getItem('playlist_counts');
const obj = raw ? (JSON.parse(raw) as Record<string, number>) : {};
const next = (obj[key] ?? 0) + 1;
obj[key] = next;
localStorage.setItem('playlist_counts', JSON.stringify(obj));
return next;
} catch {
return 1;
}
}
/**
* Convert a list of songs to Beat Saber playlist JSON format
*/
export function toPlaylistJson(
songs: Array<{ hash: string; difficulties: Difficulty[] }>,
playlistKey: string,
description: string
): unknown {
const count = incrementPlaylistCount(playlistKey);
const playlistTitle = `${playlistKey}-${String(count).padStart(2, '0')}`;
return {
playlistTitle,
playlistAuthor: 'SaberList Tool',
songs: songs.map((s) => ({
hash: s.hash,
difficulties: s.difficulties,
})),
description,
allowDuplicates: false,
customData: {}
};
}
/**
* Download a playlist as a .bplist file
*/
export function downloadPlaylist(playlistData: unknown): void {
const title = (playlistData as any).playlistTitle ?? 'playlist';
const blob = new Blob([JSON.stringify(playlistData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title}.bplist`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// ============================================================================
// 7. Pagination Utilities
// ============================================================================
export type PaginationResult<T> = {
pageItems: T[];
totalPages: number;
validPage: number;
};
/**
* Calculate pagination for a list of items
*/
export function calculatePagination<T>(
items: T[],
page: number,
pageSize: number
): PaginationResult<T> {
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const validPage = Math.min(Math.max(1, page), totalPages);
const startIdx = (validPage - 1) * pageSize;
const endIdx = startIdx + pageSize;
const pageItems = items.slice(startIdx, endIdx);
return {
pageItems,
totalPages,
validPage
};
}
// ============================================================================
// 8. URL Parameter Utilities
// ============================================================================
/**
* Parse URL search parameters with type safety
*/
export function getUrlParam(
searchParams: URLSearchParams,
key: string,
defaultValue?: string
): string | undefined {
return searchParams.get(key) ?? defaultValue;
}
/**
* Parse a URL parameter as a number
*/
export function getUrlParamAsNumber(
searchParams: URLSearchParams,
key: string,
defaultValue: number
): number {
const value = searchParams.get(key);
if (!value) return defaultValue;
const num = Number(value);
return Number.isFinite(num) ? num : defaultValue;
}
/**
* Parse a URL parameter as one of a set of allowed values
*/
export function getUrlParamAsEnum<T extends string>(
searchParams: URLSearchParams,
key: string,
allowedValues: readonly T[],
defaultValue: T
): T {
const value = searchParams.get(key);
if (!value) return defaultValue;
return (allowedValues as readonly string[]).includes(value) ? (value as T) : defaultValue;
}
/**
* Update URL without triggering navigation
*/
export function updateUrlParams(
params: Record<string, string | number | boolean | undefined | null>,
replace = true
): void {
const sp = new URLSearchParams(window.location.search);
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '' || value === false) {
sp.delete(key);
} else {
sp.set(key, String(value));
}
}
const qs = sp.toString();
const url = window.location.pathname + (qs ? `?${qs}` : '');
if (replace) {
window.history.replaceState(null, '', url);
} else {
window.history.pushState(null, '', url);
}
}

View File

@ -1,27 +1,21 @@
<script lang="ts"> <script lang="ts">
import MapCard from '$lib/components/MapCard.svelte'; import MapCard from '$lib/components/MapCard.svelte';
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte'; import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
import {
type BeatLeaderScore = { type MapMeta,
timeset?: string | number; type StarInfo,
leaderboard?: { type BeatLeaderScore,
// BeatLeader tends to expose a short id for the leaderboard route type BeatLeaderScoresResponse,
id?: string | number | null; type Difficulty,
leaderboardId?: string | number | null; loadMetaForHashes,
song?: { hash?: string | null }; loadStarsForHashes,
difficulty?: { value?: number | string | null; modeName?: string | null }; normalizeDifficultyName,
}; parseTimeset,
}; getCutoffEpochFromMonths,
toPlaylistJson,
type BeatLeaderScoresResponse = { downloadPlaylist,
data?: BeatLeaderScore[]; ONE_YEAR_SECONDS
metadata?: { page?: number; itemsPerPage?: number; total?: number }; } from '$lib/utils/plebsaber-utils';
};
type Difficulty = {
name: string;
characteristic: string;
};
type SongItem = { type SongItem = {
hash: string; hash: string;
@ -30,8 +24,6 @@
leaderboardId?: string; leaderboardId?: string;
}; };
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
let playerA = ''; let playerA = '';
let playerB = ''; let playerB = '';
let loading = false; let loading = false;
@ -66,131 +58,30 @@
$: page = Math.min(page, totalPages); $: page = Math.min(page, totalPages);
$: pageItems = sortedResults.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum); $: pageItems = sortedResults.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum);
type MapMeta = { // Metadata caches
songName?: string;
key?: string;
coverURL?: string;
mapper?: string;
};
let metaByHash: Record<string, MapMeta> = {}; let metaByHash: Record<string, MapMeta> = {};
type StarInfo = {
stars?: number;
accRating?: number;
passRating?: number;
techRating?: number;
status?: number;
};
// Keyed by `${hash}|${difficultyName}|${modeName}` for precise lookup
let starsByKey: Record<string, StarInfo> = {}; let starsByKey: Record<string, StarInfo> = {};
let loadingStars = false; let loadingStars = false;
async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> { // Lazy load metadata when pageItems changes
try { $: if (pageItems.length > 0) {
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`); loadPageMetadata(pageItems);
if (!res.ok) throw new Error(String(res.status));
const data: any = await res.json();
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg`;
return {
songName: data?.metadata?.songName ?? data?.name ?? undefined,
key: data?.id ?? undefined,
coverURL: cover,
mapper: data?.uploader?.name ?? undefined
};
} catch {
// Fallback to CDN cover only
return { coverURL: `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg` };
}
} }
async function loadMetaForResults(items: SongItem[]): Promise<void> { async function loadPageMetadata(items: SongItem[]): Promise<void> {
const needed = Array.from(new Set(items.map((i) => i.hash))).filter((h) => !metaByHash[h]); const hashes = items.map(i => i.hash);
if (needed.length === 0) return;
// Load BeatSaver metadata
loadingMeta = true; loadingMeta = true;
for (const h of needed) { metaByHash = await loadMetaForHashes(hashes, metaByHash);
const meta = await fetchBeatSaverMeta(h);
if (meta) metaByHash = { ...metaByHash, [h]: meta };
}
loadingMeta = false; loadingMeta = false;
}
async function fetchBeatLeaderStarsByHash(hash: string): Promise<void> { // Load star ratings
try {
const res = await fetch(`/api/beatleader?path=/leaderboards/hash/${encodeURIComponent(hash)}`);
if (!res.ok) return;
const data: any = await res.json();
const leaderboards: any[] = Array.isArray(data?.leaderboards) ? data.leaderboards : Array.isArray(data) ? data : [];
for (const lb of leaderboards) {
const diffName: string | undefined = lb?.difficulty?.difficultyName ?? lb?.difficulty?.name ?? undefined;
const modeName: string | undefined = lb?.difficulty?.modeName ?? lb?.modeName ?? 'Standard';
if (!diffName || !modeName) continue;
const normalized = normalizeDifficultyName(diffName);
const key = `${hash}|${normalized}|${modeName}`;
const info: StarInfo = {
stars: lb?.difficulty?.stars ?? lb?.stars,
accRating: lb?.difficulty?.accRating,
passRating: lb?.difficulty?.passRating,
techRating: lb?.difficulty?.techRating,
status: lb?.difficulty?.status
};
starsByKey = { ...starsByKey, [key]: info };
}
} catch {
// ignore
}
}
async function loadStarsForResults(items: SongItem[]): Promise<void> {
const neededHashes = Array.from(new Set(items.map((i) => i.hash)));
if (neededHashes.length === 0) return;
loadingStars = true; loadingStars = true;
for (const h of neededHashes) { starsByKey = await loadStarsForHashes(hashes, starsByKey, normalizeDifficultyName);
await fetchBeatLeaderStarsByHash(h);
}
loadingStars = false; loadingStars = false;
} }
function normalizeDifficultyName(value: number | string | null | undefined): string {
if (value === null || value === undefined) return 'ExpertPlus';
if (typeof value === 'string') {
const v = value.toLowerCase();
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
if (v.includes('expert')) return 'Expert';
if (v.includes('hard')) return 'Hard';
if (v.includes('normal')) return 'Normal';
if (v.includes('easy')) return 'Easy';
return value;
}
switch (value) {
case 1:
return 'Easy';
case 3:
return 'Normal';
case 5:
return 'Hard';
case 7:
return 'Expert';
case 9:
return 'ExpertPlus';
default:
return 'ExpertPlus';
}
}
function parseTimeset(ts: string | number | undefined): number {
if (ts === undefined) return 0;
if (typeof ts === 'number') return ts;
const n = Number(ts);
return Number.isFinite(n) ? n : 0;
}
function getCutoffEpochFromMonths(months: number | string): number {
const m = Number(months) || 0;
const seconds = Math.max(0, m) * 30 * 24 * 60 * 60; // approx 30 days per month
return Math.floor(Date.now() / 1000) - seconds;
}
async function fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise<BeatLeaderScore[]> { async function fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise<BeatLeaderScore[]> {
const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) }); const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`; const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
@ -203,48 +94,13 @@
function incrementPlaylistCount(): number { function handleDownloadPlaylist(): void {
try { const payload = toPlaylistJson(
const raw = localStorage.getItem('playlist_counts'); results,
const obj = raw ? (JSON.parse(raw) as Record<string, number>) : {}; 'beatleader_compare_players',
const key = 'beatleader_compare_players'; `A's recent songs not played by B. Generated ${new Date().toISOString()}`
const next = (obj[key] ?? 0) + 1; );
obj[key] = next; downloadPlaylist(payload);
localStorage.setItem('playlist_counts', JSON.stringify(obj));
return next;
} catch {
return 1;
}
}
function toPlaylistJson(songs: SongItem[]): unknown {
const count = incrementPlaylistCount();
const playlistTitle = `beatleader_compare_players-${String(count).padStart(2, '0')}`;
return {
playlistTitle,
playlistAuthor: 'SaberList Tool',
songs: songs.map((s) => ({
hash: s.hash,
difficulties: s.difficulties,
})),
description: `A's recent songs not played by B. Generated ${new Date().toISOString()}`,
allowDuplicates: false,
customData: {}
};
}
function downloadPlaylist(): void {
const payload = toPlaylistJson(results);
const title = (payload as any).playlistTitle ?? 'playlist';
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title}.bplist`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} }
async function onCompare() { async function onCompare() {
@ -317,14 +173,9 @@
candidates.sort((x, y) => y.timeset - x.timeset); candidates.sort((x, y) => y.timeset - x.timeset);
const limited = candidates; // return all; pagination handled client-side const limited = candidates; // return all; pagination handled client-side
results = limited; results = limited;
page = 1; page = 1;
// Load BeatSaver metadata (covers, titles) for tiles // Metadata will be loaded lazily via reactive statement when pageItems changes
loadMetaForResults(limited);
// Load BeatLeader star ratings per hash/diff
loadStarsForResults(limited);
} catch (err) { } catch (err) {
errorMsg = err instanceof Error ? err.message : 'Unknown error'; errorMsg = err instanceof Error ? err.message : 'Unknown error';
} finally { } finally {
@ -341,7 +192,7 @@
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare}> <PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare}>
<svelte:fragment slot="extra-buttons"> <svelte:fragment slot="extra-buttons">
{#if results.length > 0} {#if results.length > 0}
<button type="button" class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={downloadPlaylist}>Download .bplist</button> <button type="button" class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={handleDownloadPlaylist}>Download .bplist</button>
{/if} {/if}
</svelte:fragment> </svelte:fragment>
</PlayerCompareForm> </PlayerCompareForm>

View File

@ -2,28 +2,27 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import MapCard from '$lib/components/MapCard.svelte'; import MapCard from '$lib/components/MapCard.svelte';
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte'; import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
import {
type BeatLeaderScore = { type MapMeta,
timeset?: string | number; type StarInfo,
accuracy?: number; type BeatLeaderScore,
acc?: number; type BeatLeaderScoresResponse,
rank?: number; loadMetaForHashes,
leaderboard?: { loadStarsForHashes,
id?: string | number | null; normalizeDifficultyName,
leaderboardId?: string | number | null; parseTimeset,
song?: { hash?: string | null }; getCutoffEpochFromMonths,
difficulty?: { value?: number | string | null; modeName?: string | null }; normalizeAccuracy,
}; buildLatestByKey,
}; fetchAllRecentScoresForDiff,
fetchAllRecentScoresAllDiffs,
type BeatLeaderScoresResponse = { data?: BeatLeaderScore[] }; mean,
median,
type MapMeta = { percentile,
songName?: string; DIFFICULTIES,
key?: string; MODES,
coverURL?: string; ONE_YEAR_SECONDS
mapper?: string; } from '$lib/utils/plebsaber-utils';
};
type H2HItem = { type H2HItem = {
hash: string; hash: string;
@ -37,10 +36,6 @@
leaderboardId?: string; leaderboardId?: string;
}; };
const difficulties = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'];
const modes = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'];
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
let playerA = ''; let playerA = '';
let playerB = ''; let playerB = '';
const months = 24; const months = 24;
@ -69,122 +64,29 @@
$: page = Math.min(page, totalPages); $: page = Math.min(page, totalPages);
$: pageItems = sorted.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum); $: pageItems = sorted.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum);
// Meta cache // Metadata caches
let metaByHash: Record<string, MapMeta> = {}; let metaByHash: Record<string, MapMeta> = {};
let starsByKey: Record<string, StarInfo> = {};
let loadingMeta = false; let loadingMeta = false;
let loadingStars = false;
function normalizeAccuracy(value: number | undefined): number | null { // Lazy load metadata when pageItems changes
if (value === undefined || value === null) return null; $: if (pageItems.length > 0) {
return value <= 1 ? value * 100 : value; loadPageMetadata(pageItems);
} }
function normalizeDifficultyName(value: number | string | null | undefined): string { async function loadPageMetadata(list: H2HItem[]): Promise<void> {
if (value === null || value === undefined) return 'ExpertPlus'; const hashes = list.map(i => i.hash);
if (typeof value === 'string') {
const v = value.toLowerCase();
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
if (v.includes('expert')) return 'Expert';
if (v.includes('hard')) return 'Hard';
if (v.includes('normal')) return 'Normal';
if (v.includes('easy')) return 'Easy';
return value;
}
switch (value) {
case 1: return 'Easy';
case 3: return 'Normal';
case 5: return 'Hard';
case 7: return 'Expert';
case 9: return 'ExpertPlus';
default: return 'ExpertPlus';
}
}
function parseTimeset(ts: string | number | undefined): number { // Load BeatSaver metadata
if (ts === undefined) return 0;
if (typeof ts === 'number') return ts;
const n = Number(ts);
return Number.isFinite(n) ? n : 0;
}
function getCutoffEpochFromMonths(m: number | string): number {
const monthsNum = Number(m) || 0;
const seconds = Math.max(0, monthsNum) * 30 * 24 * 60 * 60;
return Math.floor(Date.now() / 1000) - seconds;
}
async function fetchBeatSaverMeta(inHash: string): Promise<MapMeta | null> {
try {
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(inHash)}`);
if (!res.ok) throw new Error(String(res.status));
const data: any = await res.json();
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${inHash.toLowerCase()}.jpg`;
return {
songName: data?.metadata?.songName ?? data?.name ?? undefined,
key: data?.id ?? undefined,
coverURL: cover,
mapper: data?.uploader?.name ?? undefined
};
} catch {
return { coverURL: `https://cdn.beatsaver.com/${inHash.toLowerCase()}.jpg` };
}
}
async function loadMetaForResults(list: H2HItem[]): Promise<void> {
const needed = Array.from(new Set(list.map((i) => i.hash))).filter((h) => !metaByHash[h]);
if (needed.length === 0) return;
loadingMeta = true; loadingMeta = true;
for (const h of needed) { metaByHash = await loadMetaForHashes(hashes, metaByHash);
const m = await fetchBeatSaverMeta(h);
if (m) metaByHash = { ...metaByHash, [h]: m };
}
loadingMeta = false; loadingMeta = false;
}
async function fetchAllRecentScoresForDiff(playerId: string, cutoffEpoch: number, reqDiff: string, maxPages = 100): Promise<BeatLeaderScore[]> { // Load star ratings
const qs = new URLSearchParams({ diff: reqDiff, cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) }); loadingStars = true;
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`; starsByKey = await loadStarsForHashes(hashes, starsByKey, normalizeDifficultyName);
const res = await fetch(url); loadingStars = false;
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
const data = (await res.json()) as BeatLeaderScoresResponse;
return data.data ?? [];
}
async function fetchAllRecentScoresAllDiffs(playerId: string, cutoffEpoch: number): Promise<BeatLeaderScore[]> {
const arrays = await Promise.all(
difficulties.map((d) => fetchAllRecentScoresForDiff(playerId, cutoffEpoch, d))
);
// Merge and dedupe by leaderboard key (hash|diff|mode) and timeset
const merged = new Map<string, BeatLeaderScore>();
for (const arr of arrays) {
for (const s of arr) {
const rawHash = s.leaderboard?.song?.hash ?? undefined;
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
if (!rawHash) continue;
const hashLower = String(rawHash).toLowerCase();
const diffName = normalizeDifficultyName(s.leaderboard?.difficulty?.value ?? undefined);
const key = `${hashLower}|${diffName}|${modeName}`;
const prev = merged.get(key);
if (!prev || parseTimeset(prev.timeset) < parseTimeset(s.timeset)) merged.set(key, s);
}
}
return Array.from(merged.values());
}
function buildLatestByKey(scores: BeatLeaderScore[], cutoffEpoch: number): Map<string, BeatLeaderScore> {
const byKey = new Map<string, BeatLeaderScore>();
for (const s of scores) {
const t = parseTimeset(s.timeset);
if (!t || t < cutoffEpoch - ONE_YEAR_SECONDS) continue; // sanity guard
const rawHash = s.leaderboard?.song?.hash ?? undefined;
const modeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
if (!rawHash) continue;
const hashLower = String(rawHash).toLowerCase();
const diffName = normalizeDifficultyName(s.leaderboard?.difficulty?.value ?? undefined);
const key = `${hashLower}|${diffName}|${modeName}`;
const prev = byKey.get(key);
if (!prev || parseTimeset(prev.timeset) < t) byKey.set(key, s);
}
return byKey;
} }
function toH2HItems(aMap: Map<string, BeatLeaderScore>, bMap: Map<string, BeatLeaderScore>): H2HItem[] { function toH2HItems(aMap: Map<string, BeatLeaderScore>, bMap: Map<string, BeatLeaderScore>): H2HItem[] {
@ -227,16 +129,16 @@
try { try {
const cutoff = getCutoffEpochFromMonths(months); const cutoff = getCutoffEpochFromMonths(months);
const [aScores, bScores] = await Promise.all([ const [aScores, bScores] = await Promise.all([
fetchAllRecentScoresAllDiffs(a, cutoff), fetchAllRecentScoresAllDiffs(a, cutoff, normalizeDifficultyName, parseTimeset),
fetchAllRecentScoresAllDiffs(b, cutoff) fetchAllRecentScoresAllDiffs(b, cutoff, normalizeDifficultyName, parseTimeset)
]); ]);
const aLatest = buildLatestByKey(aScores, cutoff - ONE_YEAR_SECONDS); const aLatest = buildLatestByKey(aScores, cutoff - ONE_YEAR_SECONDS, normalizeDifficultyName, parseTimeset);
const bLatest = buildLatestByKey(bScores, cutoff - ONE_YEAR_SECONDS); const bLatest = buildLatestByKey(bScores, cutoff - ONE_YEAR_SECONDS, normalizeDifficultyName, parseTimeset);
const combined = toH2HItems(aLatest, bLatest); const combined = toH2HItems(aLatest, bLatest);
combined.sort((x, y) => y.timeset - x.timeset); combined.sort((x, y) => y.timeset - x.timeset);
items = combined; items = combined;
page = 1; page = 1;
loadMetaForResults(combined); // Metadata will be loaded lazily via reactive statement when pageItems changes
} catch (err) { } catch (err) {
errorMsg = err instanceof Error ? err.message : 'Unknown error'; errorMsg = err instanceof Error ? err.message : 'Unknown error';
} finally { } finally {
@ -291,23 +193,6 @@
$: updateUrl(true); $: updateUrl(true);
// ===== Derived Stats for Visualizations ===== // ===== Derived Stats for Visualizations =====
function mean(values: number[]): number {
if (!values.length) return 0;
return values.reduce((a, b) => a + b, 0) / values.length;
}
function median(values: number[]): number {
if (!values.length) return 0;
const v = [...values].sort((a, b) => a - b);
const mid = Math.floor(v.length / 2);
return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
}
function percentile(values: number[], p: number): number {
if (!values.length) return 0;
const v = [...values].sort((a, b) => a - b);
const idx = Math.min(v.length - 1, Math.max(0, Math.floor((p / 100) * (v.length - 1))));
return v[idx];
}
$: comparable = items.filter((i) => i.accA != null && i.accB != null) as Array<{ $: comparable = items.filter((i) => i.accA != null && i.accB != null) as Array<{
hash: string; diffName: string; modeName: string; timeset: number; accA: number; accB: number; rankA?: number; rankB?: number; leaderboardId?: string; hash: string; diffName: string; modeName: string; timeset: number; accA: number; accB: number; rankA?: number; rankB?: number; leaderboardId?: string;
}>; }>;
@ -562,6 +447,9 @@
{#if loadingMeta} {#if loadingMeta}
<div class="mt-2 text-xs text-muted">Loading covers…</div> <div class="mt-2 text-xs text-muted">Loading covers…</div>
{/if} {/if}
{#if loadingStars}
<div class="mt-2 text-xs text-muted">Loading star ratings…</div>
{/if}
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each pageItems as item} {#each pageItems as item}
@ -571,6 +459,7 @@
coverURL={metaByHash[item.hash]?.coverURL} coverURL={metaByHash[item.hash]?.coverURL}
songName={metaByHash[item.hash]?.songName} songName={metaByHash[item.hash]?.songName}
mapper={metaByHash[item.hash]?.mapper} mapper={metaByHash[item.hash]?.mapper}
stars={starsByKey[`${item.hash}|${item.diffName}|${item.modeName}`]?.stars}
timeset={item.timeset} timeset={item.timeset}
diffName={item.diffName} diffName={item.diffName}
modeName={item.modeName} modeName={item.modeName}