2025-10-29 11:04:51 -07:00

469 lines
16 KiB
Svelte

<script lang="ts">
import MapCard from '$lib/components/MapCard.svelte';
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
type BeatLeaderScore = {
timeset?: string | number;
leaderboard?: {
// BeatLeader tends to expose a short id for the leaderboard route
id?: string | number | null;
leaderboardId?: string | number | null;
song?: { hash?: string | null };
difficulty?: { value?: number | string | null; modeName?: string | null };
};
};
type BeatLeaderScoresResponse = {
data?: BeatLeaderScore[];
metadata?: { page?: number; itemsPerPage?: number; total?: number };
};
type Difficulty = {
name: string;
characteristic: string;
};
type SongItem = {
hash: string;
difficulties: Difficulty[];
timeset: number;
leaderboardId?: string;
};
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
let playerA = '';
let playerB = '';
let loading = false;
let errorMsg: string | null = null;
let results: SongItem[] = [];
let loadingMeta = false;
// Sorting and pagination state
let sortBy: 'date' | 'difficulty' = 'date';
let sortDir: 'asc' | 'desc' = 'desc';
let page = 1;
let pageSize: number | string = 24;
$: pageSizeNum = Number(pageSize) || 24;
// Configurable lookback windows (months)
const monthsA = 24; // default 24 months
const monthsB = 24; // default 24 months
// Derived lists
$: sortedResults = [...results].sort((a, b) => {
let cmp = 0;
if (sortBy === 'date') {
cmp = a.timeset - b.timeset;
} else {
const an = a.difficulties[0]?.name ?? '';
const bn = b.difficulties[0]?.name ?? '';
cmp = an.localeCompare(bn);
}
return sortDir === 'asc' ? cmp : -cmp;
});
$: totalPages = Math.max(1, Math.ceil(sortedResults.length / pageSizeNum));
$: page = Math.min(page, totalPages);
$: pageItems = sortedResults.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum);
type MapMeta = {
songName?: string;
key?: string;
coverURL?: string;
mapper?: string;
};
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 loadingStars = false;
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` };
}
}
async function loadMetaForResults(items: SongItem[]): Promise<void> {
const needed = Array.from(new Set(items.map((i) => i.hash))).filter((h) => !metaByHash[h]);
if (needed.length === 0) return;
loadingMeta = true;
for (const h of needed) {
const meta = await fetchBeatSaverMeta(h);
if (meta) metaByHash = { ...metaByHash, [h]: meta };
}
loadingMeta = false;
}
async function fetchBeatLeaderStarsByHash(hash: string): Promise<void> {
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;
for (const h of neededHashes) {
await fetchBeatLeaderStarsByHash(h);
}
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[]> {
const qs = new URLSearchParams({ diff: 'ExpertPlus', 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 ?? [];
}
function incrementPlaylistCount(): number {
try {
const raw = localStorage.getItem('playlist_counts');
const obj = raw ? (JSON.parse(raw) as Record<string, number>) : {};
const key = 'beatleader_compare_players';
const next = (obj[key] ?? 0) + 1;
obj[key] = next;
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() {
errorMsg = null;
results = [];
const a = playerA.trim();
const b = playerB.trim();
if (!a || !b) {
errorMsg = 'Please enter both Player A and Player B IDs.';
return;
}
loading = true;
try {
const cutoff = getCutoffEpochFromMonths(monthsA);
const cutoffB = getCutoffEpochFromMonths(monthsB);
const [aScores, bScores] = await Promise.all([
fetchAllRecentScores(a, cutoff),
fetchAllRecentScores(b, cutoffB, 100)
]);
const bLeaderboardIds = new Set<string>();
const bExpertPlusKeys = new Set<string>(); // `${hashLower}|ExpertPlus|${modeName}`
for (const s of bScores) {
const rawHash = s.leaderboard?.song?.hash ?? undefined;
const hashLower = rawHash ? String(rawHash).toLowerCase() : undefined;
const lbIdRaw = (s.leaderboard as any)?.id ?? (s.leaderboard as any)?.leaderboardId;
const lbId = lbIdRaw != null ? String(lbIdRaw) : undefined;
const bDiffValue = s.leaderboard?.difficulty?.value ?? undefined;
const bModeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
const bDiffName = normalizeDifficultyName(bDiffValue);
if (bDiffName !== 'ExpertPlus') continue; // ignore non-ExpertPlus for B
if (lbId) bLeaderboardIds.add(lbId);
if (hashLower) bExpertPlusKeys.add(`${hashLower}|ExpertPlus|${bModeName}`);
}
const runSeen = new Set<string>(); // avoid duplicates within this run
const candidates: SongItem[] = [];
for (const entry of aScores) {
const t = parseTimeset(entry.timeset);
if (!t || t < cutoff) continue;
const rawHash = entry.leaderboard?.song?.hash ?? undefined;
const diffValue = entry.leaderboard?.difficulty?.value ?? undefined;
const modeName = entry.leaderboard?.difficulty?.modeName ?? 'Standard';
const leaderboardIdRaw = (entry.leaderboard as any)?.id ?? (entry.leaderboard as any)?.leaderboardId;
const leaderboardId = leaderboardIdRaw != null ? String(leaderboardIdRaw) : undefined;
if (!rawHash) continue;
const hashLower = String(rawHash).toLowerCase();
const diffName = normalizeDifficultyName(diffValue);
if (diffName !== 'ExpertPlus') continue; // Only compare ExpertPlus for A
// B has played ExpertPlus of same map+mode if matching leaderboard or key exists
if ((leaderboardId && bLeaderboardIds.has(leaderboardId)) || bExpertPlusKeys.has(`${hashLower}|${diffName}|${modeName}`)) continue;
const key = `${rawHash}|${diffName}|${modeName}`;
if (runSeen.has(key)) continue;
runSeen.add(key);
candidates.push({
hash: rawHash,
difficulties: [{ name: diffName, characteristic: modeName ?? 'Standard' }],
timeset: t,
leaderboardId
});
}
candidates.sort((x, y) => y.timeset - x.timeset);
const limited = candidates; // return all; pagination handled client-side
results = limited;
page = 1;
// Load BeatSaver metadata (covers, titles) for tiles
loadMetaForResults(limited);
// Load BeatLeader star ratings per hash/diff
loadStarsForResults(limited);
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'Unknown error';
} finally {
loading = false;
}
}
</script>
<section class="py-8">
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Compare Players</h1>
<p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — configurable lookback.</p>
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare}>
<svelte:fragment slot="extra-buttons">
{#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>
{/if}
</svelte:fragment>
</PlayerCompareForm>
{#if errorMsg}
<div class="mt-4 text-danger">{errorMsg}</div>
{/if}
{#if results.length > 0}
<div class="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm text-muted">
{results.length} songs
</div>
<div class="flex items-center gap-4 text-sm text-muted flex-wrap justify-end">
<label class="flex items-center gap-3">
<span class="filter-label">Options:</span>
<select class="neon-select" bind:value={sortBy}>
<option value="date">Date</option>
<option value="difficulty">Difficulty</option>
</select>
</label>
<label class="flex items-center gap-2">Dir
<select class="neon-select" bind:value={sortDir}>
<option value="desc">Desc</option>
<option value="asc">Asc</option>
</select>
</label>
<label class="flex items-center gap-2">Page size
<select class="neon-select" bind:value={pageSize}>
<option value={12}>12</option>
<option value={24}>24</option>
<option value={36}>36</option>
<option value={48}>48</option>
</select>
</label>
</div>
</div>
{#if loadingMeta}
<div class="mt-2 text-xs text-muted">Loading covers…</div>
{/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">
{#each pageItems as item}
<article class="card-surface overflow-hidden">
<MapCard
hash={item.hash}
coverURL={metaByHash[item.hash]?.coverURL}
songName={metaByHash[item.hash]?.songName}
mapper={metaByHash[item.hash]?.mapper}
stars={starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars}
timeset={item.timeset}
diffName={item.difficulties[0]?.name ?? 'ExpertPlus'}
modeName={item.difficulties[0]?.characteristic ?? 'Standard'}
leaderboardId={item.leaderboardId}
beatsaverKey={metaByHash[item.hash]?.key}
/>
</article>
{/each}
</div>
{#if totalPages > 1}
<div class="mt-6 flex items-center justify-center gap-2">
<button class="rounded-md border border-white/10 px-3 py-1 text-sm disabled:opacity-50" on:click={() => (page = Math.max(1, page - 1))} disabled={page === 1}>
Prev
</button>
<span class="text-sm text-muted">Page {page} / {totalPages}</span>
<button class="rounded-md border border-white/10 px-3 py-1 text-sm disabled:opacity-50" on:click={() => (page = Math.min(totalPages, page + 1))} disabled={page === totalPages}>
Next
</button>
</div>
{/if}
{/if}
</section>
<style>
.text-danger { color: #dc2626; }
/* Filter label styling */
.filter-label {
font-size: 1em;
letter-spacing: 0.05em;
font-weight: 700;
color: rgba(255, 0, 170, 0.95);
text-shadow: 0 0 8px rgba(255, 0, 170, 0.3);
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
font-family: var(--font-display);
line-height: 1;
}
/* Neon select dropdown */
.neon-select {
border-radius: 0.375rem;
border: 1px solid rgba(34, 211, 238, 0.3);
background: linear-gradient(180deg, rgba(15,23,42,0.9), rgba(11,15,23,0.95));
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
color: rgba(148, 163, 184, 1);
transition: all 0.2s ease;
box-shadow: 0 0 8px rgba(34, 211, 238, 0.15);
cursor: pointer;
}
.neon-select:hover {
border-color: rgba(34, 211, 238, 0.5);
color: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 16px rgba(34, 211, 238, 0.25);
}
.neon-select:focus {
outline: none;
border-color: rgba(34, 211, 238, 0.7);
box-shadow: 0 0 20px rgba(34, 211, 238, 0.35), 0 0 0 2px rgba(34, 211, 238, 0.1);
}
.neon-select option {
background: #0f172a;
color: #fff;
}
</style>