From 0a031469cc1268f6c047c9f3c23edaef615732b9 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 29 Oct 2025 11:54:00 -0700 Subject: [PATCH] refactor utils --- plebsaber.stream.code-workspace | 9 +- src/lib/components/MapCard.svelte | 2 +- src/lib/utils/plebsaber-utils.ts | 486 ++++++++++++++++++ .../tools/beatleader-compare/+page.svelte | 221 ++------ .../tools/beatleader-headtohead/+page.svelte | 203 ++------ 5 files changed, 572 insertions(+), 349 deletions(-) create mode 100644 src/lib/utils/plebsaber-utils.ts diff --git a/plebsaber.stream.code-workspace b/plebsaber.stream.code-workspace index 07f11b9..8bf675e 100644 --- a/plebsaber.stream.code-workspace +++ b/plebsaber.stream.code-workspace @@ -4,14 +4,11 @@ "path": "." }, { - "path": "../../../src/beatleader-website" + "path": "../../../src/beatleader/beatleader-website" }, { - "path": "../../../src/beatleader-server" - }, - { - "path": "../../../src/beatleader-mod" + "path": "../../../src/beatleader/beatleader-server" } ], "settings": {} -} \ No newline at end of file +} diff --git a/src/lib/components/MapCard.svelte b/src/lib/components/MapCard.svelte index 7cfefdb..865277a 100644 --- a/src/lib/components/MapCard.svelte +++ b/src/lib/components/MapCard.svelte @@ -29,7 +29,7 @@ class="h-full w-full object-cover" /> {:else} -
No cover
+
☁️
{/if}
diff --git a/src/lib/utils/plebsaber-utils.ts b/src/lib/utils/plebsaber-utils.ts new file mode 100644 index 0000000..aed958a --- /dev/null +++ b/src/lib/utils/plebsaber-utils.ts @@ -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 { + 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> { + 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 = {}; + 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, + onProgress?: (loaded: number, total: number) => void +): Promise> { + 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, + normalizeFn: (value: number | string | null | undefined) => string, + onProgress?: (loaded: number, total: number) => void +): Promise> { + 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 { + 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 { + 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(); + 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 { + const byKey = new Map(); + 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) : {}; + 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 = { + pageItems: T[]; + totalPages: number; + validPage: number; +}; + +/** + * Calculate pagination for a list of items + */ +export function calculatePagination( + items: T[], + page: number, + pageSize: number +): PaginationResult { + 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( + 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, + 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); + } +} + diff --git a/src/routes/tools/beatleader-compare/+page.svelte b/src/routes/tools/beatleader-compare/+page.svelte index 55c6a76..f110a4c 100644 --- a/src/routes/tools/beatleader-compare/+page.svelte +++ b/src/routes/tools/beatleader-compare/+page.svelte @@ -1,27 +1,21 @@