Rename playlist discovery page and include the playlist description in the layout

This commit is contained in:
pleb 2025-11-03 14:46:05 -08:00
parent 2cae4ad3f6
commit 2f37126617
6 changed files with 111 additions and 58 deletions

View File

@ -5,6 +5,7 @@ import * as nodeOs from 'node:os';
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';
const CACHE_ROOT = '.data/beatsaver-cache';
type QueryParams = Record<string, string | number | boolean | undefined | null>;
@ -530,17 +531,8 @@ export class BeatSaverAPI {
}
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');
return this.pathJoin(process.cwd(), CACHE_ROOT);
}
private ensureCacheDir(): void {
@ -585,7 +577,12 @@ export class BeatSaverAPI {
}
export function createBeatSaverAPI(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}): BeatSaverAPI {
return new BeatSaverAPI(fetchFn, options);
const mergedOptions: BeatSaverApiOptions = {
...options,
cacheExpiryDays: options.cacheExpiryDays ?? 90
};
return new BeatSaverAPI(fetchFn, mergedOptions);
}

View File

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

View File

@ -7,7 +7,7 @@
{ 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 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' }
{ name: 'Playlist Discovery', href: '/tools/playlist-discovery', desc: 'Browse verified BeatSaver playlists sorted by recency' }
] as tool}
<a href={tool.href} class="card-surface p-5 block">
<div class="font-semibold">{tool.name}</div>

View File

