Rename playlist discovery page and include the playlist description in the layout
This commit is contained in:
parent
2cae4ad3f6
commit
2f37126617
@ -5,6 +5,7 @@ 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
|
// 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 WEB_API_BASE_URL = 'https://beatsaver.com/api';
|
||||||
|
const CACHE_ROOT = '.data/beatsaver-cache';
|
||||||
|
|
||||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||||
|
|
||||||
@ -530,17 +531,8 @@ export class BeatSaverAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private determineCacheDir(): string {
|
private determineCacheDir(): string {
|
||||||
// Prefer ~/.cache/saberlist/beatsaver, fallback to CWD .cache
|
|
||||||
const os = this.osModule();
|
|
||||||
const path = this.pathModule();
|
const path = this.pathModule();
|
||||||
const home = os.homedir?.();
|
return this.pathJoin(process.cwd(), CACHE_ROOT);
|
||||||
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 {
|
private ensureCacheDir(): void {
|
||||||
@ -585,7 +577,12 @@ export class BeatSaverAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createBeatSaverAPI(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}): 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -106,7 +106,7 @@ 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
|
'playlist-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;
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
{ 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' }
|
{ name: 'Playlist Discovery', href: '/tools/playlist-discovery', desc: 'Browse verified BeatSaver playlists sorted by recency' }
|
||||||
] 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>
|
||||||
|
|||||||
@ -22,10 +22,20 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => {
|
|||||||
const pageIndex = requestedPage - 1;
|
const pageIndex = requestedPage - 1;
|
||||||
|
|
||||||
const api = createBeatSaverAPI(fetch);
|
const api = createBeatSaverAPI(fetch);
|
||||||
|
const MIN_MAPS = 3;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = (await api.searchPlaylists({ page: pageIndex })) as PlaylistSearchResponse<PlaylistFull>;
|
const response = (await api.searchPlaylists({
|
||||||
const docs = Array.isArray(response?.docs) ? response.docs : [];
|
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 info = (response?.info && typeof response.info === 'object' ? (response.info as SearchInfoShape) : null) ?? null;
|
||||||
|
|
||||||
const infoPage = typeof info?.page === 'number' ? info.page : pageIndex;
|
const infoPage = typeof info?.page === 'number' ? info.page : pageIndex;
|
||||||
@ -44,7 +44,7 @@
|
|||||||
adminPlayer: BeatLeaderPlayerProfile | null;
|
adminPlayer: BeatLeaderPlayerProfile | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requirement = TOOL_REQUIREMENTS['map-pack-discovery'];
|
const requirement = TOOL_REQUIREMENTS['playlist-discovery'];
|
||||||
|
|
||||||
const playlistBaseUrl = 'https://beatsaver.com/playlists/';
|
const playlistBaseUrl = 'https://beatsaver.com/playlists/';
|
||||||
const profileBaseUrl = 'https://beatsaver.com/profile/';
|
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 }
|
[id]: { loading: true, maps: playlistState[id]?.maps ?? [], error: null, total: playlistState[id]?.total ?? null, offset }
|
||||||
};
|
};
|
||||||
try {
|
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}`);
|
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 payload = (await res.json()) as { maps?: MapDetailWithOrder[] | null; totalCount?: number | null; offset: number; pageSize?: number };
|
||||||
const maps = Array.isArray(payload?.maps) ? payload.maps : [];
|
const maps = Array.isArray(payload?.maps) ? payload.maps : [];
|
||||||
@ -178,13 +178,25 @@ function togglePlaylist(id: number) {
|
|||||||
if (id === undefined || id === null) return profileBaseUrl;
|
if (id === undefined || id === null) return profileBaseUrl;
|
||||||
return `${profileBaseUrl}${id}#playlists`;
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="py-8">
|
<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">
|
<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.
|
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>.
|
||||||
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.
|
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>
|
</p>
|
||||||
|
|
||||||
<HasToolAccess player={data?.player ?? null} requirement={requirement} adminRank={data?.adminRank ?? null} adminPlayer={data?.adminPlayer ?? null}>
|
<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">
|
<div class="mt-6 playlists-grid">
|
||||||
{#each playlists as playlist (playlist.playlistId)}
|
{#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">
|
<article class:expanded={Boolean(expanded[playlist.playlistId])} class="playlist-tile card-surface">
|
||||||
<button class="tile-header" type="button" on:click={() => togglePlaylist(playlist.playlistId)}>
|
<button class="tile-header" type="button" on:click={() => togglePlaylist(playlist.playlistId)}>
|
||||||
<div class="tile-cover">
|
<div class="tile-cover">
|
||||||
@ -215,8 +229,14 @@ function togglePlaylist(id: number) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="tile-main">
|
<div class="tile-main">
|
||||||
<div class="tile-title">
|
<div class="tile-title">
|
||||||
<a href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer" on:click|stopPropagation>
|
<a
|
||||||
{playlist.name}
|
href={playlistLink(playlist.playlistId)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
on:click|stopPropagation
|
||||||
|
title={playlist.name}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
</a>
|
</a>
|
||||||
<span class="map-count">{playlist.stats?.totalMaps ?? 0} maps</span>
|
<span class="map-count">{playlist.stats?.totalMaps ?? 0} maps</span>
|
||||||
</div>
|
</div>
|
||||||
@ -247,12 +267,53 @@ function togglePlaylist(id: number) {
|
|||||||
|
|
||||||
{#if expanded[playlist.playlistId]}
|
{#if expanded[playlist.playlistId]}
|
||||||
{@const state = playlistState[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">
|
<div class="tile-body">
|
||||||
|
{#if description}
|
||||||
|
<div class="tile-description">{description}</div>
|
||||||
|
{/if}
|
||||||
{#if state?.loading}
|
{#if state?.loading}
|
||||||
<div class="tile-status">Loading songs…</div>
|
<div class="tile-status">Loading songs…</div>
|
||||||
{:else if state?.error}
|
{:else if state?.error}
|
||||||
<div class="tile-status error">{state.error}</div>
|
<div class="tile-status error">{state.error}</div>
|
||||||
{:else if state?.maps?.length}
|
{: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">
|
<div class="songs-grid">
|
||||||
{#each state.maps as card (card.id)}
|
{#each state.maps as card (card.id)}
|
||||||
<MapCard
|
<MapCard
|
||||||
@ -279,40 +340,6 @@ function togglePlaylist(id: number) {
|
|||||||
</MapCard>
|
</MapCard>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<div class="tile-status">No songs found.</div>
|
<div class="tile-status">No songs found.</div>
|
||||||
{/if}
|
{/if}
|
||||||
@ -457,6 +484,16 @@ function togglePlaylist(id: number) {
|
|||||||
padding: 1rem 1.25rem 1.25rem;
|
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 {
|
.tile-status {
|
||||||
color: rgba(148, 163, 184, 0.95);
|
color: rgba(148, 163, 184, 0.95);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@ -485,7 +522,7 @@ function togglePlaylist(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tile-pagination {
|
.tile-pagination {
|
||||||
margin-top: 1rem;
|
margin-bottom: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@ -533,6 +570,15 @@ function togglePlaylist(id: number) {
|
|||||||
pointer-events: none;
|
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 {
|
.card-surface {
|
||||||
background: rgba(15, 23, 42, 0.4);
|
background: rgba(15, 23, 42, 0.4);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.08);
|
border: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
@ -14,7 +14,7 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
|
|||||||
const offset = Number.isFinite(offsetParam) && offsetParam >= 0 ? offsetParam : 0;
|
const offset = Number.isFinite(offsetParam) && offsetParam >= 0 ? offsetParam : 0;
|
||||||
const api = createBeatSaverAPI(fetch);
|
const api = createBeatSaverAPI(fetch);
|
||||||
try {
|
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 maps = Array.isArray(detail?.maps) ? detail.maps : [];
|
||||||
|
|
||||||
const totalCount = detail?.playlist?.stats?.totalMaps ?? maps.length;
|
const totalCount = detail?.playlist?.stats?.totalMaps ?? maps.length;
|
||||||
Loading…
x
Reference in New Issue
Block a user