New web project with Beat Saber tools

This commit is contained in:
Brian Lee 2025-08-09 00:16:28 -07:00
commit 6c2066d784
41 changed files with 11037 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

16
.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

173
docs/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,173 @@
## Deploying plebsaber.stream on Debian 12 with Caddy (SvelteKit + adapter-node)
This guide sets up a production deployment of this SvelteKit app on Debian 12 using:
- SvelteKit with `@sveltejs/adapter-node` (Node server)
- Node.js 20 LTS
- systemd service to keep the app running
- Caddy as reverse proxy with automatic HTTPS for `plebsaber.stream`
### 1) Prerequisites
- Debian 12 server with root or sudo access
- DNS A/AAAA records pointing `plebsaber.stream` (and optionally `www`) to this server
- Caddy installed (from official repo)
Install Caddy (if not already):
```bash
sudo apt update
sudo apt install -y debian-keyring debian-archive-keyring curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
```
Install Node.js 20 (via NodeSource):
```bash
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
node -v
```
Create a deploy user and directories:
```bash
sudo useradd -r -m -d /srv/plebsaber -s /usr/sbin/nologin plebsaber || true
sudo mkdir -p /srv/plebsaber/app
sudo chown -R plebsaber:plebsaber /srv/plebsaber
```
### 2) Copy the app to the server
From your local machine (run in project root):
```bash
npm ci
npm run build
tar -czf plebsaber.build.tgz node_modules build package.json package-lock.json
scp plebsaber.build.tgz user@server:/srv/plebsaber/app/
```
On the server:
```bash
sudo -u plebsaber bash -lc 'cd /srv/plebsaber/app && tar -xzf plebsaber.build.tgz'
```
If building on the server instead:
```bash
sudo -u plebsaber bash -lc '
cd /srv/plebsaber/app &&
git clone <your-repo-url> . &&
npm ci &&
npm run build
'
```
### 3) Systemd service
Create `/etc/systemd/system/plebsaber.service`:
```ini
[Unit]
Description=Plebsaber SvelteKit service
After=network.target
[Service]
Type=simple
User=plebsaber
WorkingDirectory=/srv/plebsaber/app
Environment=NODE_ENV=production
ExecStart=/usr/bin/node build/index.js
Restart=always
RestartSec=5
# Hardening
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
```
Reload and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now plebsaber.service
sudo systemctl status plebsaber.service --no-pager
```
The Node server listens on `0.0.0.0:3000` by default.
### 4) Caddy reverse proxy
Create or edit `/etc/caddy/Caddyfile`:
```caddy
plebsaber.stream {
encode zstd gzip
@static {
path /favicon.ico
path /robots.txt
}
reverse_proxy 127.0.0.1:3000 {
health_uri /healthz
}
log {
output file /var/log/caddy/plebsaber.access.log
format json
}
}
```
Reload Caddy:
```bash
sudo systemctl reload caddy
```
### 5) Optional: health endpoint
Add a simple health route for Caddy health checks:
Create `src/routes/healthz/+server.ts`:
```ts
import type { RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async () => {
return new Response('ok', { headers: { 'content-type': 'text/plain' } });
};
```
Rebuild and redeploy if you add this file.
### 6) Updating the app
Deploying a new version:
```bash
npm ci
npm run build
tar -czf plebsaber.build.tgz node_modules build package.json package-lock.json
scp plebsaber.build.tgz user@server:/srv/plebsaber/app/
ssh user@server 'sudo -u plebsaber bash -lc "cd /srv/plebsaber/app && tar -xzf plebsaber.build.tgz" && sudo systemctl restart plebsaber.service'
```
### 7) Notes
- This project is configured with `@sveltejs/adapter-node` and includes server routes under `src/routes/api/beatleader/...` for server-side BeatLeader requests.
- If you need environment variables, set them in the systemd unit with `Environment=KEY=VALUE` or `EnvironmentFile=`.
- Ensure your firewall allows inbound 80/443 (Caddy) and blocks 3000 from the internet.

40
eslint.config.js Normal file
View File

@ -0,0 +1,40 @@
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

5181
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "plebsaber.stream",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "node build/index.js",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-node": "^5.2.14",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@vitest/browser": "^3.2.3",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"playwright": "^1.53.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4",
"vitest": "^3.2.3",
"vitest-browser-svelte": "^0.1.0"
}
}

1257
samples/MapCard.svelte Normal file

File diff suppressed because it is too large Load Diff

1932
samples/MapsList.svelte Normal file

File diff suppressed because it is too large Load Diff

24
samples/Preview.svelte Normal file
View File

@ -0,0 +1,24 @@
<script>
export let previewLink;
let iframe;
</script>
<iframe
class="previewFrame"
title=""
src={previewLink}
allowfullscreen
bind:this={iframe}
on:load={() => {
const newLocation = iframe.contentWindow.location.href;
window.location.href = newLocation;
}} />
<style>
.previewFrame {
width: 90vw;
height: 65vh;
border-radius: 0.6em;
}
</style>

148
samples/SongPlayer.svelte Normal file
View File

@ -0,0 +1,148 @@
<script>
import {onMount} from 'svelte';
import {tweened} from 'svelte/motion';
import {cubicOut} from 'svelte/easing';
import {createEventDispatcher} from 'svelte';
import Button from '../../Common/Button.svelte';
import {fade, fly, slide} from 'svelte/transition';
import {songPlayerStore, currentTimeStore} from '../../../stores/songPlayer';
export let song;
$: if (song) {
isCurrentSong = $songPlayerStore?.currentHash === song.hash;
}
let isCurrentSong = false;
let showVolumeSlider = false;
function handleTogglePlay() {
songPlayerStore.togglePlay(song.hash, song.downloadUrl.includes('beatleader'));
}
function handleVolumeChange(event) {
songPlayerStore.setVolume(parseFloat(event.target.value));
}
function toggleVolumeSlider() {
showVolumeSlider = !showVolumeSlider;
}
$: currentTime = $songPlayerStore?.currentHash == song.hash ? $currentTimeStore : 0;
</script>
<div class="player">
<Button
iconFa={isCurrentSong && $songPlayerStore?.playing ? 'fas fa-pause' : 'fas fa-play'}
cls="song-play-button"
square={true}
on:click={handleTogglePlay} />
<div class="timeline">
<div class="progress" style="width: {$songPlayerStore?.currentHash ? (currentTime / $songPlayerStore?.duration) * 100 : 0}%"></div>
</div>
<div class="time">
{Math.floor(currentTime / 60)}:{Math.floor(currentTime % 60)
.toString()
.padStart(2, '0')} / {Math.floor($songPlayerStore?.duration / 60)}:{Math.floor($songPlayerStore?.duration % 60)
.toString()
.padStart(2, '0')}
</div>
<div class="volume-control">
<Button
iconFa={$songPlayerStore?.volume === 0
? 'fas fa-volume-mute'
: $songPlayerStore?.volume < 0.5
? 'fas fa-volume-down'
: 'fas fa-volume-up'}
cls="volume-button"
square={true}
on:click={toggleVolumeSlider} />
{#if showVolumeSlider}
<div class="volume-slider" transition:fade={{duration: 100}}>
<input type="range" min="0" max="1" step="0.01" value={$songPlayerStore?.volume} on:input={handleVolumeChange} />
</div>
{/if}
</div>
</div>
<style>
.player {
display: flex;
align-items: center;
gap: 1em;
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.timeline {
flex-grow: 1;
height: 0.5em;
background: #dddddd30;
border-radius: 0.25em;
overflow: hidden;
position: relative;
}
.progress {
height: 100%;
background: #ffffff7c;
}
.time {
font-size: 0.9em;
}
.volume-control {
position: relative;
display: flex;
align-items: center;
}
.volume-slider {
position: absolute;
bottom: 100%;
right: 0;
background: #2a2a2a;
padding: 0.5em;
border-radius: 0.25em;
margin-bottom: 0.5em;
transform-origin: bottom right;
}
.volume-slider input[type='range'] {
writing-mode: bt-lr;
-webkit-appearance: slider-vertical;
width: 0.5em;
height: 100px;
padding: 0;
vertical-align: bottom;
writing-mode: vertical-lr;
direction: rtl;
appearance: slider-vertical;
}
:global(.song-play-button) {
width: 1.4em !important;
height: 1.4em !important;
padding: 0 !important;
padding-top: 0.15em !important;
margin-bottom: 0em !important;
--btn-bg-color: transparent !important;
--btn-color: #ffffff63 !important;
--btn-active-bg-color: transparent !important;
}
:global(.volume-button) {
width: 1.4em !important;
height: 1.4em !important;
padding: 0 !important;
padding-top: 0.15em !important;
margin-bottom: 0em !important;
--btn-bg-color: transparent !important;
--btn-color: #ffffff63 !important;
--btn-active-bg-color: transparent !important;
}
:global(.song-play-button:hover),
:global(.volume-button:hover) {
--btn-color: #ffffff !important;
}
@media (max-width: 768px) {
.player {
margin-bottom: 0.1em;
margin-top: 0.1em;
}
}
</style>

110
samples/songPlayer.js Normal file
View File

@ -0,0 +1,110 @@
import {writable} from 'svelte/store';
import {tweened} from 'svelte/motion';
import {cubicOut} from 'svelte/easing';
export let songPlayerStore = null;
export let currentTimeStore = null;
const DEFAULT_STATE = {
currentHash: null,
playing: false,
duration: 0,
currentTime: 0,
volume: 0.3,
};
export default () => {
if (songPlayerStore) return songPlayerStore;
let currentState = {...DEFAULT_STATE};
let audio = null;
currentTimeStore = tweened(0, {duration: 10, easing: cubicOut});
const {subscribe, set, update} = writable(currentState);
function cleanup() {
if (audio) {
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('loadedmetadata', handleMetadata);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('pause', handleEnded);
audio.pause();
audio.currentTime = 0;
audio = null;
}
}
function handleTimeUpdate() {
currentTimeStore.set(audio.currentTime);
}
function handleMetadata() {
update(state => ({...state, duration: audio.duration}));
}
function handleEnded() {
update(state => ({...state, playing: false, currentHash: null}));
}
function togglePlay(hash, local = false) {
update(state => {
const isPlaying = hash == state.currentHash ? !state.playing : true;
let url = local ? `https://cdn.songs.beatleader.com/${hash}.mp3` : `https://eu.cdn.beatsaver.com/${hash.toLowerCase()}.mp3`;
if (hash !== state.currentHash) {
cleanup();
currentTimeStore.set(0);
audio = new Audio(url);
audio.volume = state.volume;
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('loadedmetadata', handleMetadata);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('pause', handleEnded);
} else if (!audio) {
audio = new Audio(url);
audio.volume = state.volume;
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('loadedmetadata', handleMetadata);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('pause', handleEnded);
}
if (isPlaying) {
audio?.play();
} else {
audio?.pause();
}
return {
...state,
currentHash: hash,
playing: isPlaying,
};
});
}
function setVolume(volume) {
update(state => {
if (audio) {
audio.volume = volume;
}
return {...state, volume};
});
}
const reset = () => {
cleanup();
set(DEFAULT_STATE);
};
songPlayerStore = {
subscribe,
togglePlay,
setVolume,
reset,
};
return songPlayerStore;
};

