Add new tool which will be for discovery map packs, currently it just lists playlists

This commit is contained in:
pleb 2025-11-02 13:39:01 -08:00
parent 7eb7680e16
commit 0b1146de71
5 changed files with 545 additions and 11 deletions

View File

@ -1,4 +1,10 @@
import * as nodeFs from 'node:fs';
import * as nodePath from 'node:path';
import * as nodeOs from 'node:os';
const BASE_URL = 'https://api.beatsaver.com'; const BASE_URL = 'https://api.beatsaver.com';
// Many API calls are undocumented and only work on the web API, e.g. searchPlaylists
const WEB_API_BASE_URL = 'https://beatsaver.com/api';
type QueryParams = Record<string, string | number | boolean | undefined | null>; type QueryParams = Record<string, string | number | boolean | undefined | null>;
@ -28,6 +34,183 @@ export interface EnvironmentMapInfo extends CuratedSongInfo {
environment?: string; environment?: string;
} }
type DateInput = string | number | Date;
export interface BeatSaverUser {
id: number;
name: string;
avatar: string;
description?: string | null;
hash?: string | null;
verifiedMapper?: boolean;
[key: string]: unknown;
}
export interface PlaylistStats {
totalMaps: number;
mapperCount: number;
totalDuration: number;
minNps: number;
maxNps: number;
upVotes: number;
downVotes: number;
avgScore: number;
[key: string]: unknown;
}
export interface PlaylistBasic {
playlistId: number;
playlistImage: string;
name: string;
type: string;
owner: number;
config?: unknown;
[key: string]: unknown;
}
export interface PlaylistFull {
playlistId: number;
name: string;
description: string;
playlistImage: string;
playlistImage512?: string | null;
owner: BeatSaverUser;
curator?: BeatSaverUser | null;
stats?: PlaylistStats | null;
createdAt?: string;
updatedAt?: string;
songsChangedAt?: string | null;
curatedAt?: string | null;
deletedAt?: string | null;
downloadURL: string;
type: string;
config?: unknown;
[key: string]: unknown;
}
export interface MapMetadata {
bpm: number;
duration: number;
songName: string;
songSubName: string;
songAuthorName: string;
levelAuthorName: string;
[key: string]: unknown;
}
export interface MapStats {
plays: number;
downloads: number;
upvotes: number;
downvotes: number;
score: number;
reviews?: number;
sentiment?: unknown;
[key: string]: unknown;
}
export interface MapDifficulty {
njs: number;
offset: number;
notes: number;
bombs: number;
obstacles: number;
nps: number;
length: number;
characteristic: string;
difficulty: string;
events: number;
chroma: boolean;
me: boolean;
ne: boolean;
cinema: boolean;
vivify?: boolean;
seconds: number;
stars?: number;
maxScore: number;
label?: string | null;
blStars?: number | null;
environment?: string | null;
[key: string]: unknown;
}
export interface MapVersion {
hash: string;
key?: string | null;
state: string;
createdAt: string;
downloadURL: string;
coverURL: string;
previewURL: string;
diffs?: MapDifficulty[];
feedback?: string | null;
testplayAt?: string | null;
testplays?: unknown;
scheduledAt?: string | null;
[key: string]: unknown;
}
export interface MapDetail {
id: string;
name: string;
description: string;
uploader: BeatSaverUser;
metadata: MapMetadata;
stats: MapStats;
versions: MapVersion[];
curator?: BeatSaverUser | null;
curatedAt?: string | null;
createdAt?: string;
updatedAt?: string;
lastPublishedAt?: string | null;
automapper?: boolean;
ranked?: boolean;
qualified?: boolean;
tags?: unknown;
bookmarked?: boolean | null;
collaborators?: BeatSaverUser[] | null;
declaredAi?: unknown;
blRanked?: boolean;
blQualified?: boolean;
nsfw?: boolean;
[key: string]: unknown;
}
export interface MapDetailWithOrder {
map: MapDetail;
order: number;
}
export interface PlaylistPage {
playlist: PlaylistFull | null;
maps: MapDetailWithOrder[] | null;
}
export interface PlaylistSearchResponse<T = PlaylistFull> {
docs: T[];
info?: unknown;
}
export interface PlaylistSearchParams {
query?: string;
page?: number;
sortOrder?: string;
order?: string;
ascending?: boolean;
includeEmpty?: boolean;
curated?: boolean;
verified?: boolean;
minNps?: number;
maxNps?: number;
from?: DateInput;
to?: DateInput;
}
export interface PlaylistDetailOptions {
page?: number | null;
useCache?: boolean;
}
interface BeatSaverApiOptions { interface BeatSaverApiOptions {
cacheExpiryDays?: number; cacheExpiryDays?: number;
cacheDir?: string; cacheDir?: string;
@ -198,6 +381,90 @@ export class BeatSaverAPI {
return processed; return processed;
} }
async searchPlaylists(params: PlaylistSearchParams = {}): Promise<PlaylistSearchResponse> {
const {
query = '',
page = 0,
sortOrder = 'Relevance',
order,
ascending,
includeEmpty,
curated,
verified,
minNps,
maxNps,
from,
to
} = params;
const normalizeDate = (value?: DateInput): string | undefined => {
if (value === undefined || value === null) return undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'number') return new Date(value).toISOString();
return value;
};
const url = `${WEB_API_BASE_URL}/playlists/search/${page}${buildQuery({
q: query,
sortOrder,
order,
ascending,
includeEmpty,
curated,
verified,
minNps,
maxNps,
from: normalizeDate(from),
to: normalizeDate(to)
})}`;
const res = await this.request(url);
if (!res.ok) throw new Error(`BeatSaver searchPlaylists failed: ${res.status}`);
return (await res.json()) as PlaylistSearchResponse;
}
async getPlaylistDetail(
playlistId: number | string,
options: PlaylistDetailOptions = {}
): Promise<PlaylistPage> {
const { page = null, useCache = true } = options;
const rawId = String(playlistId);
const safeId = encodeURIComponent(rawId);
const cacheSafeId = rawId.replace(/[^a-zA-Z0-9_-]/g, '_');
const pageSuffix = page === null ? 'detail' : `page_${page}`;
const cachePath = this.pathJoin(this.cacheDir, `playlist_${cacheSafeId}_${pageSuffix}.json`);
if (useCache && (await this.isCacheValid(cachePath))) {
const cached = await this.readCache<PlaylistPage>(cachePath);
if (cached) return cached;
}
const endpoint =
page === null
? `${WEB_API_BASE_URL}/playlists/id/${safeId}`
: `${WEB_API_BASE_URL}/playlists/id/${safeId}/${page}`;
const res = await this.request(endpoint);
if (!res.ok) throw new Error(`BeatSaver getPlaylistDetail failed: ${res.status}`);
const payload = (await res.json()) as PlaylistPage;
if (useCache) {
await this.writeCache(cachePath, payload);
}
return payload;
}
async getPlaylistMaps(
playlistId: number | string,
page = 0,
options: { useCache?: boolean } = {}
): Promise<MapDetailWithOrder[]> {
const { useCache = true } = options;
const detail = await this.getPlaylistDetail(playlistId, { page, useCache });
return detail.maps ?? [];
}
async getMapByHash(mapHash: string): Promise<unknown> { async getMapByHash(mapHash: string): Promise<unknown> {
const url = `${BASE_URL}/maps/hash/${encodeURIComponent(mapHash)}`; const url = `${BASE_URL}/maps/hash/${encodeURIComponent(mapHash)}`;
const res = await this.request(url); const res = await this.request(url);
@ -304,21 +571,16 @@ export class BeatSaverAPI {
// Lazy require Node builtins to keep SSR-friendly import graph // Lazy require Node builtins to keep SSR-friendly import graph
private fsPromises() { private fsPromises() {
// eslint-disable-next-line @typescript-eslint/no-var-requires return nodeFs.promises;
const fs = require('fs');
return fs.promises as typeof import('fs').promises;
} }
private fsModule() { private fsModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires return nodeFs;
return require('fs') as typeof import('fs');
} }
private pathModule() { private pathModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires return nodePath;
return require('path') as typeof import('path');
} }
private osModule() { private osModule() {
// eslint-disable-next-line @typescript-eslint/no-var-requires return nodeOs;
return require('os') as typeof import('os');
} }
} }

