refactor utils

This commit is contained in:
2025-10-29 11:54:00 -07:00
parent ef2db550db
commit 0a031469cc
5 changed files with 572 additions and 349 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
class="h-full w-full object-cover"
/>
{: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}
</div>
<div class="p-3">
+486
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);
}
}