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 @@