diff --git a/src/routes/api/beatleader-cache/player/[id]/+server.ts b/src/routes/api/beatleader-cache/player/[id]/+server.ts new file mode 100644 index 0000000..4d8fc73 --- /dev/null +++ b/src/routes/api/beatleader-cache/player/[id]/+server.ts @@ -0,0 +1,201 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { createBeatLeaderAPI } from '$lib/server/beatleader'; +import { getBeatLeaderSessionCookieHeader, getValidAccessToken } from '$lib/server/beatleaderAuth'; +import { getSteamIdFromCookies } from '$lib/server/steam'; + +import { promises as fsp } from 'fs'; +import path from 'path'; + +type BeatLeaderScore = { + timeset?: string | number; + leaderboard?: { + id?: string | number | null; + leaderboardId?: string | number | null; + song?: { hash?: string | null }; + difficulty?: { value?: number | string | null; modeName?: string | null }; + }; +}; + +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; +} + +const DATA_DIR = path.join(process.cwd(), '.data', 'beatleader-cache', 'player-scores'); + +async function ensureDir(dir: string): Promise { + await fsp.mkdir(dir, { recursive: true }); +} + +async function readJson(filePath: string): Promise { + try { + const buf = await fsp.readFile(filePath, 'utf8'); + return JSON.parse(buf) as T; + } catch { + return null; + } +} + +async function writeJson(filePath: string, data: unknown): Promise { + const dir = path.dirname(filePath); + await ensureDir(dir); + const tmp = filePath + '.tmp'; + await fsp.writeFile(tmp, JSON.stringify(data)); + await fsp.rename(tmp, filePath); +} + +function getCacheFilePath(playerId: string, diff: string): string { + const safeId = encodeURIComponent(playerId); + const safeDiff = encodeURIComponent(diff); + return path.join(DATA_DIR, `${safeId}__${safeDiff}.json`); +} + +function scoreKey(score: BeatLeaderScore): string | null { + const lb: any = score?.leaderboard ?? {}; + const lbId = lb?.id ?? lb?.leaderboardId; + if (lbId != null) return `lb:${String(lbId)}`; + const hash = lb?.song?.hash ? String(lb.song.hash).toLowerCase() : undefined; + const diff = normalizeDifficultyName(lb?.difficulty?.value ?? undefined); + const mode = lb?.difficulty?.modeName ?? 'Standard'; + if (hash) return `hdm:${hash}|${diff}|${mode}`; + const ts = parseTimeset(score?.timeset); + return ts ? `ts:${ts}` : null; +} + +export const GET: RequestHandler<{ id: string }> = async ({ fetch, params, url, cookies }) => { + type AuthMode = 'steam' | 'oauth' | 'session' | 'auto' | 'none'; + const authMode = (url.searchParams.get('auth') as AuthMode | null) ?? 'steam'; + + let token: string | undefined; + let websiteCookieHeader: string | undefined; + + if (authMode === 'oauth') { + const maybeToken = await getValidAccessToken(cookies); + token = maybeToken ?? undefined; + } else if (authMode === 'session') { + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + } else if (authMode === 'steam') { + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + if (!websiteCookieHeader) { + const steamId = getSteamIdFromCookies(cookies); + if (steamId) { + try { + const probe = await fetch('https://api.beatleader.com/user', { credentials: 'include' as RequestCredentials }); + const setCookieHeaders = (probe.headers as any).getSetCookie?.() ?? probe.headers.get('set-cookie')?.split(',') ?? []; + const { setBeatLeaderSessionFromSetCookieHeaders } = await import('$lib/server/beatleaderAuth'); + setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]); + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + } catch {} + } + } + } else if (authMode === 'auto') { + const maybeToken = await getValidAccessToken(cookies); + if (maybeToken) { + token = maybeToken; + } else { + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + if (!websiteCookieHeader) { + const steamId = getSteamIdFromCookies(cookies); + if (steamId) { + try { + const probe = await fetch('https://api.beatleader.com/user', { credentials: 'include' as RequestCredentials }); + const setCookieHeaders = (probe.headers as any).getSetCookie?.() ?? probe.headers.get('set-cookie')?.split(',') ?? []; + const { setBeatLeaderSessionFromSetCookieHeaders } = await import('$lib/server/beatleaderAuth'); + setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]); + websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined; + } catch {} + } + } + } + } + + const api = createBeatLeaderAPI(fetch, token, websiteCookieHeader); + const playerId = params.id as string; + const diff = (url.searchParams.get('diff') ?? 'ExpertPlus'); + const cutoffEpochParam = url.searchParams.get('cutoffEpoch'); + const cutoffEpoch = cutoffEpochParam ? Number(cutoffEpochParam) : undefined; + const pageSize = 100; + const maxPages = url.searchParams.get('maxPages') ? Number(url.searchParams.get('maxPages')) : 100; + + try { + const filePath = getCacheFilePath(playerId, diff); + const existing = await readJson<{ scores: BeatLeaderScore[]; updatedAt: number }>(filePath); + const existingScores = Array.isArray(existing?.scores) ? existing!.scores : []; + const existingNewest = existingScores.length > 0 ? Math.max(...existingScores.map((s) => parseTimeset(s.timeset))) : 0; + + // Fetch new pages until we reach an older-or-equal score than the newest cached + let page = 1; + const newScores: BeatLeaderScore[] = []; + while (page <= maxPages) { + const data = (await api.getPlayerScores(playerId, { page, count: pageSize, sortBy: 'date', order: 'desc' })) as any; + const batch: BeatLeaderScore[] = Array.isArray(data?.data) ? data.data : []; + if (batch.length === 0) break; + // Filter by requested diff immediately + const filtered = batch.filter((s) => normalizeDifficultyName(s?.leaderboard?.difficulty?.value ?? undefined) === diff); + newScores.push(...filtered); + const last = batch[batch.length - 1]; + const lastTs = parseTimeset(last?.timeset); + if (existingNewest && lastTs <= existingNewest) break; + if (cutoffEpoch !== undefined && lastTs < cutoffEpoch) break; + page += 1; + } + + // Merge new with existing, dedupe by scoreboard key + const mergedMap = new Map(); + for (const s of existingScores) { + const k = scoreKey(s); + if (k) mergedMap.set(k, s); + } + for (const s of newScores) { + const k = scoreKey(s); + if (k) mergedMap.set(k, s); + } + const merged = Array.from(mergedMap.values()); + // Keep only requested diff in the stored file to reduce size + const stored = merged.filter((s) => normalizeDifficultyName(s?.leaderboard?.difficulty?.value ?? undefined) === diff); + + // Persist + await writeJson(filePath, { scores: stored, updatedAt: Date.now() }); + + // Prepare response: filter by cutoffEpoch if provided and sort desc by timeset + const responseScores = stored + .filter((s) => (cutoffEpoch !== undefined ? parseTimeset(s.timeset) >= cutoffEpoch : true)) + .sort((a, b) => parseTimeset(b.timeset) - parseTimeset(a.timeset)); + + return new Response(JSON.stringify({ data: responseScores }), { headers: { 'content-type': 'application/json' } }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'content-type': 'application/json' } }); + } +}; + + diff --git a/src/routes/auth/beatleader/logout/+server.ts b/src/routes/auth/beatleader/logout/+server.ts index 4353fdb..ce8300f 100644 --- a/src/routes/auth/beatleader/logout/+server.ts +++ b/src/routes/auth/beatleader/logout/+server.ts @@ -1,6 +1,12 @@ import type { RequestHandler } from '@sveltejs/kit'; import { clearTokens } from '$lib/server/beatleaderAuth'; +export const GET: RequestHandler = async ({ url }) => { + // For prerendering, redirect to home page + const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; + return new Response(null, { status: 302, headers: { Location: redirectTo } }); +}; + export const POST: RequestHandler = async ({ url, cookies }) => { clearTokens(cookies); const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; diff --git a/src/routes/tools/beatleader-compare/+page.svelte b/src/routes/tools/beatleader-compare/+page.svelte index 46b3f60..c77d7d5 100644 --- a/src/routes/tools/beatleader-compare/+page.svelte +++ b/src/routes/tools/beatleader-compare/+page.svelte @@ -34,7 +34,6 @@ let playerA = ''; let playerB = ''; - let songCount = 10; let loading = false; let errorMsg: string | null = null; let results: SongItem[] = []; @@ -47,6 +46,10 @@ let pageSize: number | string = 24; $: pageSizeNum = Number(pageSize) || 24; + // Configurable lookback windows (months) + let monthsA: number | string = 6; // default 6 months + let monthsB: number | string = 24; // default 24 months + // Derived lists $: sortedResults = [...results].sort((a, b) => { let cmp = 0; @@ -82,6 +85,55 @@ let starsByKey: Record = {}; let loadingStars = false; + // Toast notification state + let toastMessage = ''; + let showToast = false; + let toastTimeout: number | null = null; + + // Button feedback state - track which buttons are currently "lit up" + let litButtons = new Set(); + + function showToastMessage(message: string) { + // Clear any existing toast + if (toastTimeout) { + clearTimeout(toastTimeout); + } + + toastMessage = message; + showToast = true; + + // Auto-hide toast after 3 seconds + toastTimeout = setTimeout(() => { + showToast = false; + toastMessage = ''; + }, 3000); + } + + function lightUpButton(buttonId: string) { + litButtons.add(buttonId); + // Reassign here too to trigger reactivity immediately + litButtons = litButtons; + // Remove the lighting effect after 1 second + setTimeout(() => { + litButtons.delete(buttonId); + litButtons = litButtons; // Trigger reactivity + }, 1000); + } + + async function copyBsrCommand(key: string, hash: string) { + try { + const bsrCommand = `!bsr ${key}`; + await navigator.clipboard.writeText(bsrCommand); + + // Show success feedback with the actual command + showToastMessage(`Copied "${bsrCommand}" to clipboard`); + lightUpButton(`bsr-${hash}`); + } catch (err) { + console.error('Failed to copy to clipboard:', err); + showToastMessage('Failed to copy to clipboard'); + } + } + async function fetchBeatSaverMeta(hash: string): Promise { try { const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`); @@ -147,6 +199,8 @@ loadingStars = false; } + + function difficultyToColor(name: string | undefined): string { const n = (name ?? 'ExpertPlus').toLowerCase(); if (n === 'easy') return 'MediumSeaGreen'; @@ -195,69 +249,23 @@ return Number.isFinite(n) ? n : 0; } - function getCutoffEpoch(): number { - return Math.floor(Date.now() / 1000) - ONE_YEAR_SECONDS; + 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): Promise { - const pageSize = 100; - let page = 1; - const maxPages = 15; // safety cap - const all: BeatLeaderScore[] = []; - - while (page <= maxPages) { - const url = `/api/beatleader/player/${encodeURIComponent(playerId)}?scores=1&count=${pageSize}&page=${page}&sortBy=date&order=desc`; - 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; - const batch = data.data ?? []; - if (batch.length === 0) break; - all.push(...batch); - - const last = batch[batch.length - 1]; - const lastTs = parseTimeset(last?.timeset); - if (lastTs < cutoffEpoch) break; // remaining pages will be older - - page += 1; - } - - return all; + async function fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise { + 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 ?? []; } - async function fetchAllScoresAnyTime(playerId: string, maxPages = 100): Promise { - const pageSize = 100; - let page = 1; - const all: BeatLeaderScore[] = []; - while (page <= maxPages) { - const url = `/api/beatleader/player/${encodeURIComponent(playerId)}?scores=1&count=${pageSize}&page=${page}&sortBy=date&order=desc`; - 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; - const batch = data.data ?? []; - if (batch.length === 0) break; - all.push(...batch); - page += 1; - } - - return all; - } - - function loadHistory(): Record { - try { - const raw = localStorage.getItem('bl_compare_history'); - if (!raw) return {}; - const obj = JSON.parse(raw); - if (obj && typeof obj === 'object') return obj as Record; - } catch {} - return {}; - } - - function saveHistory(history: Record): void { - try { - localStorage.setItem('bl_compare_history', JSON.stringify(history)); - } catch {} - } + function incrementPlaylistCount(): number { try { @@ -316,24 +324,29 @@ loading = true; try { - const cutoff = getCutoffEpoch(); + const cutoff = getCutoffEpochFromMonths(monthsA); + const cutoffB = getCutoffEpochFromMonths(monthsB); const [aScores, bScores] = await Promise.all([ fetchAllRecentScores(a, cutoff), - fetchAllScoresAnyTime(b, 100) + fetchAllRecentScores(b, cutoffB, 100) ]); - const bHashes = new Set(); const bLeaderboardIds = new Set(); + const bExpertPlusKeys = new Set(); // `${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; - if (hashLower) bHashes.add(hashLower); + 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 history = loadHistory(); + const runSeen = new Set(); // avoid duplicates within this run const candidates: SongItem[] = []; @@ -348,11 +361,11 @@ const leaderboardId = leaderboardIdRaw != null ? String(leaderboardIdRaw) : undefined; if (!rawHash) continue; const hashLower = String(rawHash).toLowerCase(); - if (bHashes.has(hashLower) || (leaderboardId && bLeaderboardIds.has(leaderboardId))) continue; // B has played this song - const diffName = normalizeDifficultyName(diffValue); - const historyDiffs = history[rawHash] ?? []; - if (historyDiffs.includes(diffName)) continue; // used previously + 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; @@ -367,15 +380,9 @@ } candidates.sort((x, y) => y.timeset - x.timeset); - const limited = candidates.slice(0, Math.max(0, Math.min(200, Number(songCount) || 40))); + const limited = candidates; // return all; pagination handled client-side - // update history for saved pairs - for (const s of limited) { - const diff = s.difficulties[0]?.name ?? 'ExpertPlus'; - if (!history[s.hash]) history[s.hash] = []; - if (!history[s.hash].includes(diff)) history[s.hash].push(diff); - } - saveHistory(history); + results = limited; page = 1; @@ -395,17 +402,12 @@ const sp = new URLSearchParams(location.search); playerA = sp.get('a') ?? ''; playerB = sp.get('b') ?? ''; - const sc = sp.get('n'); - if (sc) { - const n = Number(sc); - if (Number.isFinite(n) && n > 0) songCount = n; - } });

BeatLeader: Compare Players

-

Maps Player A has played that Player B hasn't — last 12 months.

+

Maps Player A has played that Player B hasn't — configurable lookback.

@@ -420,8 +422,13 @@
-
+
+
@@ -464,6 +471,7 @@ +
@@ -539,7 +547,8 @@ > @@ -563,8 +572,63 @@ {/if}
+ +{#if showToast} +
+ {toastMessage} +
+{/if} +