View File

@ -105,7 +105,8 @@ const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = {
export const TOOL_REQUIREMENTS = { export const TOOL_REQUIREMENTS = {
'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT, 'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT, 'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT 'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
'map-pack-discovery': DEFAULT_PRIVATE_TOOL_REQUIREMENT
} as const satisfies Record<string, ToolRequirement>; } as const satisfies Record<string, ToolRequirement>;
export type ToolKey = keyof typeof TOOL_REQUIREMENTS; export type ToolKey = keyof typeof TOOL_REQUIREMENTS;

View File

@ -6,7 +6,8 @@
{#each [ {#each [
{ name: 'Compare Play Histories', href: '/tools/compare-histories', desc: 'Find songs A played that B has not' }, { name: 'Compare Play Histories', href: '/tools/compare-histories', desc: 'Find songs A played that B has not' },
{ name: 'Player Playlist Gaps', href: '/tools/player-playlist-gaps', desc: 'Upload a playlist and find songs a player has not played' }, { name: 'Player Playlist Gaps', href: '/tools/player-playlist-gaps', desc: 'Upload a playlist and find songs a player has not played' },
{ name: 'Player Head-to-Head', href: '/tools/player-headtohead', desc: 'Compare two players on the same map/difficulty' } { name: 'Player Head-to-Head', href: '/tools/player-headtohead', desc: 'Compare two players on the same map/difficulty' },
{ name: 'Map Pack Discovery', href: '/tools/map-pack-discovery', desc: 'Browse curated BeatSaver playlists with mapper context' }
] as tool} ] as tool}
<a href={tool.href} class="card-surface p-5 block"> <a href={tool.href} class="card-surface p-5 block">
<div class="font-semibold">{tool.name}</div> <div class="font-semibold">{tool.name}</div>

