From 2f37126617a2b228229081b32b2c1e32fd60e303 Mon Sep 17 00:00:00 2001 From: pleb Date: Mon, 3 Nov 2025 14:46:05 -0800 Subject: [PATCH] Rename playlist discovery page and include the playlist description in the layout --- src/lib/server/beatsaver.ts | 19 ++- src/lib/utils/plebsaber-utils.ts | 2 +- src/routes/tools/+page.svelte | 2 +- .../+page.server.ts | 14 +- .../+page.svelte | 130 ++++++++++++------ .../playlist/+server.ts | 2 +- 6 files changed, 111 insertions(+), 58 deletions(-) rename src/routes/tools/{map-pack-discovery => playlist-discovery}/+page.server.ts (79%) rename src/routes/tools/{map-pack-discovery => playlist-discovery}/+page.svelte (85%) rename src/routes/tools/{map-pack-discovery => playlist-discovery}/playlist/+server.ts (96%) diff --git a/src/lib/server/beatsaver.ts b/src/lib/server/beatsaver.ts index 0dba256..66edaec 100644 --- a/src/lib/server/beatsaver.ts +++ b/src/lib/server/beatsaver.ts @@ -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; @@ -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); } diff --git a/src/lib/utils/plebsaber-utils.ts b/src/lib/utils/plebsaber-utils.ts index 57bd412..a4349ba 100644 --- a/src/lib/utils/plebsaber-utils.ts +++ b/src/lib/utils/plebsaber-utils.ts @@ -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; export type ToolKey = keyof typeof TOOL_REQUIREMENTS; diff --git a/src/routes/tools/+page.svelte b/src/routes/tools/+page.svelte index b0ba25f..e0278f9 100644 --- a/src/routes/tools/+page.svelte +++ b/src/routes/tools/+page.svelte @@ -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}
{tool.name}
diff --git a/src/routes/tools/map-pack-discovery/+page.server.ts b/src/routes/tools/playlist-discovery/+page.server.ts similarity index 79% rename from src/routes/tools/map-pack-discovery/+page.server.ts rename to src/routes/tools/playlist-discovery/+page.server.ts index a81f160..e13a195 100644 --- a/src/routes/tools/map-pack-discovery/+page.server.ts +++ b/src/routes/tools/playlist-discovery/+page.server.ts @@ -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; - const docs = Array.isArray(response?.docs) ? response.docs : []; + const response = (await api.searchPlaylists({ + page: pageIndex, + sortOrder: 'Latest', + verified: true, + includeEmpty: false + })) as PlaylistSearchResponse; + 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; diff --git a/src/routes/tools/map-pack-discovery/+page.svelte b/src/routes/tools/playlist-discovery/+page.svelte similarity index 85% rename from src/routes/tools/map-pack-discovery/+page.svelte rename to src/routes/tools/playlist-discovery/+page.svelte index 595d01d..351337a 100644 --- a/src/routes/tools/map-pack-discovery/+page.svelte +++ b/src/routes/tools/playlist-discovery/+page.svelte @@ -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}…`; + }
-

Map Pack Discovery

+

Playlist Discovery

- See Featured Packs on Beast Saber 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 Featured Packs on Beast Saber for curated map packs. You can also view most packs by searching BeatSaver for picks or packs. + 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.

@@ -208,6 +220,8 @@ function togglePlaylist(id: number) {
{#each playlists as playlist (playlist.playlistId)} + {@const displayName = truncateTitle(playlist.name)} + {@const description = sanitizeDescription(playlist.description ?? '')}
+ + {#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} + + +
+ {/if}
{#each state.maps as card (card.id)} {/each}
- {#if state.total && state.total > SONGS_PER_PAGE} -
- - - {#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} - - -
- {/if} {:else}
No songs found.
{/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); diff --git a/src/routes/tools/map-pack-discovery/playlist/+server.ts b/src/routes/tools/playlist-discovery/playlist/+server.ts similarity index 96% rename from src/routes/tools/map-pack-discovery/playlist/+server.ts rename to src/routes/tools/playlist-discovery/playlist/+server.ts index 052c6a1..aca93cb 100644 --- a/src/routes/tools/map-pack-discovery/playlist/+server.ts +++ b/src/routes/tools/playlist-discovery/playlist/+server.ts @@ -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;