diff --git a/src/lib/server/beatsaver.ts b/src/lib/server/beatsaver.ts index 1aab4c7..0dba256 100644 --- a/src/lib/server/beatsaver.ts +++ b/src/lib/server/beatsaver.ts @@ -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'; +// 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; @@ -28,6 +34,183 @@ export interface EnvironmentMapInfo extends CuratedSongInfo { 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 { + 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 { cacheExpiryDays?: number; cacheDir?: string; @@ -198,6 +381,90 @@ export class BeatSaverAPI { return processed; } + async searchPlaylists(params: PlaylistSearchParams = {}): Promise { + 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 { + 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(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 { + const { useCache = true } = options; + const detail = await this.getPlaylistDetail(playlistId, { page, useCache }); + return detail.maps ?? []; + } + async getMapByHash(mapHash: string): Promise { const url = `${BASE_URL}/maps/hash/${encodeURIComponent(mapHash)}`; const res = await this.request(url); @@ -304,21 +571,16 @@ export class BeatSaverAPI { // 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 typeof import('fs').promises; + return nodeFs.promises; } private fsModule() { - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require('fs') as typeof import('fs'); + return nodeFs; } private pathModule() { - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require('path') as typeof import('path'); + return nodePath; } private osModule() { - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require('os') as typeof import('os'); + return nodeOs; } } diff --git a/src/lib/utils/plebsaber-utils.ts b/src/lib/utils/plebsaber-utils.ts index 7d42043..57bd412 100644 --- a/src/lib/utils/plebsaber-utils.ts +++ b/src/lib/utils/plebsaber-utils.ts @@ -105,7 +105,8 @@ const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = { export const TOOL_REQUIREMENTS = { 'compare-histories': 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; export type ToolKey = keyof typeof TOOL_REQUIREMENTS; diff --git a/src/routes/tools/+page.svelte b/src/routes/tools/+page.svelte index e2b91bf..b0ba25f 100644 --- a/src/routes/tools/+page.svelte +++ b/src/routes/tools/+page.svelte @@ -6,7 +6,8 @@ {#each [ { 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: '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}
{tool.name}
diff --git a/src/routes/tools/map-pack-discovery/+page.server.ts b/src/routes/tools/map-pack-discovery/+page.server.ts new file mode 100644 index 0000000..a81f160 --- /dev/null +++ b/src/routes/tools/map-pack-discovery/+page.server.ts @@ -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; + 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' + }; + } +}; + + diff --git a/src/routes/tools/map-pack-discovery/+page.svelte b/src/routes/tools/map-pack-discovery/+page.svelte new file mode 100644 index 0000000..e5f20dd --- /dev/null +++ b/src/routes/tools/map-pack-discovery/+page.svelte @@ -0,0 +1,204 @@ + + +
+

Map Pack Discovery

+

+ Browse curated BeatSaver playlists with quick access to pack covers, curators, and their overall rating. +

+ + + {#if data?.error} +
+ {data.error} +
+ {/if} + + {#if !data?.error && playlists.length === 0} +
No playlists found for this page.
+ {/if} + + {#if playlists.length > 0} +
+ Page {page}{#if totalPages} of {totalPages}{/if} + {#if total != null} + • {total.toLocaleString()} total playlists + {/if} +
+ +
+ {#each playlists as playlist (playlist.playlistId)} +
+ +
+ {`Playlist +
+
+
+
+ {playlist.name} + {#if playlist.description} +

{playlist.description}

+ {/if} +
+ +
+ + {#if playlist.owner?.name} + {playlist.owner.name} + {:else} + Unknown curator + {/if} + + {#if formatScore(playlist.stats?.avgScore)} + {formatScore(playlist.stats?.avgScore)} + {/if} +
+
+
+ {/each} +
+ +
+ + Prev + + Page {page}{#if totalPages} / {totalPages}{/if} + + Next + +
+ {/if} + +
+ + + +