View File

@ -0,0 +1,66 @@
import { createBeatSaverAPI, type PlaylistFull, type PlaylistSearchResponse } from '$lib/server/beatsaver';
import type { PageServerLoad } from './$types';
function parsePositiveInt(value: string | null, fallback: number): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return parsed;
}
type SearchInfoShape = {
page?: number;
pages?: number;
total?: number;
size?: number;
itemsPerPage?: number;
};
export const load: PageServerLoad = async ({ url, fetch, parent }) => {
const parentData = await parent();
const requestedPage = parsePositiveInt(url.searchParams.get('page'), 1);
const pageIndex = requestedPage - 1;
const api = createBeatSaverAPI(fetch);
try {
const response = (await api.searchPlaylists({ page: pageIndex })) as PlaylistSearchResponse<PlaylistFull>;
const docs = Array.isArray(response?.docs) ? response.docs : [];
const info = (response?.info && typeof response.info === 'object' ? (response.info as SearchInfoShape) : null) ?? null;
const infoPage = typeof info?.page === 'number' ? info.page : pageIndex;
const currentPage = infoPage + 1;
const totalPages = typeof info?.pages === 'number' ? info.pages : null;
const total = typeof info?.total === 'number' ? info.total : null;
const pageSize = typeof info?.size === 'number'
? info.size
: typeof info?.itemsPerPage === 'number'
? info.itemsPerPage
: docs.length;
return {
...parentData,
playlists: docs,
info,
page: currentPage,
totalPages,
total,
pageSize,
error: null
};
} catch (err) {
console.error('Failed to load BeatSaver playlists', err);
return {
...parentData,
playlists: [] as PlaylistFull[],
info: null,
page: requestedPage,
totalPages: null,
total: null,
pageSize: 0,
error: err instanceof Error ? err.message : 'Failed to load playlists'
};
}
};

View File

