New web project with Beat Saber tools
This commit is contained in:
commit
6c2066d784
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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-*
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal 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
16
.prettierrc
Normal 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
38
README.md
Normal 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
173
docs/DEPLOYMENT.md
Normal 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
40
eslint.config.js
Normal 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
5181
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal 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
1257
samples/MapCard.svelte
Normal file
File diff suppressed because it is too large
Load Diff
1932
samples/MapsList.svelte
Normal file
1932
samples/MapsList.svelte
Normal file
File diff suppressed because it is too large
Load Diff
24
samples/Preview.svelte
Normal file
24
samples/Preview.svelte
Normal 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
148
samples/SongPlayer.svelte
Normal 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
110
samples/songPlayer.js
Normal 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
48
src/app.css
Normal 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
13
src/app.d.ts
vendored
Normal 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
14
src/app.html
Normal 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
7
src/demo.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal 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 |
48
src/lib/components/NavBar.svelte
Normal file
48
src/lib/components/NavBar.svelte
Normal 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>
|
||||
|
||||
|
||||
51
src/lib/components/SongPlayer.svelte
Normal file
51
src/lib/components/SongPlayer.svelte
Normal 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
2
src/lib/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
export * from './server/playlist';
|
||||
121
src/lib/server/beatleader.ts
Normal file
121
src/lib/server/beatleader.ts
Normal 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
319
src/lib/server/beatsaver.ts
Normal 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
275
src/lib/server/playlist.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
242
src/lib/server/scoresaber.ts
Normal file
242
src/lib/server/scoresaber.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
132
src/lib/stores/songPlayer.ts
Normal file
132
src/lib/stores/songPlayer.ts
Normal 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
26
src/routes/+layout.svelte
Normal 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>© {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
3
src/routes/+layout.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const prerender = true;
|
||||
|
||||
|
||||
55
src/routes/+page.svelte
Normal file
55
src/routes/+page.svelte
Normal 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>
|
||||
61
src/routes/api/beatleader/+server.ts
Normal file
61
src/routes/api/beatleader/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
22
src/routes/api/beatleader/player/[id]/+server.ts
Normal file
22
src/routes/api/beatleader/player/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
22
src/routes/guides/+page.svelte
Normal file
22
src/routes/guides/+page.svelte
Normal 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>
|
||||
|
||||
|
||||
13
src/routes/page.svelte.spec.ts
Normal file
13
src/routes/page.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
src/routes/tools/+page.svelte
Normal file
28
src/routes/tools/+page.svelte
Normal 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>
|
||||
|
||||
|
||||
464
src/routes/tools/beatleader-compare/+page.svelte
Normal file
464
src/routes/tools/beatleader-compare/+page.svelte
Normal 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 — 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>
|
||||
|
||||
|
||||
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
12
svelte.config.js
Normal file
12
svelte.config.js
Normal 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
19
tsconfig.json
Normal 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
36
vite.config.ts
Normal 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
2
vitest-setup-client.ts
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="@vitest/browser/matchers" />
|
||||
/// <reference types="@vitest/browser/providers/playwright" />
|
||||
Loading…
x
Reference in New Issue
Block a user