// BeatSaver exporter: map JSON and the zip of the exact version hash that was played. import * as fs from 'node:fs'; import { get, limiters } from './http.ts'; import { archivePath, eachWithProgress, exists, readJson, save } from './store.ts'; const bs = (...p: string[]) => archivePath('beatsaver', ...p); const MISSING_FILE = () => bs('missing.json'); type BSMap = { id: string; versions: { hash: string; downloadURL: string }[] }; type Missing = Record; function readMissing(): Missing { try { return readJson(MISSING_FILE()); } catch { return {}; } } /** Entry names from a zip's central directory, or null if the zip is truncated or malformed. */ export function zipEntryNames(buf: Buffer): string[] | null { if (buf.length < 22 || buf.readUInt32LE(0) !== 0x04034b50) return null; const tailStart = Math.max(0, buf.length - 65_557); const eocd = buf.subarray(tailStart).lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); if (eocd < 0) return null; const e = tailStart + eocd; const count = buf.readUInt16LE(e + 10); let p = buf.readUInt32LE(e + 16); const names: string[] = []; for (let i = 0; i < count; i++) { if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50) return null; const nameLen = buf.readUInt16LE(p + 28); const extraLen = buf.readUInt16LE(p + 30); const commentLen = buf.readUInt16LE(p + 32); names.push(buf.toString('utf-8', p + 46, p + 46 + nameLen)); p += 46 + nameLen + extraLen + commentLen; } return names; } /** A zip is usable if its central directory is intact and lists an Info.dat (any case, any folder). */ export function looksLikeMapZip(buf: Buffer): boolean { return zipEntryNames(buf)?.some((n) => /(^|\/)info\.dat$/i.test(n)) ?? false; } export async function exportMaps(hashes: string[]): Promise { const missing = readMissing(); console.log(`BeatSaver maps: ${hashes.length} hashes`); const res = await eachWithProgress('maps', hashes, async (hash) => { const jsonFile = bs('maps', `${hash}.json`); const zipFile = bs('zips', `${hash}.zip`); if (exists(zipFile) && exists(jsonFile)) return 'skipped'; let map: BSMap | null = null; if (exists(jsonFile)) { map = readJson(jsonFile); } else { const r = await get(`https://api.beatsaver.com/maps/hash/${hash}`, { limiter: limiters.beatsaver, accept: [404] }); if (r.status === 200) { save(jsonFile, r.body); map = JSON.parse(r.body.toString('utf-8')) as BSMap; } } if (exists(zipFile)) return 'done'; // Prefer the version entry for this exact hash; old versions often remain on the CDN. const version = map?.versions.find((v) => v.hash.toLowerCase() === hash); const candidates = [version?.downloadURL, `https://r2cdn.beatsaver.com/${hash}.zip`, `https://cdn.beatsaver.com/${hash}.zip`].filter( (u): u is string => Boolean(u) ); for (const url of [...new Set(candidates)]) { const r = await get(url, { limiter: limiters.beatsaverCdn, accept: [403, 404] }); if (r.status === 200 && looksLikeMapZip(r.body)) { save(zipFile, r.body); delete missing[hash]; return 'done'; } } missing[hash] = { reason: map ? `map ${map.id} found but version zip unavailable` : 'hash unknown to BeatSaver', checkedAt: new Date().toISOString() }; return 'missing'; }); fs.mkdirSync(bs(), { recursive: true }); save(MISSING_FILE(), JSON.stringify(missing, null, 2) + '\n'); console.log(` missing.json: ${Object.keys(missing).length} hashes`); if (res.failed.length) console.warn(` ${res.failed.length} failed; rerun to retry`); }