469 lines
16 KiB
Svelte
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>
|
|
|
|
|