@ -22,10 +22,20 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => {
const pageIndex = requestedPage - 1;
const api = createBeatSaverAPI(fetch);
const MIN_MAPS = 3;
try {
const response = (await api.searchPlaylists({ page: pageIndex })) as PlaylistSearchResponse<PlaylistFull>;
const docs = Array.isArray(response?.docs) ? response.docs : [];
const response = (await api.searchPlaylists({
page: pageIndex,
sortOrder: 'Latest',
verified: true,
includeEmpty: false
})) as PlaylistSearchResponse<PlaylistFull>;
const docsRaw = Array.isArray(response?.docs) ? response.docs : [];
const docs = docsRaw.filter((playlist) => {
const totalMaps = playlist?.stats?.totalMaps ?? 0;
return Number.isFinite(totalMaps) && totalMaps >= MIN_MAPS;
});
const info = (response?.info && typeof response.info === 'object' ? (response.info as SearchInfoShape) : null) ?? null;
const infoPage = typeof info?.page === 'number' ? info.page : pageIndex;

View File

@ -44,7 +44,7 @@
adminPlayer: BeatLeaderPlayerProfile | null;
};
const requirement = TOOL_REQUIREMENTS['map-pack-discovery'];
const requirement = TOOL_REQUIREMENTS['playlist-discovery'];
const playlistBaseUrl = 'https://beatsaver.com/playlists/';
const profileBaseUrl = 'https://beatsaver.com/profile/';
@ -82,7 +82,7 @@ function togglePlaylist(id: number) {
[id]: { loading: true, maps: playlistState[id]?.maps ?? [], error: null, total: playlistState[id]?.total ?? null, offset }
};
try {
const res = await fetch(`/tools/map-pack-discovery/playlist?id=${encodeURIComponent(String(id))}&offset=${offset}`);
const res = await fetch(`/tools/playlist-discovery/playlist?id=${encodeURIComponent(String(id))}&offset=${offset}`);
if (!res.ok) throw new Error(`Failed to load playlist ${id}: ${res.status}`);
const payload = (await res.json()) as { maps?: MapDetailWithOrder[] | null; totalCount?: number | null; offset: number; pageSize?: number };
const maps = Array.isArray(payload?.maps) ? payload.maps : [];
@ -178,13 +178,25 @@ function togglePlaylist(id: number) {
if (id === undefined || id === null) return profileBaseUrl;
return `${profileBaseUrl}${id}#playlists`;
}
function sanitizeDescription(value: string | null | undefined): string {
if (!value) return '';
return value.replace(/[^a-zA-Z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
}
function truncateTitle(value: string | null | undefined, maxLength = 20): string {
if (!value) return '';
if (value.length <= maxLength) return value;
const sliced = value.slice(0, maxLength - 1).trimEnd();
return `${sliced}…`;
}
</script>
<section class="py-8">
<h1 class="font-display text-3xl sm:text-4xl">Map Pack Discovery</h1>
<h1 class="font-display text-3xl sm:text-4xl">Playlist Discovery</h1>
<p class="mt-2 text-muted max-w-2xl">
See <strong><a href="https://bsaber.com/playlists" target="_blank" rel="noreferrer">Featured Packs</a> on Beast Saber</strong> for curated map packs. Otherwise, this page aims to provide a discovery tool for finding any and all map packs.
The heuristic this tool uses to find map packs is to show playlists on beatsaver where more than 3 maps link back to the playlist.
See <strong><a href="https://bsaber.com/playlists" target="_blank" rel="noreferrer">Featured Packs</a> on Beast Saber</strong> for curated map packs. You can also view most packs by searching BeatSaver for <a href="https://beatsaver.com/playlists?q=picks&order=Latest" target="_blank" rel="noreferrer">picks</a> or <a href="https://beatsaver.com/playlists?q=pack&verified=true&order=Latest" target="_blank" rel="noreferrer">packs</a>.
Otherwise this page is a UI/UX experiment that shows playlists by verified mappers, and provides a reactive interface for previewing map ratings and listening to song previews.
</p>
<HasToolAccess player={data?.player ?? null} requirement={requirement} adminRank={data?.adminRank ?? null} adminPlayer={data?.adminPlayer ?? null}>
@ -208,6 +220,8 @@ function togglePlaylist(id: number) {
<div class="mt-6 playlists-grid">
{#each playlists as playlist (playlist.playlistId)}
{@const displayName = truncateTitle(playlist.name)}
{@const description = sanitizeDescription(playlist.description ?? '')}
<article class:expanded={Boolean(expanded[playlist.playlistId])} class="playlist-tile card-surface">
<button class="tile-header" type="button" on:click={() => togglePlaylist(playlist.playlistId)}>
<div class="tile-cover">
@ -215,8 +229,14 @@ function togglePlaylist(id: number) {
</div>
<div class="tile-main">
<div class="tile-title">
<a href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer" on:click|stopPropagation>
{playlist.name}
<a
href={playlistLink(playlist.playlistId)}
target="_blank"
rel="noopener noreferrer"
on:click|stopPropagation
title={playlist.name}
>
{displayName}
</a>
<span class="map-count">{playlist.stats?.totalMaps ?? 0} maps</span>
</div>
@ -247,12 +267,53 @@ function togglePlaylist(id: number) {
{#if expanded[playlist.playlistId]}
{@const state = playlistState[playlist.playlistId]}
{@const offset = state?.offset ?? 0}
{@const total = typeof state?.total === 'number' ? state.total : null}
{@const currentCount = state?.maps?.length ?? 0}
{@const atFirstPage = offset <= 0}
{@const atLastPage = total !== null
? (offset + currentCount) >= total
: currentCount < SONGS_PER_PAGE}
<div class="tile-body">
{#if description}
<div class="tile-description">{description}</div>
{/if}
{#if state?.loading}
<div class="tile-status">Loading songs…</div>
{:else if state?.error}
<div class="tile-status error">{state.error}</div>
{:else if state?.maps?.length}
{#if state.total && state.total > SONGS_PER_PAGE}
<div class="tile-pagination">
<button
class="pager-btn"
on:click={() => loadPlaylistMaps(playlist.playlistId, Math.max(0, offset - SONGS_PER_PAGE))}
disabled={state.loading || atFirstPage}
>
Prev {SONGS_PER_PAGE}
</button>
<span class="tile-status">
{#if state.total === 0}
No songs
{:else}
Showing {(state.offset ?? 0) + 1}
-
{Math.min(
(state.offset ?? 0) + state.maps.length,
state.total ?? 0
)}
of {state.total}
{/if}
</span>
<button
class="pager-btn"
on:click={() => loadPlaylistMaps(playlist.playlistId, offset + SONGS_PER_PAGE)}
disabled={state.loading || atLastPage}
>
Next {SONGS_PER_PAGE}
</button>
</div>
{/if}
<div class="songs-grid">
{#each state.maps as card (card.id)}
<MapCard
@ -279,40 +340,6 @@ function togglePlaylist(id: number) {
</MapCard>
{/each}
</div>
{#if state.total && state.total > SONGS_PER_PAGE}
<div class="tile-pagination">
<button
class="pager-btn"
on:click={() => loadPlaylistMaps(playlist.playlistId, Math.max(0, (state.offset ?? 0) - SONGS_PER_PAGE))}
disabled={(state.offset ?? 0) === 0 || state.loading}
>
Prev {SONGS_PER_PAGE}
</button>
<span class="tile-status">
{#if state.total === 0}
No songs
{:else}
Showing {(state.offset ?? 0) + 1}
-
{Math.min(
(state.offset ?? 0) + state.maps.length,
state.total ?? 0
)}
of {state.total}
{/if}
</span>
<button
class="pager-btn"
on:click={() => loadPlaylistMaps(playlist.playlistId, (state.offset ?? 0) + SONGS_PER_PAGE)}
disabled={
state.loading ||
((state.offset ?? 0) + state.maps.length) >= (state.total ?? 0)
}
>
Next {SONGS_PER_PAGE}
</button>
</div>
{/if}
{:else}
<div class="tile-status">No songs found.</div>
{/if}
@ -457,6 +484,16 @@ function togglePlaylist(id: number) {
padding: 1rem 1.25rem 1.25rem;
}
.tile-description {
font-size: 0.95rem;
line-height: 1.45;
color: rgba(226, 232, 240, 0.85);
background: rgba(15, 23, 42, 0.55);
padding: 0.75rem 0.9rem;
border-radius: 0.65rem;
margin-bottom: 0.9rem;
}
.tile-status {
color: rgba(148, 163, 184, 0.95);
font-size: 0.9rem;
@ -485,7 +522,7 @@ function togglePlaylist(id: number) {
}
.tile-pagination {
margin-top: 1rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
@ -533,6 +570,15 @@ function togglePlaylist(id: number) {
pointer-events: none;
}
.pager-btn:disabled,
.pager-btn:disabled:hover {
opacity: 0.45;
pointer-events: none;
border-color: rgba(148, 163, 184, 0.2);
box-shadow: none;
cursor: default;
}
.card-surface {
background: rgba(15, 23, 42, 0.4);
border: 1px solid rgba(148, 163, 184, 0.08);

View File

@ -14,7 +14,7 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
const offset = Number.isFinite(offsetParam) && offsetParam >= 0 ? offsetParam : 0;
const api = createBeatSaverAPI(fetch);
try {
const detail = (await api.getPlaylistDetail(idParam, { page: 0, useCache: false })) as PlaylistPage;
const detail = (await api.getPlaylistDetail(idParam, { page: 0 })) as PlaylistPage;
const maps = Array.isArray(detail?.maps) ? detail.maps : [];
const totalCount = detail?.playlist?.stats?.totalMaps ?? maps.length;