New web project with Beat Saber tools
This commit is contained in:
+48
@@ -0,0 +1,48 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@theme {
|
||||
--font-display: "Orbitron", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
|
||||
--font-sans: "Rajdhani", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
|
||||
--font-mono: "Share Tech Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
|
||||
--color-bg: #0b0f17;
|
||||
--color-surface: #0f172a; /* slate-900 */
|
||||
--color-muted: #94a3b8; /* slate-400 */
|
||||
--color-neon: #22d3ee; /* cyan-400 */
|
||||
--color-neon-fuchsia: #ff00e5;
|
||||
--color-acid: #a3ff12;
|
||||
--color-accent: #00ffd1;
|
||||
--color-danger: #ff3b81;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg text-white/90 antialiased;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
@utility btn-neon {
|
||||
@apply inline-flex items-center gap-2 rounded-md border border-neon/60 px-4 py-2 text-neon transition hover:border-neon hover:text-white focus:outline-none focus:ring-2 focus:ring-neon/50;
|
||||
box-shadow: 0 0 12px rgba(34, 211, 238, 0.30);
|
||||
}
|
||||
|
||||
.btn-neon:hover {
|
||||
box-shadow: 0 0 24px rgba(34, 211, 238, 0.60);
|
||||
}
|
||||
|
||||
@utility card-surface {
|
||||
@apply rounded-xl bg-surface/60 ring-1 ring-white/10 backdrop-blur-md shadow-[0_0_30px_rgba(34,211,238,0.06)] hover:shadow-[0_0_45px_rgba(255,0,229,0.10)] transition;
|
||||
}
|
||||
|
||||
@utility neon-text {
|
||||
@apply text-transparent bg-clip-text bg-gradient-to-r from-neon via-accent to-neon-fuchsia;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet"/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
const links = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/tools', label: 'Tools' },
|
||||
{ href: '/guides', label: 'Guides' }
|
||||
];
|
||||
let open = false;
|
||||
const toggle = () => (open = !open);
|
||||
const close = () => (open = false);
|
||||
const year = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-40 backdrop-blur supports-[backdrop-filter]:bg-surface/50 border-b border-white/10">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-14 items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neon" style="box-shadow: 0 0 12px rgba(34,211,238,0.60);"></span>
|
||||
<span class="font-display text-lg tracking-widest">
|
||||
<span class="neon-text">PLEBSABER</span><span class="text-muted">.stream</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-6">
|
||||
{#each links as link}
|
||||
<a href={link.href} class="text-muted hover:text-white transition">{link.label}</a>
|
||||
{/each}
|
||||
<a href="/tools" class="btn-neon">Launch Tools</a>
|
||||
</nav>
|
||||
|
||||
<button class="md:hidden btn-neon px-3 py-1.5" on:click={toggle} aria-expanded={open} aria-controls="mobile-nav">
|
||||
Menu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div id="mobile-nav" class="md:hidden border-t border-white/10 bg-surface/80">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-3 grid gap-3">
|
||||
{#each links as link}
|
||||
<a href={link.href} on:click={close} class="text-muted hover:text-white transition">{link.label}</a>
|
||||
{/each}
|
||||
<a href="/tools" on:click={close} class="btn-neon w-max">Launch Tools</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { songPlayerStore, currentTimeStore, togglePlay, setVolume } from '$lib/stores/songPlayer';
|
||||
|
||||
export let hash: string;
|
||||
export let preferBeatLeader = false;
|
||||
|
||||
let isCurrent = false;
|
||||
$: isCurrent = $songPlayerStore?.currentHash === hash;
|
||||
$: currentTime = isCurrent ? $currentTimeStore : 0;
|
||||
|
||||
function onToggle() {
|
||||
togglePlay(hash, preferBeatLeader);
|
||||
}
|
||||
|
||||
function onVolumeInput(e: Event) {
|
||||
const v = Number((e.target as HTMLInputElement).value);
|
||||
setVolume(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="player">
|
||||
<button class="play" on:click={onToggle} aria-label="Play/Pause">
|
||||
{#if isCurrent && $songPlayerStore?.playing}
|
||||
❚❚
|
||||
{:else}
|
||||
▶
|
||||
{/if}
|
||||
</button>
|
||||
<div class="timeline" title="Progress">
|
||||
<div class="progress" style="width: {($songPlayerStore?.currentHash ? (currentTime / ($songPlayerStore?.duration || 1)) : 0) * 100}%"></div>
|
||||
</div>
|
||||
<div class="time">
|
||||
{Math.floor(currentTime / 60)}:{String(Math.floor(currentTime % 60)).padStart(2, '0')} /
|
||||
{Math.floor(($songPlayerStore?.duration || 0) / 60)}:{String(Math.floor(($songPlayerStore?.duration || 0) % 60)).padStart(2, '0')}
|
||||
</div>
|
||||
<div class="volume">
|
||||
<input type="range" min="0" max="1" step="0.01" value={$songPlayerStore?.volume} on:input={onVolumeInput} />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.player { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.play { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; background: transparent; color: white; cursor: pointer; }
|
||||
.timeline { flex: 1; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; }
|
||||
.progress { height: 100%; background: rgba(255,255,255,0.6); }
|
||||
.time { font-size: 11px; opacity: 0.8; min-width: 80px; text-align: right; }
|
||||
.volume input { width: 80px; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
export * from './server/playlist';
|
||||
@@ -0,0 +1,121 @@
|
||||
const BASE_URL = 'https://api.beatleader.com';
|
||||
|
||||
// Simple in-memory cache for GET requests to BeatLeader
|
||||
// Caches JSON responses by URL for a short TTL to reduce backend load
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
type CacheEntry = { expiresAt: number; data: unknown };
|
||||
const responseCache: Map<string, CacheEntry> = new Map();
|
||||
|
||||
async function fetchJsonCached(fetchFn: typeof fetch, url: string, ttlMs = CACHE_TTL_MS): Promise<unknown> {
|
||||
const now = Date.now();
|
||||
const cached = responseCache.get(url);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const res = await fetchFn(url);
|
||||
if (!res.ok) throw new Error(`BeatLeader request failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
responseCache.set(url, { expiresAt: now + ttlMs, data });
|
||||
return data;
|
||||
}
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
function buildQuery(params: QueryParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
const qs = searchParams.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export class BeatLeaderAPI {
|
||||
private readonly fetchFn: typeof fetch;
|
||||
|
||||
constructor(fetchFn: typeof fetch) {
|
||||
this.fetchFn = fetchFn;
|
||||
}
|
||||
|
||||
async getPlayer(playerId: string): Promise<unknown> {
|
||||
const url = `${BASE_URL}/player/${encodeURIComponent(playerId)}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
}
|
||||
|
||||
async getPlayerScores(
|
||||
playerId: string,
|
||||
params: {
|
||||
page?: number;
|
||||
count?: number;
|
||||
leaderboardContext?: string;
|
||||
sortBy?: string | number;
|
||||
order?: 'asc' | 'desc' | string;
|
||||
search?: string;
|
||||
diff?: string;
|
||||
mode?: string;
|
||||
requirements?: string;
|
||||
type?: string;
|
||||
hmd?: string;
|
||||
modifiers?: string;
|
||||
stars_from?: string | number;
|
||||
stars_to?: string | number;
|
||||
eventId?: string | number;
|
||||
includeIO?: boolean;
|
||||
} = {}
|
||||
): Promise<unknown> {
|
||||
const query = buildQuery({
|
||||
page: params.page,
|
||||
count: params.count,
|
||||
leaderboardContext: params.leaderboardContext,
|
||||
sortBy: params.sortBy,
|
||||
order: params.order,
|
||||
search: params.search,
|
||||
diff: params.diff,
|
||||
mode: params.mode,
|
||||
requirements: params.requirements,
|
||||
type: params.type,
|
||||
hmd: params.hmd,
|
||||
modifiers: params.modifiers,
|
||||
stars_from: params.stars_from,
|
||||
stars_to: params.stars_to,
|
||||
eventId: params.eventId,
|
||||
includeIO: params.includeIO
|
||||
});
|
||||
|
||||
const url = `${BASE_URL}/player/${encodeURIComponent(playerId)}/scores${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
}
|
||||
|
||||
async getLeaderboard(
|
||||
hash: string,
|
||||
options: { diff?: string; mode?: string; page?: number; count?: number } = {}
|
||||
): Promise<unknown> {
|
||||
const diff = options.diff ?? 'ExpertPlus';
|
||||
const mode = options.mode ?? 'Standard';
|
||||
const query = buildQuery({ page: options.page, count: options.count });
|
||||
const url = `${BASE_URL}/v5/scores/${encodeURIComponent(hash)}/${encodeURIComponent(
|
||||
diff
|
||||
)}/${encodeURIComponent(mode)}${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
}
|
||||
|
||||
async getRankedLeaderboards(params: { stars_from?: number; stars_to?: number; page?: number; count?: number } = {}): Promise<unknown> {
|
||||
const query = buildQuery({
|
||||
page: params.page,
|
||||
count: params.count,
|
||||
type: 'ranked',
|
||||
stars_from: params.stars_from,
|
||||
stars_to: params.stars_to
|
||||
});
|
||||
const url = `${BASE_URL}/leaderboards${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBeatLeaderAPI(fetchFn: typeof fetch): BeatLeaderAPI {
|
||||
return new BeatLeaderAPI(fetchFn);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
const BASE_URL = 'https://api.beatsaver.com';
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
function buildQuery(params: QueryParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
const qs = searchParams.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
// Minimal shapes for returned data
|
||||
export interface CuratedSongInfo {
|
||||
hash: string;
|
||||
key: string;
|
||||
songName: string;
|
||||
}
|
||||
|
||||
export interface MapperMapInfo extends CuratedSongInfo {
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentMapInfo extends CuratedSongInfo {
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
interface BeatSaverApiOptions {
|
||||
cacheExpiryDays?: number;
|
||||
cacheDir?: string;
|
||||
maxRetries?: number;
|
||||
initialBackoffMs?: number;
|
||||
maxBackoffMs?: number;
|
||||
backoffFactor?: number;
|
||||
}
|
||||
|
||||
export class BeatSaverAPI {
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private readonly cacheExpiryMs: number;
|
||||
private readonly cacheDir: string;
|
||||
private readonly maxRetries: number;
|
||||
private readonly initialBackoffMs: number;
|
||||
private readonly maxBackoffMs: number;
|
||||
private readonly backoffFactor: number;
|
||||
|
||||
constructor(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}) {
|
||||
this.fetchFn = fetchFn;
|
||||
this.cacheExpiryMs = (options.cacheExpiryDays ?? 1) * 24 * 60 * 60 * 1000;
|
||||
this.maxRetries = options.maxRetries ?? 5;
|
||||
this.initialBackoffMs = options.initialBackoffMs ?? 1000;
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
|
||||
this.backoffFactor = options.backoffFactor ?? 2;
|
||||
this.cacheDir = options.cacheDir ?? this.determineCacheDir();
|
||||
this.ensureCacheDir();
|
||||
}
|
||||
|
||||
// Public API
|
||||
async getCuratedSongs(useCache: boolean = true): Promise<CuratedSongInfo[]> {
|
||||
const cachePath = this.pathJoin(this.cacheDir, 'curated_songs.json');
|
||||
if (useCache) {
|
||||
const cached = await this.readCache<CuratedSongInfo[]>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const processed: CuratedSongInfo[] = [];
|
||||
let page = 0;
|
||||
|
||||
while (true) {
|
||||
const url = `${BASE_URL}/search/text/${page}${buildQuery({ sortOrder: 'Curated', curated: 'true' })}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getCuratedSongs failed: ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
for (const song of data?.docs ?? []) {
|
||||
for (const version of song?.versions ?? []) {
|
||||
processed.push({
|
||||
hash: version?.hash,
|
||||
key: song?.id,
|
||||
songName: song?.metadata?.songName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages: number = data?.info?.pages ?? 0;
|
||||
if (page >= totalPages - 1) break;
|
||||
page += 1;
|
||||
await this.sleep(1000);
|
||||
}
|
||||
|
||||
// Do not expire curated songs cache (mirror Python approach). We still write once and always use it if present.
|
||||
await this.writeCache(cachePath, processed);
|
||||
return processed;
|
||||
}
|
||||
|
||||
async getFollowedMappers(userId: number = 243016, useCache: boolean = true): Promise<unknown> {
|
||||
const cachePath = this.pathJoin(this.cacheDir, `followed_mappers_${userId}.json`);
|
||||
if (useCache && (await this.isCacheValid(cachePath))) {
|
||||
const cached = await this.readCache<unknown>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const url = `${BASE_URL}/users/followedBy/${encodeURIComponent(String(userId))}/0`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getFollowedMappers failed: ${res.status}`);
|
||||
const mappers = await res.json();
|
||||
await this.writeCache(cachePath, mappers);
|
||||
return mappers;
|
||||
}
|
||||
|
||||
async getMapperMaps(mapperId: number, useCache: boolean = true): Promise<MapperMapInfo[]> {
|
||||
const cachePath = this.pathJoin(this.cacheDir, `mapper_${mapperId}_maps.json`);
|
||||
if (useCache && (await this.isCacheValid(cachePath))) {
|
||||
const cached = await this.readCache<MapperMapInfo[]>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const processed: MapperMapInfo[] = [];
|
||||
let page = 0;
|
||||
while (true) {
|
||||
const url = `${BASE_URL}/search/text/${page}${buildQuery({ collaborator: String(mapperId), automapper: 'true', sortOrder: 'Latest' })}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getMapperMaps failed: ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
for (const song of data?.docs ?? []) {
|
||||
for (const version of song?.versions ?? []) {
|
||||
processed.push({
|
||||
hash: version?.hash,
|
||||
key: song?.id,
|
||||
songName: song?.metadata?.songName,
|
||||
date: song?.lastPublishedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages: number = data?.info?.pages ?? 0;
|
||||
if (page >= totalPages - 1) break;
|
||||
page += 1;
|
||||
await this.sleep(1000);
|
||||
}
|
||||
|
||||
await this.writeCache(cachePath, processed);
|
||||
return processed;
|
||||
}
|
||||
|
||||
async getMapsByEnvironment(
|
||||
environmentName: string,
|
||||
options: { maxPages?: number; useCache?: boolean } = {}
|
||||
): Promise<EnvironmentMapInfo[]> {
|
||||
const useCache = options.useCache ?? true;
|
||||
const cachePath = this.pathJoin(this.cacheDir, `environment_${environmentName}_maps.json`);
|
||||
if (useCache && (await this.isCacheValid(cachePath))) {
|
||||
const cached = await this.readCache<EnvironmentMapInfo[]>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const processed: EnvironmentMapInfo[] = [];
|
||||
let page = 0;
|
||||
const maxPages = options.maxPages ?? undefined;
|
||||
|
||||
while (true) {
|
||||
const url = `${BASE_URL}/search/text/${page}${buildQuery({ environments: `${environmentName}Environment` })}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getMapsByEnvironment failed: ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
for (const song of data?.docs ?? []) {
|
||||
for (const version of song?.versions ?? []) {
|
||||
processed.push({
|
||||
hash: version?.hash,
|
||||
key: song?.id,
|
||||
songName: song?.metadata?.songName,
|
||||
environment: environmentName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages: number = data?.info?.pages ?? 0;
|
||||
if (page >= totalPages - 1) break;
|
||||
page += 1;
|
||||
if (maxPages !== undefined && page >= maxPages) break;
|
||||
await this.sleep(1000);
|
||||
}
|
||||
|
||||
await this.writeCache(cachePath, processed);
|
||||
return processed;
|
||||
}
|
||||
|
||||
async getMapByHash(mapHash: string): Promise<unknown> {
|
||||
const url = `${BASE_URL}/maps/hash/${encodeURIComponent(mapHash)}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getMapByHash failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Internal utilities
|
||||
private async request(url: string, init?: RequestInit): Promise<Response> {
|
||||
let attempt = 0;
|
||||
let backoffMs = this.initialBackoffMs;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const res = await this.fetchFn(url, init);
|
||||
if (res.status === 429) {
|
||||
attempt += 1;
|
||||
if (attempt > this.maxRetries) return res; // surface 429 if retries exceeded
|
||||
|
||||
const retryAfterHeader = res.headers.get('Retry-After');
|
||||
const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN;
|
||||
const waitMs = Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : backoffMs;
|
||||
await this.sleep(waitMs);
|
||||
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
attempt += 1;
|
||||
if (attempt > this.maxRetries) throw err;
|
||||
await this.sleep(backoffMs);
|
||||
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isCacheValid(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await this.fsPromises().stat(filePath);
|
||||
const ageMs = Date.now() - stat.mtimeMs;
|
||||
return ageMs < this.cacheExpiryMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async readCache<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const data = await this.fsPromises().readFile(filePath, 'utf-8');
|
||||
return JSON.parse(data) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeCache(filePath: string, data: unknown): Promise<void> {
|
||||
const fs = this.fsPromises();
|
||||
try {
|
||||
await fs.writeFile(filePath, JSON.stringify(data));
|
||||
} catch {
|
||||
// best-effort cache write; ignore
|
||||
}
|
||||
}
|
||||
|
||||
private determineCacheDir(): string {
|
||||
// Prefer ~/.cache/saberlist/beatsaver, fallback to CWD .cache
|
||||
const os = this.osModule();
|
||||
const path = this.pathModule();
|
||||
const home = os.homedir?.();
|
||||
const homeCache = home ? path.join(home, '.cache') : null;
|
||||
if (homeCache) {
|
||||
const saberlist = path.join(homeCache, 'saberlist');
|
||||
const beatsaver = path.join(saberlist, 'beatsaver');
|
||||
return beatsaver;
|
||||
}
|
||||
return this.pathJoin(process.cwd(), '.cache');
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const fs = this.fsModule();
|
||||
const path = this.pathModule();
|
||||
const base = this.cacheDir;
|
||||
const parts = base.split(path.sep);
|
||||
let cur = parts[0] || path.sep;
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
cur = this.pathJoin(cur, parts[i]);
|
||||
if (!fs.existsSync(cur)) {
|
||||
try {
|
||||
fs.mkdirSync(cur);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private pathJoin(...segments: string[]): string {
|
||||
return this.pathModule().join(...segments);
|
||||
}
|
||||
|
||||
// Lazy require Node builtins to keep SSR-friendly import graph
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('fs') as typeof import('fs');
|
||||
}
|
||||
private pathModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('path') as typeof import('path');
|
||||
}
|
||||
private osModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('os') as typeof import('os');
|
||||
}
|
||||
}
|
||||
|
||||
export function createBeatSaverAPI(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}): BeatSaverAPI {
|
||||
return new BeatSaverAPI(fetchFn, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// Utilities for building Beat Saber playlists server-side
|
||||
|
||||
export interface Difficulty {
|
||||
name: string;
|
||||
characteristic: string;
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
hash: string;
|
||||
difficulties: Difficulty[];
|
||||
key?: string;
|
||||
levelId?: string;
|
||||
songName?: string;
|
||||
}
|
||||
|
||||
export interface CustomData {
|
||||
syncURL?: string | null;
|
||||
owner?: string | null;
|
||||
id?: string | null;
|
||||
hash?: string | null;
|
||||
shared?: boolean | null;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
playlistTitle: string;
|
||||
songs: Song[];
|
||||
playlistAuthor?: string;
|
||||
image?: string | null;
|
||||
coverImage?: string | null;
|
||||
description?: string;
|
||||
allowDuplicates?: boolean;
|
||||
customData?: CustomData | null;
|
||||
}
|
||||
|
||||
type StandardizedSongInput = {
|
||||
hash: string;
|
||||
difficulties?: Difficulty[];
|
||||
key?: string;
|
||||
levelId?: string;
|
||||
songName?: string;
|
||||
};
|
||||
|
||||
interface PlaylistBuilderOptions {
|
||||
coversDir?: string;
|
||||
historyFile?: string;
|
||||
outputDir?: string;
|
||||
}
|
||||
|
||||
export class PlaylistBuilder {
|
||||
private readonly coversDir: string;
|
||||
private readonly historyFile: string;
|
||||
private readonly outputDir: string;
|
||||
private history: { cover_history: string[] };
|
||||
|
||||
constructor(options: PlaylistBuilderOptions = {}) {
|
||||
const cwd = this.processModule().cwd();
|
||||
this.coversDir = options.coversDir ?? this.pathModule().join(cwd, 'covers');
|
||||
this.historyFile = options.historyFile ?? this.pathModule().join(cwd, 'playlist_history.json');
|
||||
this.outputDir = options.outputDir ?? cwd;
|
||||
|
||||
this.ensureDirectory(this.coversDir);
|
||||
this.ensureDirectory(this.outputDir);
|
||||
this.history = this.loadHistory();
|
||||
this.saveHistory();
|
||||
}
|
||||
|
||||
async createPlaylist(
|
||||
playlistData: StandardizedSongInput[],
|
||||
playlistTitle: string = 'playlist',
|
||||
playlistAuthor: string = 'SaberList Tool'
|
||||
): Promise<string> {
|
||||
const songs: Song[] = (playlistData ?? []).map((song) => ({
|
||||
hash: song.hash,
|
||||
difficulties: (song.difficulties ?? []).map((d) => ({ name: d.name, characteristic: d.characteristic })),
|
||||
key: song.key,
|
||||
levelId: song.levelId,
|
||||
songName: song.songName
|
||||
}));
|
||||
|
||||
const coverPath = this.getRandomUnusedCover();
|
||||
const imageBase64 = coverPath ? await this.encodeImage(coverPath) : null;
|
||||
const imageDataUri = imageBase64 ? `data:image/png;base64,${imageBase64}` : null;
|
||||
|
||||
const playlist: Playlist = {
|
||||
playlistTitle,
|
||||
playlistAuthor,
|
||||
songs,
|
||||
image: imageDataUri,
|
||||
coverImage: coverPath ?? null,
|
||||
description: `Playlist created by SaberList Tool on ${new Date().toISOString()}`,
|
||||
allowDuplicates: false,
|
||||
customData: {}
|
||||
};
|
||||
|
||||
// Remove undefined fields recursively before writing
|
||||
const cleaned = this.removeUndefined(playlist);
|
||||
const filename = this.pathModule().join(
|
||||
this.outputDir,
|
||||
`${playlistTitle.replace(/\s+/g, '_')}.bplist`
|
||||
);
|
||||
await this.writeJsonFile(filename, cleaned);
|
||||
this.consoleModule().log(`Playlist created: ${filename}`);
|
||||
return filename;
|
||||
}
|
||||
|
||||
async splitPlaylist(inputPlaylistPath: string, songsPerPlaylist: number = 50): Promise<string[]> {
|
||||
const fs = this.fsPromises();
|
||||
try {
|
||||
await fs.access(inputPlaylistPath);
|
||||
} catch {
|
||||
this.consoleModule().error(`Input playlist file not found: ${inputPlaylistPath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(inputPlaylistPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Playlist;
|
||||
|
||||
const playlistTitle = data.playlistTitle ?? 'Split Playlist';
|
||||
const playlistAuthor = data.playlistAuthor ?? 'SaberList Tool';
|
||||
const playlistDescription = data.description ?? '';
|
||||
const playlistImage = data.image ?? null;
|
||||
const playlistCoverImage = data.coverImage ?? null;
|
||||
const playlistCustomData = data.customData ?? null;
|
||||
|
||||
const songs = (data.songs ?? []) as Song[];
|
||||
const totalSongs = songs.length;
|
||||
if (totalSongs === 0) {
|
||||
this.consoleModule().warn('No songs found in the input playlist.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const numPlaylists = Math.floor((totalSongs + songsPerPlaylist - 1) / songsPerPlaylist);
|
||||
const created: string[] = [];
|
||||
|
||||
for (let i = 0; i < numPlaylists; i++) {
|
||||
const startIdx = i * songsPerPlaylist;
|
||||
const endIdx = Math.min((i + 1) * songsPerPlaylist, totalSongs);
|
||||
const subsetSongs = songs.slice(startIdx, endIdx);
|
||||
|
||||
const subset: Playlist = {
|
||||
playlistTitle: `${playlistTitle} (${i + 1}/${numPlaylists})`,
|
||||
playlistAuthor,
|
||||
songs: subsetSongs,
|
||||
image: playlistImage,
|
||||
coverImage: playlistCoverImage,
|
||||
description: playlistDescription,
|
||||
allowDuplicates: false,
|
||||
customData: playlistCustomData
|
||||
};
|
||||
|
||||
const cleaned = this.removeUndefined(subset);
|
||||
const filename = this.pathModule().join(
|
||||
this.outputDir,
|
||||
`${playlistTitle.replace(/\s+/g, '_')}_${i + 1}_${numPlaylists}.bplist`
|
||||
);
|
||||
await this.writeJsonFile(filename, cleaned);
|
||||
this.consoleModule().log(
|
||||
`Created split playlist: ${filename} with ${subsetSongs.length} songs`
|
||||
);
|
||||
created.push(filename);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
// Internals
|
||||
private ensureDirectory(dir: string): void {
|
||||
const fs = this.fsModule();
|
||||
if (!fs.existsSync(dir)) {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
this.consoleModule().log(`Created directory: ${dir}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadHistory(): { cover_history: string[] } {
|
||||
const fs = this.fsModule();
|
||||
try {
|
||||
if (fs.existsSync(this.historyFile)) {
|
||||
const raw = fs.readFileSync(this.historyFile, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { cover_history?: string[] };
|
||||
return { cover_history: parsed.cover_history ?? [] };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { cover_history: [] };
|
||||
}
|
||||
|
||||
private saveHistory(): void {
|
||||
const fs = this.fsModule();
|
||||
try {
|
||||
fs.writeFileSync(this.historyFile, JSON.stringify(this.history));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private getRandomUnusedCover(): string | null {
|
||||
const fs = this.fsModule();
|
||||
const path = this.pathModule();
|
||||
let available: string[] = [];
|
||||
try {
|
||||
available = (fs.readdirSync(this.coversDir) as string[]).filter(
|
||||
(f) => f.endsWith('.jpg') && !this.history.cover_history.includes(f)
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!available || available.length === 0) {
|
||||
this.consoleModule().warn('No unused cover images available. Using no cover.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const selected = available[Math.floor(Math.random() * available.length)];
|
||||
this.history.cover_history.push(selected);
|
||||
this.saveHistory();
|
||||
return path.join(this.coversDir, selected);
|
||||
}
|
||||
|
||||
private async encodeImage(imagePath: string): Promise<string> {
|
||||
const fs = this.fsPromises();
|
||||
const buf = await fs.readFile(imagePath);
|
||||
return buf.toString('base64');
|
||||
}
|
||||
|
||||
private async writeJsonFile(pathname: string, data: unknown): Promise<void> {
|
||||
const fs = this.fsPromises();
|
||||
await fs.writeFile(pathname, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
private removeUndefined<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((v) => this.removeUndefined(v)) as unknown as T;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (v === undefined) continue;
|
||||
out[k] = this.removeUndefined(v as never);
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Lazy Node built-ins for SSR friendliness
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('fs') as typeof import('fs');
|
||||
}
|
||||
private pathModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('path') as typeof import('path');
|
||||
}
|
||||
private processModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('process') as typeof import('process');
|
||||
}
|
||||
private consoleModule() {
|
||||
return console;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
const BASE_URL = 'https://scoresaber.com/api';
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
function buildQuery(params: QueryParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
const qs = searchParams.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
interface ScoreSaberApiOptions {
|
||||
cacheExpiryDays?: number;
|
||||
cacheDir?: string;
|
||||
maxRetries?: number;
|
||||
initialBackoffMs?: number;
|
||||
maxBackoffMs?: number;
|
||||
backoffFactor?: number;
|
||||
}
|
||||
|
||||
export class ScoreSaberAPI {
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private readonly cacheExpiryMs: number;
|
||||
private readonly cacheDir: string;
|
||||
private readonly maxRetries: number;
|
||||
private readonly initialBackoffMs: number;
|
||||
private readonly maxBackoffMs: number;
|
||||
private readonly backoffFactor: number;
|
||||
|
||||
constructor(fetchFn: typeof fetch, options: ScoreSaberApiOptions = {}) {
|
||||
this.fetchFn = fetchFn;
|
||||
this.cacheExpiryMs = (options.cacheExpiryDays ?? 1) * 24 * 60 * 60 * 1000;
|
||||
this.maxRetries = options.maxRetries ?? 5;
|
||||
this.initialBackoffMs = options.initialBackoffMs ?? 1000;
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
|
||||
this.backoffFactor = options.backoffFactor ?? 2;
|
||||
this.cacheDir = options.cacheDir ?? this.determineCacheDir();
|
||||
this.ensureCacheDir();
|
||||
}
|
||||
|
||||
async getRankedMaps(params: {
|
||||
minStar?: number;
|
||||
maxStar?: number;
|
||||
useCache?: boolean;
|
||||
maxPages?: number;
|
||||
delayMsBetweenPages?: number;
|
||||
} = {}): Promise<unknown[]> {
|
||||
const minStar = params.minStar ?? 5;
|
||||
const maxStar = params.maxStar ?? 10;
|
||||
const useCache = params.useCache ?? true;
|
||||
const maxPages = params.maxPages ?? undefined;
|
||||
const delayMs = params.delayMsBetweenPages ?? 500;
|
||||
|
||||
const cachePath = this.pathJoin(this.cacheDir, `ranked_maps_${minStar}_${maxStar}.json`);
|
||||
if (useCache && (await this.isCacheValid(cachePath))) {
|
||||
const cached = await this.readCache<unknown[]>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const all: unknown[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
if (maxPages !== undefined && page > maxPages) break;
|
||||
const url = `${BASE_URL}/leaderboards${buildQuery({
|
||||
minStar,
|
||||
maxStar,
|
||||
unique: 'true',
|
||||
ranked: 'true',
|
||||
page
|
||||
})}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`ScoreSaber getRankedMaps failed: ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
const leaderboards: unknown[] = data?.leaderboards ?? [];
|
||||
if (!leaderboards || leaderboards.length === 0) break;
|
||||
all.push(...leaderboards);
|
||||
|
||||
page += 1;
|
||||
await this.sleep(delayMs);
|
||||
}
|
||||
|
||||
await this.writeCache(cachePath, all);
|
||||
return all;
|
||||
}
|
||||
|
||||
async clearCache(minStar?: number, maxStar?: number): Promise<void> {
|
||||
const fs = this.fsPromises();
|
||||
if (minStar !== undefined && maxStar !== undefined) {
|
||||
const file = this.pathJoin(this.cacheDir, `ranked_maps_${minStar}_${maxStar}.json`);
|
||||
try {
|
||||
await fs.unlink(file);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => fs.unlink(this.pathJoin(this.cacheDir, e.name)))
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
getCacheDir(): string {
|
||||
return this.cacheDir;
|
||||
}
|
||||
|
||||
// Internal utilities
|
||||
private async request(url: string, init?: RequestInit): Promise<Response> {
|
||||
let attempt = 0;
|
||||
let backoffMs = this.initialBackoffMs;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const res = await this.fetchFn(url, init);
|
||||
if (res.status === 429) {
|
||||
attempt += 1;
|
||||
if (attempt > this.maxRetries) return res; // surface 429 if retries exceeded
|
||||
|
||||
const retryAfterHeader = res.headers.get('Retry-After');
|
||||
const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN;
|
||||
const waitMs = Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : backoffMs;
|
||||
await this.sleep(waitMs);
|
||||
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
attempt += 1;
|
||||
if (attempt > this.maxRetries) throw err;
|
||||
await this.sleep(backoffMs);
|
||||
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isCacheValid(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await this.fsPromises().stat(filePath);
|
||||
const ageMs = Date.now() - stat.mtimeMs;
|
||||
return ageMs < this.cacheExpiryMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async readCache<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const data = await this.fsPromises().readFile(filePath, 'utf-8');
|
||||
return JSON.parse(data) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeCache(filePath: string, data: unknown): Promise<void> {
|
||||
const fs = this.fsPromises();
|
||||
try {
|
||||
await fs.writeFile(filePath, JSON.stringify(data));
|
||||
} catch {
|
||||
// best-effort cache write; ignore
|
||||
}
|
||||
}
|
||||
|
||||
private determineCacheDir(): string {
|
||||
// Prefer ~/.cache/saberlist/scoresaber, fallback to CWD .cache
|
||||
const os = this.osModule();
|
||||
const path = this.pathModule();
|
||||
const home = os.homedir?.();
|
||||
const homeCache = home ? path.join(home, '.cache') : null;
|
||||
if (homeCache) {
|
||||
const saberlist = path.join(homeCache, 'saberlist');
|
||||
const scoresaber = path.join(saberlist, 'scoresaber');
|
||||
return scoresaber;
|
||||
}
|
||||
return this.pathJoin(process.cwd(), '.cache', 'scoresaber');
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const fs = this.fsModule();
|
||||
const path = this.pathModule();
|
||||
const base = this.cacheDir;
|
||||
const parts = base.split(path.sep);
|
||||
let cur = parts[0] || path.sep;
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
cur = this.pathJoin(cur, parts[i]);
|
||||
if (!fs.existsSync(cur)) {
|
||||
try {
|
||||
fs.mkdirSync(cur);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private pathJoin(...segments: string[]): string {
|
||||
return this.pathModule().join(...segments);
|
||||
}
|
||||
|
||||
// Lazy require Node builtins to keep SSR-friendly import graph
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('fs') as typeof import('fs');
|
||||
}
|
||||
private pathModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('path') as typeof import('path');
|
||||
}
|
||||
private osModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('os') as typeof import('os');
|
||||
}
|
||||
}
|
||||
|
||||
export function createScoreSaberAPI(
|
||||
fetchFn: typeof fetch,
|
||||
options: ScoreSaberApiOptions = {}
|
||||
): ScoreSaberAPI {
|
||||
return new ScoreSaberAPI(fetchFn, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { tweened } from 'svelte/motion';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
export type SongPlayerState = {
|
||||
currentHash: string | null;
|
||||
playing: boolean;
|
||||
duration: number;
|
||||
currentTime: number;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: SongPlayerState = {
|
||||
currentHash: null,
|
||||
playing: false,
|
||||
duration: 0,
|
||||
currentTime: 0,
|
||||
volume: 0.3
|
||||
};
|
||||
|
||||
let audio: HTMLAudioElement | null = null;
|
||||
let lastPreferredWasBL = false;
|
||||
|
||||
export const currentTimeStore = tweened(0, { duration: 60, easing: cubicOut });
|
||||
const { subscribe, update, set } = writable<SongPlayerState>({ ...DEFAULT_STATE });
|
||||
|
||||
function cleanup(): void {
|
||||
if (audio) {
|
||||
audio.removeEventListener('timeupdate', handleTimeUpdate as any);
|
||||
audio.removeEventListener('loadedmetadata', handleMetadata as any);
|
||||
audio.removeEventListener('ended', handleEnded as any);
|
||||
audio.removeEventListener('pause', handlePaused as any);
|
||||
try {
|
||||
audio.pause();
|
||||
} catch {}
|
||||
try {
|
||||
audio.currentTime = 0;
|
||||
} catch {}
|
||||
}
|
||||
audio = null;
|
||||
}
|
||||
|
||||
function handleTimeUpdate(): void {
|
||||
if (!audio) return;
|
||||
currentTimeStore.set(audio.currentTime);
|
||||
}
|
||||
|
||||
function handleMetadata(): void {
|
||||
if (!audio) return;
|
||||
update((state) => ({ ...state, duration: isFinite(audio.duration) ? audio.duration : 0 }));
|
||||
}
|
||||
|
||||
function handleEnded(): void {
|
||||
update((state) => ({ ...state, playing: false, currentHash: null }));
|
||||
}
|
||||
|
||||
function handlePaused(): void {
|
||||
update((state) => ({ ...state, playing: false }));
|
||||
}
|
||||
|
||||
function buildAudioUrl(hash: string, preferBeatLeader: boolean): string {
|
||||
const h = hash?.toLowerCase?.() ?? hash;
|
||||
// Prefer BeatSaver CDN; optionally allow BeatLeader CDN
|
||||
return preferBeatLeader
|
||||
? `https://cdn.songs.beatleader.com/${h}.mp3`
|
||||
: `https://eu.cdn.beatsaver.com/${h}.mp3`;
|
||||
}
|
||||
|
||||
export function togglePlay(hash: string, preferBeatLeader = false): void {
|
||||
if (!hash) return;
|
||||
const url = buildAudioUrl(hash, preferBeatLeader);
|
||||
const altUrl = buildAudioUrl(hash, !preferBeatLeader);
|
||||
lastPreferredWasBL = preferBeatLeader;
|
||||
|
||||
update((state) => {
|
||||
const shouldPlay = hash === state.currentHash ? !state.playing : true;
|
||||
|
||||
const initWithUrl = (initialUrl: string) => {
|
||||
cleanup();
|
||||
currentTimeStore.set(0);
|
||||
audio = new Audio(initialUrl);
|
||||
audio.volume = state.volume;
|
||||
audio.addEventListener('timeupdate', handleTimeUpdate as any);
|
||||
audio.addEventListener('loadedmetadata', handleMetadata as any);
|
||||
audio.addEventListener('ended', handleEnded as any);
|
||||
audio.addEventListener('pause', handlePaused as any);
|
||||
// Fallback to alternate CDN once on error
|
||||
let triedFallback = false;
|
||||
audio.addEventListener('error', () => {
|
||||
if (!audio || triedFallback) return;
|
||||
triedFallback = true;
|
||||
try {
|
||||
const wasPlaying = shouldPlay;
|
||||
audio.src = altUrl;
|
||||
audio.load();
|
||||
if (wasPlaying) audio.play?.();
|
||||
} catch {}
|
||||
}, { once: false } as any);
|
||||
};
|
||||
|
||||
if (hash !== state.currentHash) {
|
||||
initWithUrl(url);
|
||||
} else if (!audio) {
|
||||
initWithUrl(url);
|
||||
}
|
||||
|
||||
if (shouldPlay) {
|
||||
audio?.play?.();
|
||||
} else {
|
||||
audio?.pause?.();
|
||||
}
|
||||
|
||||
return { ...state, currentHash: hash, playing: shouldPlay };
|
||||
});
|
||||
}
|
||||
|
||||
export function setVolume(volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 0.3));
|
||||
update((state) => {
|
||||
if (audio) audio.volume = clamped;
|
||||
return { ...state, volume: clamped };
|
||||
});
|
||||
}
|
||||
|
||||
export function reset(): void {
|
||||
cleanup();
|
||||
set({ ...DEFAULT_STATE });
|
||||
}
|
||||
|
||||
export const songPlayerStore = { subscribe };
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import NavBar from '$lib/components/NavBar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<meta name="theme-color" content="#0b0f17" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-dvh bg-[radial-gradient(1200px_600px_at_80%_-10%,rgba(255,0,229,0.10),transparent),radial-gradient(900px_500px_at_10%_10%,rgba(34,211,238,0.10),transparent)]">
|
||||
<NavBar />
|
||||
<main class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
{@render children?.()}
|
||||
</main>
|
||||
<footer class="border-t border-white/10 text-sm text-muted/80">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6 flex items-center justify-between">
|
||||
<span>© {new Date().getFullYear()} Plebsaber.stream</span>
|
||||
<a href="https://svelte.dev" class="hover:underline text-muted">Built with SvelteKit</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
export const prerender = true;
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<section class="grid items-center gap-10 py-12 md:py-20 lg:grid-cols-2">
|
||||
<div class="space-y-6">
|
||||
<h1 class="font-display text-4xl sm:text-5xl lg:text-6xl leading-tight">
|
||||
Beat Saber tools for the <span class="neon-text">cyber</span> underground
|
||||
</h1>
|
||||
<p class="max-w-prose text-lg text-muted">
|
||||
Mods, maps, practice helpers and utilities. Tuned for performance. Styled for neon.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="/tools" class="btn-neon">Explore Tools</a>
|
||||
<a href="/guides" class="inline-flex items-center gap-2 rounded-md border border-white/10 px-4 py-2 text-white/80 hover:text-white hover:border-white/30 transition">Read Guides</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-6 bg-gradient-to-tr from-neon/20 via-transparent to-neon-fuchsia/20 blur-2xl rounded-3xl"></div>
|
||||
<div class="relative card-surface p-6 sm:p-8">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="rounded-lg bg-black/30 ring-1 ring-white/10 p-4">
|
||||
<div class="text-sm text-muted">PP Calculator</div>
|
||||
<div class="mt-1 text-2xl font-mono">Soon</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-black/30 ring-1 ring-white/10 p-4">
|
||||
<div class="text-sm text-muted">Map Search</div>
|
||||
<div class="mt-1 text-2xl font-mono">Soon</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-black/30 ring-1 ring-white/10 p-4">
|
||||
<div class="text-sm text-muted">Replay Analyzer</div>
|
||||
<div class="mt-1 text-2xl font-mono">Soon</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-black/30 ring-1 ring-white/10 p-4">
|
||||
<div class="text-sm text-muted">Practice Slicer</div>
|
||||
<div class="mt-1 text-2xl font-mono">Soon</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="py-8">
|
||||
<h2 class="font-display text-2xl tracking-widest text-muted mb-4">Featured tools</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array.from({ length: 6 }) as _, i}
|
||||
<article class="card-surface p-5">
|
||||
<h3 class="font-semibold">Tool {i + 1}</h3>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Coming soon. Have an idea? Open an issue or PR.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<button class="btn-neon">Learn more</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { createBeatLeaderAPI } from '$lib/server/beatleader';
|
||||
|
||||
export const GET: RequestHandler = async ({ fetch, url }) => {
|
||||
const api = createBeatLeaderAPI(fetch);
|
||||
const path = url.searchParams.get('path');
|
||||
if (!path) {
|
||||
return new Response(JSON.stringify({ error: 'Missing path' }), { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Simple pass-through for safe GETs: limit to known patterns
|
||||
if (path.startsWith('/player/')) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length === 2) {
|
||||
const playerId = parts[1];
|
||||
const data = await api.getPlayer(playerId);
|
||||
return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parts.length === 3 && parts[2] === 'scores') {
|
||||
const playerId = parts[1];
|
||||
const data = await api.getPlayerScores(playerId, Object.fromEntries(url.searchParams));
|
||||
return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
if (path.startsWith('/v5/scores/')) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
// /v5/scores/{hash}/{diff}/{mode}
|
||||
if (parts.length >= 5) {
|
||||
const hash = parts[2];
|
||||
const diff = parts[3];
|
||||
const mode = parts[4];
|
||||
const data = await api.getLeaderboard(hash, {
|
||||
diff,
|
||||
mode,
|
||||
page: Number(url.searchParams.get('page') ?? '1'),
|
||||
count: Number(url.searchParams.get('count') ?? '10')
|
||||
});
|
||||
return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/leaderboards') {
|
||||
const data = await api.getRankedLeaderboards({
|
||||
stars_from: url.searchParams.get('stars_from') ? Number(url.searchParams.get('stars_from')) : undefined,
|
||||
stars_to: url.searchParams.get('stars_to') ? Number(url.searchParams.get('stars_to')) : undefined,
|
||||
page: url.searchParams.get('page') ? Number(url.searchParams.get('page')) : undefined,
|
||||
count: url.searchParams.get('count') ? Number(url.searchParams.get('count')) : undefined
|
||||
});
|
||||
return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: 'Unsupported path' }), { status: 400 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return new Response(JSON.stringify({ error: message }), { status: 502 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { createBeatLeaderAPI } from '$lib/server/beatleader';
|
||||
|
||||
export const GET: RequestHandler = async ({ fetch, params, url }) => {
|
||||
const api = createBeatLeaderAPI(fetch);
|
||||
const includeScores = url.searchParams.get('scores');
|
||||
|
||||
try {
|
||||
if (includeScores === '1') {
|
||||
const data = await api.getPlayerScores(params.id, Object.fromEntries(url.searchParams));
|
||||
return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
const data = await api.getPlayer(params.id);
|
||||
return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return new Response(JSON.stringify({ error: message }), { status: 502 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<section class="py-8 prose prose-invert max-w-none">
|
||||
<h1 class="font-display tracking-widest">Guides</h1>
|
||||
<p>Community-written tips and guides for improving your Beat Saber game. Contributions welcome.</p>
|
||||
|
||||
<div class="not-prose grid gap-4 sm:grid-cols-2 lg:grid-cols-3 mt-6">
|
||||
{#each [
|
||||
'Setup & Mods',
|
||||
'Finding Great Maps',
|
||||
'Improving Accuracy',
|
||||
'Fitness & Endurance',
|
||||
'Controller Settings',
|
||||
'Troubleshooting'
|
||||
] as title}
|
||||
<article class="card-surface p-5">
|
||||
<h3 class="font-semibold">{title}</h3>
|
||||
<p class="mt-1 text-sm text-muted">Draft</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { page } from '@vitest/browser/context';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render h1', async () => {
|
||||
render(Page);
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 });
|
||||
await expect.element(heading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
<section class="py-8">
|
||||
<h1 class="font-display text-3xl sm:text-4xl">Tools</h1>
|
||||
<p class="mt-2 text-muted">A suite of utilities for Beat Saber players. More coming soon.</p>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each [
|
||||
{ name: 'BeatLeader Compare Players', href: '/tools/beatleader-compare', desc: 'Find songs A played that B has not' },
|
||||
{ name: 'PP Calculator', href: '#pp', desc: 'Soon' },
|
||||
{ name: 'Map Search', href: '#search', desc: 'Soon' },
|
||||
{ name: 'Replay Analyzer', href: '#replay', desc: 'Soon' },
|
||||
{ name: 'Practice Slicer', href: '#slicer', desc: 'Soon' }
|
||||
] as tool}
|
||||
<a href={tool.href} class="card-surface p-5 block">
|
||||
<div class="font-semibold">{tool.name}</div>
|
||||
<div class="mt-1 text-sm text-muted">{tool.desc}</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="sr-only">
|
||||
<div id="pp"></div>
|
||||
<div id="search"></div>
|
||||
<div id="replay"></div>
|
||||
<div id="slicer"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import SongPlayer from '$lib/components/SongPlayer.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 songCount = 40;
|
||||
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;
|
||||
|
||||
// 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> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 getCutoffEpoch(): number {
|
||||
return Math.floor(Date.now() / 1000) - ONE_YEAR_SECONDS;
|
||||
}
|
||||
|
||||
async function fetchAllRecentScores(playerId: string, cutoffEpoch: number): Promise<BeatLeaderScore[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
function loadHistory(): Record<string, string[]> {
|
||||
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<string, string[]>;
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveHistory(history: Record<string, string[]>): void {
|
||||
try {
|
||||
localStorage.setItem('bl_compare_history', JSON.stringify(history));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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(ev: SubmitEvent) {
|
||||
ev.preventDefault();
|
||||
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 = getCutoffEpoch();
|
||||
const [aScores, bScores] = await Promise.all([
|
||||
fetchAllRecentScores(a, cutoff),
|
||||
fetchAllRecentScores(b, cutoff)
|
||||
]);
|
||||
|
||||
const bHashes = new Set<string>();
|
||||
for (const s of bScores) {
|
||||
const hash = s.leaderboard?.song?.hash ?? undefined;
|
||||
if (hash) bHashes.add(hash);
|
||||
}
|
||||
|
||||
const history = loadHistory();
|
||||
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 hash = 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 (!hash) continue;
|
||||
if (bHashes.has(hash)) continue; // B has played this song
|
||||
|
||||
const diffName = normalizeDifficultyName(diffValue);
|
||||
const historyDiffs = history[hash] ?? [];
|
||||
if (historyDiffs.includes(diffName)) continue; // used previously
|
||||
|
||||
const key = `${hash}|${diffName}|${modeName}`;
|
||||
if (runSeen.has(key)) continue;
|
||||
runSeen.add(key);
|
||||
|
||||
candidates.push({
|
||||
hash,
|
||||
difficulties: [{ name: diffName, characteristic: modeName ?? 'Standard' }],
|
||||
timeset: t,
|
||||
leaderboardId
|
||||
});
|
||||
}
|
||||
|
||||
candidates.sort((x, y) => y.timeset - x.timeset);
|
||||
const limited = candidates.slice(0, Math.max(0, Math.min(200, Number(songCount) || 40)));
|
||||
|
||||
// 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;
|
||||
// Load BeatSaver metadata (covers, titles) for tiles
|
||||
loadMetaForResults(limited);
|
||||
} catch (err) {
|
||||
errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Try prefill from URL params if present
|
||||
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;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="py-8">
|
||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: A vs B — Played‑Only Delta</h1>
|
||||
<p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — last 12 months.</p>
|
||||
|
||||
<form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 items-end" on:submit|preventDefault={onCompare}>
|
||||
<div>
|
||||
<label class="block text-sm text-muted">Player A ID (source)</label>
|
||||
<input class="w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerA} placeholder="7656119... or BL ID" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-muted">Player B ID (target)</label>
|
||||
<input class="w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerB} placeholder="7656119... or BL ID" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-muted">Song count</label>
|
||||
<input class="w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="1" max="200" bind:value={songCount} />
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-neon" disabled={loading}>
|
||||
{#if loading}
|
||||
Loading...
|
||||
{:else}
|
||||
Compare
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="mt-4 text-danger">{errorMsg}</div>
|
||||
{/if}
|
||||
|
||||
{#if results.length > 0}
|
||||
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-3 text-sm text-muted">
|
||||
<span>{results.length} songs</span>
|
||||
<span>·</span>
|
||||
<label class="flex items-center gap-2">Sort
|
||||
<select class="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" 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="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" 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="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" 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 class="flex items-center gap-3">
|
||||
<button class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={downloadPlaylist}>Download .bplist</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loadingMeta}
|
||||
<div class="mt-2 text-xs text-muted">Loading covers…</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">
|
||||
<div class="aspect-square bg-black/30">
|
||||
{#if metaByHash[item.hash]?.coverURL}
|
||||
<img
|
||||
src={metaByHash[item.hash].coverURL}
|
||||
alt={metaByHash[item.hash]?.songName ?? item.hash}
|
||||
loading="lazy"
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="font-semibold truncate" title={metaByHash[item.hash]?.songName ?? item.hash}>
|
||||
{metaByHash[item.hash]?.songName ?? item.hash}
|
||||
</div>
|
||||
{#if metaByHash[item.hash]?.mapper}
|
||||
<div class="mt-0.5 text-xs text-muted truncate">{metaByHash[item.hash]?.mapper}</div>
|
||||
{/if}
|
||||
<div class="mt-2 flex items-center justify-between text-[11px]">
|
||||
<span class="rounded bg-white/10 px-2 py-0.5">
|
||||
{item.difficulties[0]?.characteristic ?? 'Standard'} · {item.difficulties[0]?.name}
|
||||
</span>
|
||||
<span class="text-muted">{new Date(item.timeset * 1000).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<SongPlayer hash={item.hash} preferBeatLeader={true} />
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<a
|
||||
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
|
||||
href={item.leaderboardId
|
||||
? `https://beatleader.com/leaderboard/global/${item.leaderboardId}`
|
||||
: `https://beatleader.com/leaderboard/global/${item.hash}?diff=${encodeURIComponent(item.difficulties[0]?.name ?? 'ExpertPlus')}&mode=${encodeURIComponent(item.difficulties[0]?.characteristic ?? 'Standard')}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Open in BeatLeader"
|
||||
>BL</a
|
||||
>
|
||||
<a
|
||||
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
|
||||
href={metaByHash[item.hash]?.key ? `https://beatsaver.com/maps/${metaByHash[item.hash]?.key}` : `https://beatsaver.com/search/hash/${item.hash}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Open in BeatSaver"
|
||||
>BSR</a
|
||||
>
|
||||
<button
|
||||
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
|
||||
on:click={() => navigator.clipboard.writeText(item.hash)}
|
||||
title="Copy hash"
|
||||
>Copy hash</button>
|
||||
</div>
|
||||
</div>
|
||||
</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; }
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user