// ScoreSaber exporter: player snapshot, scores (score + leaderboard items), replays. // Public API, no auth. Replays are paced at ~10.5 s (360/hour limit) and run as their own command. import * as fs from 'node:fs'; import * as path from 'node:path'; import { SS_SCRAPER_REPLAYS } from './config.ts'; import { get, limiters } from './http.ts'; import { archivePath, eachWithProgress, exists, listFiles, readJson, save, saveSnapshotIfChanged, updateState } from './store.ts'; const SS_API = 'https://scoresaber.com/api'; const ss = (playerId: string, ...p: string[]) => archivePath('scoresaber', playerId, ...p); export type SSItem = { score: { id: number; timeSet: string; hasReplay: boolean }; leaderboard: { songHash: string }; }; export function archivedSSScores(playerId: string, sinceUnix = 0): SSItem[] { const dir = ss(playerId, 'scores'); return listFiles(dir, '.json') .map((f) => readJson(path.join(dir, f))) .filter((it) => Date.parse(it.score.timeSet) / 1000 >= sinceUnix); } export async function exportSSPlayer(playerId: string): Promise { const r = await get(`${SS_API}/player/${playerId}/full`, { limiter: limiters.scoresaber }); console.log(`ScoreSaber player ${playerId}: ${saveSnapshotIfChanged(ss(playerId, 'player'), r.body) ? 'saved' : 'unchanged'}`); } /** Page `sort=recent` until scores are older than `sinceUnix`. Returns the new items. */ export async function exportSSScores(playerId: string, sinceUnix: number): Promise { console.log(`ScoreSaber scores for ${playerId} since ${new Date(sinceUnix * 1000).toISOString()}`); const added: SSItem[] = []; let seen = 0; for (let page = 1; ; page++) { const r = await get(`${SS_API}/player/${playerId}/scores?limit=100&page=${page}&sort=recent`, { limiter: limiters.scoresaber }); const json = JSON.parse(r.body.toString('utf-8')) as { playerScores: SSItem[]; metadata: { total: number } }; let reachedCutoff = false; for (const item of json.playerScores) { if (Date.parse(item.score.timeSet) / 1000 < sinceUnix) { reachedCutoff = true; break; } seen++; const file = ss(playerId, 'scores', `${item.score.id}.json`); if (exists(file)) continue; save(file, JSON.stringify(item)); added.push(item); } console.log(` page ${page}: ${seen} in window, ${added.length} new (total on server ${json.metadata.total})`); if (reachedCutoff || json.playerScores.length < 100) break; } updateState(`scoresaber:${playerId}`, { scoresSince: sinceUnix, lastScoresRun: new Date().toISOString() }); return added; } /** Newest archived ScoreSaber score time for a player (unix seconds), 0 when there is none. */ export function newestSSScoreTime(playerId: string): number { return archivedSSScores(playerId).reduce((max, it) => Math.max(max, Math.floor(Date.parse(it.score.timeSet) / 1000)), 0); } /** Download replays for archived scores with hasReplay, oldest first, ~10.5 s apart. */ export async function exportSSReplays(playerId: string, sinceUnix: number): Promise { const items = archivedSSScores(playerId, sinceUnix) .filter((it) => it.score.hasReplay) .sort((a, b) => Date.parse(a.score.timeSet) - Date.parse(b.score.timeSet)); seedFromScraper(playerId, items); const todo = items.filter((it) => !exists(ss(playerId, 'replays', `${it.score.id}.dat`))); console.log(`ScoreSaber replays for ${playerId}: ${items.length} with hasReplay, ${todo.length} to download (~${Math.ceil((todo.length * 10.5) / 60)} min)`); const res = await eachWithProgress( 'ss-replays', todo, async (it) => { const r = await get(`${SS_API}/v2/scores/${it.score.id}/replay`, { limiter: limiters.scoresaberReplay, accept: [404], timeoutMs: 300_000 }); if (r.status === 404) return 'missing'; save(ss(playerId, 'replays', `${it.score.id}.dat`), r.body); return 'done'; }, 10 ); if (res.missing || res.failed.length) { updateState('failures', { [`scoresaber:${playerId}:replays`]: res.failed.map((f) => f.item.score.id) }); } } /** Copy replays ss-scraper already downloaded (_.dat) instead of fetching them again. */ function seedFromScraper(playerId: string, items: SSItem[]): void { const dir = path.join(SS_SCRAPER_REPLAYS, playerId); const byId = new Map(); for (const f of listFiles(dir, '.dat')) byId.set(f.split('_')[0], f); let copied = 0; for (const it of items) { const src = byId.get(String(it.score.id)); const dest = ss(playerId, 'replays', `${it.score.id}.dat`); if (!src || exists(dest)) continue; save(dest, fs.readFileSync(path.join(dir, src))); copied++; } if (copied) console.log(` copied ${copied} replays from ${dir}`); } /** * The map hash BeatLeader and the archive use for a ScoreSaber item: lowercase, and for official * levels the level id without ScoreSaber's "ost_" prefix (BeatLeader: "beatsaber", SS: "ost_BeatSaber"). */ export function ssMapHash(it: SSItem): string { return it.leaderboard.songHash.toLowerCase().replace(/^ost_/, ''); } export function ssHashes(playerId: string, sinceUnix = 0): string[] { return [...new Set(archivedSSScores(playerId, sinceUnix).map(ssMapHash))].sort(); }