New web project with Beat Saber tools

This commit is contained in:
Brian Lee
2025-08-09 00:16:28 -07:00
commit 6c2066d784
41 changed files with 11037 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
const links = [
{ href: '/', label: 'Home' },
{ href: '/tools', label: 'Tools' },
{ href: '/guides', label: 'Guides' }
];
let open = false;
const toggle = () => (open = !open);
const close = () => (open = false);
const year = new Date().getFullYear();
</script>
<header class="sticky top-0 z-40 backdrop-blur supports-[backdrop-filter]:bg-surface/50 border-b border-white/10">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-14 items-center justify-between">
<a href="/" class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-neon" style="box-shadow: 0 0 12px rgba(34,211,238,0.60);"></span>
<span class="font-display text-lg tracking-widest">
<span class="neon-text">PLEBSABER</span><span class="text-muted">.stream</span>
</span>
</a>
<nav class="hidden md:flex items-center gap-6">
{#each links as link}
<a href={link.href} class="text-muted hover:text-white transition">{link.label}</a>
{/each}
<a href="/tools" class="btn-neon">Launch Tools</a>
</nav>
<button class="md:hidden btn-neon px-3 py-1.5" on:click={toggle} aria-expanded={open} aria-controls="mobile-nav">
Menu
</button>
</div>
</div>
{#if open}
<div id="mobile-nav" class="md:hidden border-t border-white/10 bg-surface/80">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-3 grid gap-3">
{#each links as link}
<a href={link.href} on:click={close} class="text-muted hover:text-white transition">{link.label}</a>
{/each}
<a href="/tools" on:click={close} class="btn-neon w-max">Launch Tools</a>
</div>
</div>
{/if}
</header>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import { songPlayerStore, currentTimeStore, togglePlay, setVolume } from '$lib/stores/songPlayer';
export let hash: string;
export let preferBeatLeader = false;
let isCurrent = false;
$: isCurrent = $songPlayerStore?.currentHash === hash;
$: currentTime = isCurrent ? $currentTimeStore : 0;
function onToggle() {
togglePlay(hash, preferBeatLeader);
}
function onVolumeInput(e: Event) {
const v = Number((e.target as HTMLInputElement).value);
setVolume(v);
}
</script>
<div class="player">
<button class="play" on:click={onToggle} aria-label="Play/Pause">
{#if isCurrent && $songPlayerStore?.playing}
❚❚
{:else}
{/if}
</button>
<div class="timeline" title="Progress">
<div class="progress" style="width: {($songPlayerStore?.currentHash ? (currentTime / ($songPlayerStore?.duration || 1)) : 0) * 100}%"></div>
</div>
<div class="time">
{Math.floor(currentTime / 60)}:{String(Math.floor(currentTime % 60)).padStart(2, '0')} /
{Math.floor(($songPlayerStore?.duration || 0) / 60)}:{String(Math.floor(($songPlayerStore?.duration || 0) % 60)).padStart(2, '0')}
</div>
<div class="volume">
<input type="range" min="0" max="1" step="0.01" value={$songPlayerStore?.volume} on:input={onVolumeInput} />
</div>
<slot />
</div>
<style>
.player { display: flex; align-items: center; gap: 0.5rem; }
.play { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; background: transparent; color: white; cursor: pointer; }
.timeline { flex: 1; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; }
.progress { height: 100%; background: rgba(255,255,255,0.6); }
.time { font-size: 11px; opacity: 0.8; min-width: 80px; text-align: right; }
.volume input { width: 80px; }
</style>
+2
View File
@@ -0,0 +1,2 @@
// place files you want to import through the `$lib` alias in this folder.
export * from './server/playlist';
+121
View File
@@ -0,0 +1,121 @@
const BASE_URL = 'https://api.beatleader.com';
// Simple in-memory cache for GET requests to BeatLeader
// Caches JSON responses by URL for a short TTL to reduce backend load
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
type CacheEntry = { expiresAt: number; data: unknown };
const responseCache: Map<string, CacheEntry> = new Map();
async function fetchJsonCached(fetchFn: typeof fetch, url: string, ttlMs = CACHE_TTL_MS): Promise<unknown> {
const now = Date.now();
const cached = responseCache.get(url);
if (cached && cached.expiresAt > now) {
return cached.data;
}
const res = await fetchFn(url);
if (!res.ok) throw new Error(`BeatLeader request failed: ${res.status}`);
const data = await res.json();
responseCache.set(url, { expiresAt: now + ttlMs, data });
return data;
}
type QueryParams = Record<string, string | number | boolean | undefined | null>;
function buildQuery(params: QueryParams): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
searchParams.set(key, String(value));
});
const qs = searchParams.toString();
return qs ? `?${qs}` : '';
}
export class BeatLeaderAPI {
private readonly fetchFn: typeof fetch;
constructor(fetchFn: typeof fetch) {
this.fetchFn = fetchFn;
}
async getPlayer(playerId: string): Promise<unknown> {
const url = `${BASE_URL}/player/${encodeURIComponent(playerId)}`;
return fetchJsonCached(this.fetchFn, url);
}
async getPlayerScores(
playerId: string,
params: {
page?: number;
count?: number;
leaderboardContext?: string;
sortBy?: string | number;
order?: 'asc' | 'desc' | string;
search?: string;
diff?: string;
mode?: string;
requirements?: string;
type?: string;
hmd?: string;
modifiers?: string;
stars_from?: string | number;
stars_to?: string | number;
eventId?: string | number;
includeIO?: boolean;
} = {}
): Promise<unknown> {
const query = buildQuery({
page: params.page,
count: params.count,
leaderboardContext: params.leaderboardContext,
sortBy: params.sortBy,
order: params.order,
search: params.search,
diff: params.diff,
mode: params.mode,
requirements: params.requirements,
type: params.type,
hmd: params.hmd,
modifiers: params.modifiers,
stars_from: params.stars_from,
stars_to: params.stars_to,
eventId: params.eventId,
includeIO: params.includeIO
});
const url = `${BASE_URL}/player/${encodeURIComponent(playerId)}/scores${query}`;
return fetchJsonCached(this.fetchFn, url);
}
async getLeaderboard(
hash: string,
options: { diff?: string; mode?: string; page?: number; count?: number } = {}
): Promise<unknown> {
const diff = options.diff ?? 'ExpertPlus';
const mode = options.mode ?? 'Standard';
const query = buildQuery({ page: options.page, count: options.count });
const url = `${BASE_URL}/v5/scores/${encodeURIComponent(hash)}/${encodeURIComponent(
diff
)}/${encodeURIComponent(mode)}${query}`;
return fetchJsonCached(this.fetchFn, url);
}
async getRankedLeaderboards(params: { stars_from?: number; stars_to?: number; page?: number; count?: number } = {}): Promise<unknown> {
const query = buildQuery({
page: params.page,
count: params.count,
type: 'ranked',
stars_from: params.stars_from,
stars_to: params.stars_to
});
const url = `${BASE_URL}/leaderboards${query}`;
return fetchJsonCached(this.fetchFn, url);
}
}
export function createBeatLeaderAPI(fetchFn: typeof fetch): BeatLeaderAPI {
return new BeatLeaderAPI(fetchFn);
}
+319
View File
@@ -0,0 +1,319 @@
const BASE_URL = 'https://api.beatsaver.com';
type QueryParams = Record<string, string | number | boolean | undefined | null>;
function buildQuery(params: QueryParams): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
searchParams.set(key, String(value));
});
const qs = searchParams.toString();
return qs ? `?${qs}` : '';
}
// Minimal shapes for returned data
export interface CuratedSongInfo {
hash: string;
key: string;
songName: string;
}
export interface MapperMapInfo extends CuratedSongInfo {
date?: string;
}
export interface EnvironmentMapInfo extends CuratedSongInfo {
environment?: string;
}
interface BeatSaverApiOptions {
cacheExpiryDays?: number;
cacheDir?: string;
maxRetries?: number;
initialBackoffMs?: number;
maxBackoffMs?: number;
backoffFactor?: number;
}
export class BeatSaverAPI {
private readonly fetchFn: typeof fetch;
private readonly cacheExpiryMs: number;
private readonly cacheDir: string;
private readonly maxRetries: number;
private readonly initialBackoffMs: number;
private readonly maxBackoffMs: number;
private readonly backoffFactor: number;
constructor(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}) {
this.fetchFn = fetchFn;
this.cacheExpiryMs = (options.cacheExpiryDays ?? 1) * 24 * 60 * 60 * 1000;
this.maxRetries = options.maxRetries ?? 5;
this.initialBackoffMs = options.initialBackoffMs ?? 1000;
this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
this.backoffFactor = options.backoffFactor ?? 2;
this.cacheDir = options.cacheDir ?? this.determineCacheDir();
this.ensureCacheDir();
}
// Public API
async getCuratedSongs(useCache: boolean = true): Promise<CuratedSongInfo[]> {
const cachePath = this.pathJoin(this.cacheDir, 'curated_songs.json');
if (useCache) {
const cached = await this.readCache<CuratedSongInfo[]>(cachePath);
if (cached) return cached;
}
const processed: CuratedSongInfo[] = [];
let page = 0;
while (true) {
const url = `${BASE_URL}/search/text/${page}${buildQuery({ sortOrder: 'Curated', curated: 'true' })}`;
const res = await this.request(url);
if (!res.ok) throw new Error(`BeatSaver getCuratedSongs failed: ${res.status}`);
const data: any = await res.json();
for (const song of data?.docs ?? []) {
for (const version of song?.versions ?? []) {
processed.push({
hash: version?.hash,
key: song?.id,
songName: song?.metadata?.songName
});
}
}
const totalPages: number = data?.info?.pages ?? 0;
if (page >= totalPages - 1) break;
page += 1;
await this.sleep(1000);
}
// Do not expire curated songs cache (mirror Python approach). We still write once and always use it if present.
await this.writeCache(cachePath, processed);
return processed;
}
async getFollowedMappers(userId: number = 243016, useCache: boolean = true): Promise<unknown> {
const cachePath = this.pathJoin(this.cacheDir, `followed_mappers_${userId}.json`);
if (useCache && (await this.isCacheValid(cachePath))) {
const cached = await this.readCache<unknown>(cachePath);
if (cached) return cached;
}
const url = `${BASE_URL}/users/followedBy/${encodeURIComponent(String(userId))}/0`;
const res = await this.request(url);
if (!res.ok) throw new Error(`BeatSaver getFollowedMappers failed: ${res.status}`);
const mappers = await res.json();
await this.writeCache(cachePath, mappers);
return mappers;
}
async getMapperMaps(mapperId: number, useCache: boolean = true): Promise<MapperMapInfo[]> {
const cachePath = this.pathJoin(this.cacheDir, `mapper_${mapperId}_maps.json`);
if (useCache && (await this.isCacheValid(cachePath))) {
const cached = await this.readCache<MapperMapInfo[]>(cachePath);
if (cached) return cached;
}
const processed: MapperMapInfo[] = [];
let page = 0;
while (true) {
const url = `${BASE_URL}/search/text/${page}${buildQuery({ collaborator: String(mapperId), automapper: 'true', sortOrder: 'Latest' })}`;
const res = await this.request(url);
if (!res.ok) throw new Error(`BeatSaver getMapperMaps failed: ${res.status}`);
const data: any = await res.json();
for (const song of data?.docs ?? []) {
for (const version of song?.versions ?? []) {
processed.push({
hash: version?.hash,
key: song?.id,
songName: song?.metadata?.songName,
date: song?.lastPublishedAt
});
}
}
const totalPages: number = data?.info?.pages ?? 0;
if (page >= totalPages - 1) break;
page += 1;
await this.sleep(1000);
}
await this.writeCache(cachePath, processed);
return processed;
}
async getMapsByEnvironment(
environmentName: string,
options: { maxPages?: number; useCache?: boolean } = {}
): Promise<EnvironmentMapInfo[]> {
const useCache = options.useCache ?? true;
const cachePath = this.pathJoin(this.cacheDir, `environment_${environmentName}_maps.json`);
if (useCache && (await this.isCacheValid(cachePath))) {
const cached = await this.readCache<EnvironmentMapInfo[]>(cachePath);
if (cached) return cached;
}
const processed: EnvironmentMapInfo[] = [];
let page = 0;
const maxPages = options.maxPages ?? undefined;
while (true) {
const url = `${BASE_URL}/search/text/${page}${buildQuery({ environments: `${environmentName}Environment` })}`;
const res = await this.request(url);
if (!res.ok) throw new Error(`BeatSaver getMapsByEnvironment failed: ${res.status}`);
const data: any = await res.json();
for (const song of data?.docs ?? []) {
for (const version of song?.versions ?? []) {
processed.push({
hash: version?.hash,
key: song?.id,
songName: song?.metadata?.songName,
environment: environmentName
});
}
}
const totalPages: number = data?.info?.pages ?? 0;
if (page >= totalPages - 1) break;
page += 1;
if (maxPages !== undefined && page >= maxPages) break;
await this.sleep(1000);
}
await this.writeCache(cachePath, processed);
return processed;
}
async getMapByHash(mapHash: string): Promise<unknown> {
const url = `${BASE_URL}/maps/hash/${encodeURIComponent(mapHash)}`;
const res = await this.request(url);
if (!res.ok) throw new Error(`BeatSaver getMapByHash failed: ${res.status}`);
return res.json();
}
// Internal utilities
private async request(url: string, init?: RequestInit): Promise<Response> {
let attempt = 0;
let backoffMs = this.initialBackoffMs;
while (true) {
try {
const res = await this.fetchFn(url, init);
if (res.status === 429) {
attempt += 1;
if (attempt > this.maxRetries) return res; // surface 429 if retries exceeded
const retryAfterHeader = res.headers.get('Retry-After');
const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN;
const waitMs = Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : backoffMs;
await this.sleep(waitMs);
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
continue;
}
return res;
} catch (err) {
attempt += 1;
if (attempt > this.maxRetries) throw err;
await this.sleep(backoffMs);
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
}
}
}
private async isCacheValid(filePath: string): Promise<boolean> {
try {
const stat = await this.fsPromises().stat(filePath);
const ageMs = Date.now() - stat.mtimeMs;
return ageMs < this.cacheExpiryMs;
} catch {
return false;
}
}
private async readCache<T>(filePath: string): Promise<T | null> {
try {
const data = await this.fsPromises().readFile(filePath, 'utf-8');
return JSON.parse(data) as T;
} catch {
return null;
}
}
private async writeCache(filePath: string, data: unknown): Promise<void> {
const fs = this.fsPromises();
try {
await fs.writeFile(filePath, JSON.stringify(data));
} catch {
// best-effort cache write; ignore
}
}
private determineCacheDir(): string {
// Prefer ~/.cache/saberlist/beatsaver, fallback to CWD .cache
const os = this.osModule();
const path = this.pathModule();
const home = os.homedir?.();
const homeCache = home ? path.join(home, '.cache') : null;
if (homeCache) {
const saberlist = path.join(homeCache, 'saberlist');
const beatsaver = path.join(saberlist, 'beatsaver');
return beatsaver;
}
return this.pathJoin(process.cwd(), '.cache');
}
private ensureCacheDir(): void {
const fs = this.fsModule();
const path = this.pathModule();
const base = this.cacheDir;
const parts = base.split(path.sep);
let cur = parts[0] || path.sep;
for (let i = 1; i < parts.length; i++) {
cur = this.pathJoin(cur, parts[i]);
if (!fs.existsSync(cur)) {
try {
fs.mkdirSync(cur);
} catch {
// ignore
}
}
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private pathJoin(...segments: string[]): string {
return this.pathModule().join(...segments);
}
// Lazy require Node builtins to keep SSR-friendly import graph
private fsPromises() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
return fs.promises as import('fs').Promises;
}
private fsModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('fs') as typeof import('fs');
}
private pathModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('path') as typeof import('path');
}
private osModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('os') as typeof import('os');
}
}
export function createBeatSaverAPI(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}): BeatSaverAPI {
return new BeatSaverAPI(fetchFn, options);
}
+275
View File
@@ -0,0 +1,275 @@
// Utilities for building Beat Saber playlists server-side
export interface Difficulty {
name: string;
characteristic: string;
}
export interface Song {
hash: string;
difficulties: Difficulty[];
key?: string;
levelId?: string;
songName?: string;
}
export interface CustomData {
syncURL?: string | null;
owner?: string | null;
id?: string | null;
hash?: string | null;
shared?: boolean | null;
}
export interface Playlist {
playlistTitle: string;
songs: Song[];
playlistAuthor?: string;
image?: string | null;
coverImage?: string | null;
description?: string;
allowDuplicates?: boolean;
customData?: CustomData | null;
}
type StandardizedSongInput = {
hash: string;
difficulties?: Difficulty[];
key?: string;
levelId?: string;
songName?: string;
};
interface PlaylistBuilderOptions {
coversDir?: string;
historyFile?: string;
outputDir?: string;
}
export class PlaylistBuilder {
private readonly coversDir: string;
private readonly historyFile: string;
private readonly outputDir: string;
private history: { cover_history: string[] };
constructor(options: PlaylistBuilderOptions = {}) {
const cwd = this.processModule().cwd();
this.coversDir = options.coversDir ?? this.pathModule().join(cwd, 'covers');
this.historyFile = options.historyFile ?? this.pathModule().join(cwd, 'playlist_history.json');
this.outputDir = options.outputDir ?? cwd;
this.ensureDirectory(this.coversDir);
this.ensureDirectory(this.outputDir);
this.history = this.loadHistory();
this.saveHistory();
}
async createPlaylist(
playlistData: StandardizedSongInput[],
playlistTitle: string = 'playlist',
playlistAuthor: string = 'SaberList Tool'
): Promise<string> {
const songs: Song[] = (playlistData ?? []).map((song) => ({
hash: song.hash,
difficulties: (song.difficulties ?? []).map((d) => ({ name: d.name, characteristic: d.characteristic })),
key: song.key,
levelId: song.levelId,
songName: song.songName
}));
const coverPath = this.getRandomUnusedCover();
const imageBase64 = coverPath ? await this.encodeImage(coverPath) : null;
const imageDataUri = imageBase64 ? `data:image/png;base64,${imageBase64}` : null;
const playlist: Playlist = {
playlistTitle,
playlistAuthor,
songs,
image: imageDataUri,
coverImage: coverPath ?? null,
description: `Playlist created by SaberList Tool on ${new Date().toISOString()}`,
allowDuplicates: false,
customData: {}
};
// Remove undefined fields recursively before writing
const cleaned = this.removeUndefined(playlist);
const filename = this.pathModule().join(
this.outputDir,
`${playlistTitle.replace(/\s+/g, '_')}.bplist`
);
await this.writeJsonFile(filename, cleaned);
this.consoleModule().log(`Playlist created: ${filename}`);
return filename;
}
async splitPlaylist(inputPlaylistPath: string, songsPerPlaylist: number = 50): Promise<string[]> {
const fs = this.fsPromises();
try {
await fs.access(inputPlaylistPath);
} catch {
this.consoleModule().error(`Input playlist file not found: ${inputPlaylistPath}`);
return [];
}
const raw = await fs.readFile(inputPlaylistPath, 'utf-8');
const data = JSON.parse(raw) as Playlist;
const playlistTitle = data.playlistTitle ?? 'Split Playlist';
const playlistAuthor = data.playlistAuthor ?? 'SaberList Tool';
const playlistDescription = data.description ?? '';
const playlistImage = data.image ?? null;
const playlistCoverImage = data.coverImage ?? null;
const playlistCustomData = data.customData ?? null;
const songs = (data.songs ?? []) as Song[];
const totalSongs = songs.length;
if (totalSongs === 0) {
this.consoleModule().warn('No songs found in the input playlist.');
return [];
}
const numPlaylists = Math.floor((totalSongs + songsPerPlaylist - 1) / songsPerPlaylist);
const created: string[] = [];
for (let i = 0; i < numPlaylists; i++) {
const startIdx = i * songsPerPlaylist;
const endIdx = Math.min((i + 1) * songsPerPlaylist, totalSongs);
const subsetSongs = songs.slice(startIdx, endIdx);
const subset: Playlist = {
playlistTitle: `${playlistTitle} (${i + 1}/${numPlaylists})`,
playlistAuthor,
songs: subsetSongs,
image: playlistImage,
coverImage: playlistCoverImage,
description: playlistDescription,
allowDuplicates: false,
customData: playlistCustomData
};
const cleaned = this.removeUndefined(subset);
const filename = this.pathModule().join(
this.outputDir,
`${playlistTitle.replace(/\s+/g, '_')}_${i + 1}_${numPlaylists}.bplist`
);
await this.writeJsonFile(filename, cleaned);
this.consoleModule().log(
`Created split playlist: ${filename} with ${subsetSongs.length} songs`
);
created.push(filename);
}
return created;
}
// Internals
private ensureDirectory(dir: string): void {
const fs = this.fsModule();
if (!fs.existsSync(dir)) {
try {
fs.mkdirSync(dir, { recursive: true });
this.consoleModule().log(`Created directory: ${dir}`);
} catch {
// ignore
}
}
}
private loadHistory(): { cover_history: string[] } {
const fs = this.fsModule();
try {
if (fs.existsSync(this.historyFile)) {
const raw = fs.readFileSync(this.historyFile, 'utf-8');
const parsed = JSON.parse(raw) as { cover_history?: string[] };
return { cover_history: parsed.cover_history ?? [] };
}
} catch {
// ignore
}
return { cover_history: [] };
}
private saveHistory(): void {
const fs = this.fsModule();
try {
fs.writeFileSync(this.historyFile, JSON.stringify(this.history));
} catch {
// ignore
}
}
private getRandomUnusedCover(): string | null {
const fs = this.fsModule();
const path = this.pathModule();
let available: string[] = [];
try {
available = (fs.readdirSync(this.coversDir) as string[]).filter(
(f) => f.endsWith('.jpg') && !this.history.cover_history.includes(f)
);
} catch {
// ignore
}
if (!available || available.length === 0) {
this.consoleModule().warn('No unused cover images available. Using no cover.');
return null;
}
const selected = available[Math.floor(Math.random() * available.length)];
this.history.cover_history.push(selected);
this.saveHistory();
return path.join(this.coversDir, selected);
}
private async encodeImage(imagePath: string): Promise<string> {
const fs = this.fsPromises();
const buf = await fs.readFile(imagePath);
return buf.toString('base64');
}
private async writeJsonFile(pathname: string, data: unknown): Promise<void> {
const fs = this.fsPromises();
await fs.writeFile(pathname, JSON.stringify(data, null, 2));
}
private removeUndefined<T>(obj: T): T {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) {
return obj.map((v) => this.removeUndefined(v)) as unknown as T;
}
if (typeof obj === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (v === undefined) continue;
out[k] = this.removeUndefined(v as never);
}
return out as T;
}
return obj;
}
// Lazy Node built-ins for SSR friendliness
private fsPromises() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
return fs.promises as import('fs').Promises;
}
private fsModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('fs') as typeof import('fs');
}
private pathModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('path') as typeof import('path');
}
private processModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('process') as typeof import('process');
}
private consoleModule() {
return console;
}
}
+242
View File
@@ -0,0 +1,242 @@
const BASE_URL = 'https://scoresaber.com/api';
type QueryParams = Record<string, string | number | boolean | undefined | null>;
function buildQuery(params: QueryParams): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
searchParams.set(key, String(value));
});
const qs = searchParams.toString();
return qs ? `?${qs}` : '';
}
interface ScoreSaberApiOptions {
cacheExpiryDays?: number;
cacheDir?: string;
maxRetries?: number;
initialBackoffMs?: number;
maxBackoffMs?: number;
backoffFactor?: number;
}
export class ScoreSaberAPI {
private readonly fetchFn: typeof fetch;
private readonly cacheExpiryMs: number;
private readonly cacheDir: string;
private readonly maxRetries: number;
private readonly initialBackoffMs: number;
private readonly maxBackoffMs: number;
private readonly backoffFactor: number;
constructor(fetchFn: typeof fetch, options: ScoreSaberApiOptions = {}) {
this.fetchFn = fetchFn;
this.cacheExpiryMs = (options.cacheExpiryDays ?? 1) * 24 * 60 * 60 * 1000;
this.maxRetries = options.maxRetries ?? 5;
this.initialBackoffMs = options.initialBackoffMs ?? 1000;
this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
this.backoffFactor = options.backoffFactor ?? 2;
this.cacheDir = options.cacheDir ?? this.determineCacheDir();
this.ensureCacheDir();
}
async getRankedMaps(params: {
minStar?: number;
maxStar?: number;
useCache?: boolean;
maxPages?: number;
delayMsBetweenPages?: number;
} = {}): Promise<unknown[]> {
const minStar = params.minStar ?? 5;
const maxStar = params.maxStar ?? 10;
const useCache = params.useCache ?? true;
const maxPages = params.maxPages ?? undefined;
const delayMs = params.delayMsBetweenPages ?? 500;
const cachePath = this.pathJoin(this.cacheDir, `ranked_maps_${minStar}_${maxStar}.json`);
if (useCache && (await this.isCacheValid(cachePath))) {
const cached = await this.readCache<unknown[]>(cachePath);
if (cached) return cached;
}
const all: unknown[] = [];
let page = 1;
while (true) {
if (maxPages !== undefined && page > maxPages) break;
const url = `${BASE_URL}/leaderboards${buildQuery({
minStar,
maxStar,
unique: 'true',
ranked: 'true',
page
})}`;
const res = await this.request(url);
if (!res.ok) throw new Error(`ScoreSaber getRankedMaps failed: ${res.status}`);
const data: any = await res.json();
const leaderboards: unknown[] = data?.leaderboards ?? [];
if (!leaderboards || leaderboards.length === 0) break;
all.push(...leaderboards);
page += 1;
await this.sleep(delayMs);
}
await this.writeCache(cachePath, all);
return all;
}
async clearCache(minStar?: number, maxStar?: number): Promise<void> {
const fs = this.fsPromises();
if (minStar !== undefined && maxStar !== undefined) {
const file = this.pathJoin(this.cacheDir, `ranked_maps_${minStar}_${maxStar}.json`);
try {
await fs.unlink(file);
} catch {
// ignore
}
return;
}
try {
const entries = await fs.readdir(this.cacheDir, { withFileTypes: true });
await Promise.all(
entries
.filter((e) => e.isFile())
.map((e) => fs.unlink(this.pathJoin(this.cacheDir, e.name)))
);
} catch {
// ignore
}
}
getCacheDir(): string {
return this.cacheDir;
}
// Internal utilities
private async request(url: string, init?: RequestInit): Promise<Response> {
let attempt = 0;
let backoffMs = this.initialBackoffMs;
while (true) {
try {
const res = await this.fetchFn(url, init);
if (res.status === 429) {
attempt += 1;
if (attempt > this.maxRetries) return res; // surface 429 if retries exceeded
const retryAfterHeader = res.headers.get('Retry-After');
const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN;
const waitMs = Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : backoffMs;
await this.sleep(waitMs);
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
continue;
}
return res;
} catch (err) {
attempt += 1;
if (attempt > this.maxRetries) throw err;
await this.sleep(backoffMs);
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
}
}
}
private async isCacheValid(filePath: string): Promise<boolean> {
try {
const stat = await this.fsPromises().stat(filePath);
const ageMs = Date.now() - stat.mtimeMs;
return ageMs < this.cacheExpiryMs;
} catch {
return false;
}
}
private async readCache<T>(filePath: string): Promise<T | null> {
try {
const data = await this.fsPromises().readFile(filePath, 'utf-8');
return JSON.parse(data) as T;
} catch {
return null;
}
}
private async writeCache(filePath: string, data: unknown): Promise<void> {
const fs = this.fsPromises();
try {
await fs.writeFile(filePath, JSON.stringify(data));
} catch {
// best-effort cache write; ignore
}
}
private determineCacheDir(): string {
// Prefer ~/.cache/saberlist/scoresaber, fallback to CWD .cache
const os = this.osModule();
const path = this.pathModule();
const home = os.homedir?.();
const homeCache = home ? path.join(home, '.cache') : null;
if (homeCache) {
const saberlist = path.join(homeCache, 'saberlist');
const scoresaber = path.join(saberlist, 'scoresaber');
return scoresaber;
}
return this.pathJoin(process.cwd(), '.cache', 'scoresaber');
}
private ensureCacheDir(): void {
const fs = this.fsModule();
const path = this.pathModule();
const base = this.cacheDir;
const parts = base.split(path.sep);
let cur = parts[0] || path.sep;
for (let i = 1; i < parts.length; i++) {
cur = this.pathJoin(cur, parts[i]);
if (!fs.existsSync(cur)) {
try {
fs.mkdirSync(cur);
} catch {
// ignore
}
}
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private pathJoin(...segments: string[]): string {
return this.pathModule().join(...segments);
}
// Lazy require Node builtins to keep SSR-friendly import graph
private fsPromises() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
return fs.promises as import('fs').Promises;
}
private fsModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('fs') as typeof import('fs');
}
private pathModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('path') as typeof import('path');
}
private osModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('os') as typeof import('os');
}
}
export function createScoreSaberAPI(
fetchFn: typeof fetch,
options: ScoreSaberApiOptions = {}
): ScoreSaberAPI {
return new ScoreSaberAPI(fetchFn, options);
}
+132
View File
@@ -0,0 +1,132 @@
import { writable } from 'svelte/store';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
export type SongPlayerState = {
currentHash: string | null;
playing: boolean;
duration: number;
currentTime: number;
volume: number;
};
const DEFAULT_STATE: SongPlayerState = {
currentHash: null,
playing: false,
duration: 0,
currentTime: 0,
volume: 0.3
};
let audio: HTMLAudioElement | null = null;
let lastPreferredWasBL = false;
export const currentTimeStore = tweened(0, { duration: 60, easing: cubicOut });
const { subscribe, update, set } = writable<SongPlayerState>({ ...DEFAULT_STATE });
function cleanup(): void {
if (audio) {
audio.removeEventListener('timeupdate', handleTimeUpdate as any);
audio.removeEventListener('loadedmetadata', handleMetadata as any);
audio.removeEventListener('ended', handleEnded as any);
audio.removeEventListener('pause', handlePaused as any);
try {
audio.pause();
} catch {}
try {
audio.currentTime = 0;
} catch {}
}
audio = null;
}
function handleTimeUpdate(): void {
if (!audio) return;
currentTimeStore.set(audio.currentTime);
}
function handleMetadata(): void {
if (!audio) return;
update((state) => ({ ...state, duration: isFinite(audio.duration) ? audio.duration : 0 }));
}
function handleEnded(): void {
update((state) => ({ ...state, playing: false, currentHash: null }));
}
function handlePaused(): void {
update((state) => ({ ...state, playing: false }));
}
function buildAudioUrl(hash: string, preferBeatLeader: boolean): string {
const h = hash?.toLowerCase?.() ?? hash;
// Prefer BeatSaver CDN; optionally allow BeatLeader CDN
return preferBeatLeader
? `https://cdn.songs.beatleader.com/${h}.mp3`
: `https://eu.cdn.beatsaver.com/${h}.mp3`;
}
export function togglePlay(hash: string, preferBeatLeader = false): void {
if (!hash) return;
const url = buildAudioUrl(hash, preferBeatLeader);
const altUrl = buildAudioUrl(hash, !preferBeatLeader);
lastPreferredWasBL = preferBeatLeader;
update((state) => {
const shouldPlay = hash === state.currentHash ? !state.playing : true;
const initWithUrl = (initialUrl: string) => {
cleanup();
currentTimeStore.set(0);
audio = new Audio(initialUrl);
audio.volume = state.volume;
audio.addEventListener('timeupdate', handleTimeUpdate as any);
audio.addEventListener('loadedmetadata', handleMetadata as any);
audio.addEventListener('ended', handleEnded as any);
audio.addEventListener('pause', handlePaused as any);
// Fallback to alternate CDN once on error
let triedFallback = false;
audio.addEventListener('error', () => {
if (!audio || triedFallback) return;
triedFallback = true;
try {
const wasPlaying = shouldPlay;
audio.src = altUrl;
audio.load();
if (wasPlaying) audio.play?.();
} catch {}
}, { once: false } as any);
};
if (hash !== state.currentHash) {
initWithUrl(url);
} else if (!audio) {
initWithUrl(url);
}
if (shouldPlay) {
audio?.play?.();
} else {
audio?.pause?.();
}
return { ...state, currentHash: hash, playing: shouldPlay };
});
}
export function setVolume(volume: number): void {
const clamped = Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 0.3));
update((state) => {
if (audio) audio.volume = clamped;
return { ...state, volume: clamped };
});
}
export function reset(): void {
cleanup();
set({ ...DEFAULT_STATE });
}
export const songPlayerStore = { subscribe };