48
src/app.css Normal file
View File

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

13
src/app.d.ts vendored Normal file
View File

@ -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 {};

14
src/app.html Normal file
View File

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

7
src/demo.spec.ts Normal file
View File

@ -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);
});
});

View File

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

View File

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

View File

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

2
src/lib/index.ts Normal file
View File

@ -0,0 +1,2 @@
// place files you want to import through the `$lib` alias in this folder.
export * from './server/playlist';

View File

@ -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);
}

319
src/lib/server/beatsaver.ts Normal file
View File

@ -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);
}

275
src/lib/server/playlist.ts Normal file
View File

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

View File

@ -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);
}

View File

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

26
src/routes/+layout.svelte Normal file
View File

@ -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>&copy; {new Date().getFullYear()} Plebsaber.stream</span>
<a href="https://svelte.dev" class="hover:underline text-muted">Built with SvelteKit</a>
</div>
</footer>
</div>

3
src/routes/+layout.ts Normal file
View File

@ -0,0 +1,3 @@
export const prerender = true;

55
src/routes/+page.svelte Normal file
View File

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

View File

@ -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 });
}
};

View File

@ -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 });
}
};

View File

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

View File

@ -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();
});
});

View File

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

View File

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

3
static/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

12
svelte.config.js Normal file
View File

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: { adapter: adapter() }
};
export default config;

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

36
vite.config.ts Normal file
View File

@ -0,0 +1,36 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'client',
environment: 'browser',
browser: {
enabled: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'],
setupFiles: ['./vitest-setup-client.ts']
}
},
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
});

2
vitest-setup-client.ts Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="@vitest/browser/matchers" />
/// <reference types="@vitest/browser/providers/playwright" />