@ -0,0 +1,204 @@
<script lang="ts">
import HasToolAccess from '$lib/components/HasToolAccess.svelte';
import type { PlaylistFull } from '$lib/server/beatsaver';
import type { BeatLeaderPlayerProfile } from '$lib/utils/plebsaber-utils';
import { TOOL_REQUIREMENTS } from '$lib/utils/plebsaber-utils';
type PlaylistWithStats = PlaylistFull & { stats?: PlaylistFull['stats'] };
export let data: {
playlists: PlaylistWithStats[];
page: number;
totalPages: number | null;
total: number | null;
pageSize: number;
error: string | null;
player: BeatLeaderPlayerProfile | null;
adminRank: number | null;
adminPlayer: BeatLeaderPlayerProfile | null;
};
const requirement = TOOL_REQUIREMENTS['map-pack-discovery'];
const playlistBaseUrl = 'https://beatsaver.com/playlists/';
const profileBaseUrl = 'https://beatsaver.com/profile/';
$: playlists = data?.playlists ?? [];
$: page = data?.page ?? 1;
$: totalPages = data?.totalPages ?? null;
$: total = data?.total ?? null;
$: pageSize = data?.pageSize ?? 0;
$: hasPrev = page > 1;
$: hasNext = totalPages != null ? page < totalPages : playlists.length > 0 && playlists.length === pageSize;
$: prevHref = hasPrev ? `?page=${page - 1}` : null;
$: nextHref = hasNext ? `?page=${page + 1}` : null;
function formatScore(avgScore: number | undefined | null): string | null {
if (typeof avgScore !== 'number' || !Number.isFinite(avgScore)) return null;
return `${(avgScore * 100).toFixed(1)}%`;
}
function playlistLink(id: number | string | undefined): string {
if (id === undefined || id === null) return playlistBaseUrl;
return `${playlistBaseUrl}${id}`;
}
function profileLink(id: number | string | undefined): string {
if (id === undefined || id === null) return profileBaseUrl;
return `${profileBaseUrl}${id}#playlists`;
}
</script>
<section class="py-8">
<h1 class="font-display text-3xl sm:text-4xl">Map Pack Discovery</h1>
<p class="mt-2 text-muted max-w-2xl">
Browse curated BeatSaver playlists with quick access to pack covers, curators, and their overall rating.
</p>
<HasToolAccess player={data?.player ?? null} requirement={requirement} adminRank={data?.adminRank ?? null} adminPlayer={data?.adminPlayer ?? null}>
{#if data?.error}
<div class="mt-6 rounded-md border border-danger/40 bg-danger/10 px-4 py-3 text-danger text-sm">
{data.error}
</div>
{/if}
{#if !data?.error && playlists.length === 0}
<div class="mt-6 text-muted">No playlists found for this page.</div>
{/if}
{#if playlists.length > 0}
<div class="mt-6 flex flex-wrap items-center gap-3 text-sm text-muted">
<span>Page {page}{#if totalPages} of {totalPages}{/if}</span>
{#if total != null}
<span>{total.toLocaleString()} total playlists</span>
{/if}
</div>
<div class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{#each playlists as playlist (playlist.playlistId)}
<article class="card-surface overflow-hidden">
<a class="block" href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer">
<figure class="playlist-cover">
<img
src={playlist.playlistImage}
alt={`Playlist cover for ${playlist.name}`}
loading="lazy"
/>
</figure>
</a>
<div class="p-4 space-y-3">
<div>
<a class="playlist-title" href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer">{playlist.name}</a>
{#if playlist.description}
<p class="mt-1 line-clamp-3 text-sm text-muted">{playlist.description}</p>
{/if}
</div>
<div class="flex items-center justify-between text-sm text-muted">
<span>
{#if playlist.owner?.name}
<a class="hover:text-white transition-colors" href={profileLink(playlist.owner.id)} target="_blank" rel="noopener noreferrer">{playlist.owner.name}</a>
{:else}
Unknown curator
{/if}
</span>
{#if formatScore(playlist.stats?.avgScore)}
<span class="font-medium text-white">{formatScore(playlist.stats?.avgScore)}</span>
{/if}
</div>
</div>
</article>
{/each}
</div>
<div class="mt-8 flex items-center justify-center gap-3">
<a class="pager-btn" href={prevHref} aria-disabled={!hasPrev} data-disabled={!hasPrev}>
Prev
</a>
<span class="text-sm text-muted">Page {page}{#if totalPages} / {totalPages}{/if}</span>
<a class="pager-btn" href={nextHref} aria-disabled={!hasNext} data-disabled={!hasNext}>
Next
</a>
</div>
{/if}
</HasToolAccess>
</section>
<style>
.playlist-cover {
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.8), rgba(30, 58, 138, 0.4));
display: block;
}
.playlist-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.playlist-title {
font-size: 1.1rem;
font-weight: 600;
color: white;
text-decoration: none;
}
.playlist-title:hover {
text-decoration: underline;
}
.pager-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 1.1rem;
border-radius: 0.375rem;
border: 1px solid rgba(148, 163, 184, 0.2);
color: white;
font-size: 0.9rem;
text-decoration: none;
transition: all 0.2s ease;
}
.pager-btn:hover {
border-color: rgba(94, 234, 212, 0.5);
box-shadow: 0 0 12px rgba(94, 234, 212, 0.2);
}
.pager-btn[data-disabled='true'] {
opacity: 0.45;
pointer-events: none;
}
.card-surface {
background: rgba(15, 23, 42, 0.4);
border: 1px solid rgba(148, 163, 184, 0.08);
border-radius: 0.75rem;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
line-clamp: 3;
overflow: hidden;
}
.text-danger {
color: #f87171;
}
.bg-danger\/10 {
background-color: rgba(248, 113, 113, 0.1);
}
.border-danger\/40 {
border-color: rgba(248, 113, 113, 0.4);
}
</style>