Spaces:
Running
Running
| import { PLAYER_COUNT, type Match, type PreviewChunk, type Round } from '$lib/types'; | |
| import { listTree, resolveUrl, type FetchOpts } from '$lib/api/hub'; | |
| import { fetchParquetRows } from '$lib/api/parquet'; | |
| export type FetchOptions = FetchOpts; | |
| let matchesPromise: Promise<Match[]> | null = null; | |
| let roundsPromise: Promise<Round[]> | null = null; | |
| const matchPreviewsCache = new Map<string, Promise<PreviewChunk[]>>(); | |
| // The dataset is sharded by upload batch. Index files are named like | |
| // `index/manifest-<shard_id>.parquet` / `index/rounds-<shard_id>.parquet`. | |
| // Discover them at runtime so new shards don't require a code change. | |
| async function listIndexShards( | |
| prefix: 'manifest' | 'rounds', | |
| opts: FetchOptions | |
| ): Promise<string[]> { | |
| const entries = await listTree('index', opts); | |
| return entries | |
| .filter((e) => e.type === 'file') | |
| .map((e) => e.path) | |
| .filter((p) => { | |
| const name = p.split('/').pop() ?? ''; | |
| return name.startsWith(`${prefix}-`) && name.endsWith('.parquet'); | |
| }); | |
| } | |
| type IndexRow = { match_id: number; map_name: string; uploaded_at: string; round?: number }; | |
| async function loadShardedIndex<T extends IndexRow>( | |
| prefix: 'manifest' | 'rounds', | |
| opts: FetchOptions | |
| ): Promise<T[]> { | |
| const paths = await listIndexShards(prefix, opts); | |
| if (!paths.length) return []; | |
| const shards = await Promise.all(paths.map((p) => fetchParquetRows<T>(resolveUrl(p), opts))); | |
| // A re-rendered batch produces a second shard whose rows collide with the | |
| // original on (match,map[,round]). Keep the freshest copy per key. | |
| const keyOf = | |
| prefix === 'manifest' | |
| ? (r: T) => `${r.match_id}|${r.map_name}` | |
| : (r: T) => `${r.match_id}|${r.map_name}|${r.round}`; | |
| const latest = new Map<string, T>(); | |
| for (const r of shards.flat()) { | |
| const k = keyOf(r); | |
| const cur = latest.get(k); | |
| if (!cur || r.uploaded_at > cur.uploaded_at) latest.set(k, r); | |
| } | |
| return Array.from(latest.values()); | |
| } | |
| export function listMatches(opts: FetchOptions = {}): Promise<Match[]> { | |
| if (matchesPromise) return matchesPromise; | |
| matchesPromise = loadShardedIndex<Match>('manifest', opts) | |
| .then((rows) => { | |
| rows.sort( | |
| (a, b) => | |
| new Date(b.match_date).getTime() - new Date(a.match_date).getTime() || | |
| (a.map_index ?? 0) - (b.map_index ?? 0) | |
| ); | |
| return rows; | |
| }) | |
| .catch((err) => { | |
| matchesPromise = null; | |
| throw err; | |
| }); | |
| return matchesPromise; | |
| } | |
| export async function listAllRounds(opts: FetchOptions = {}): Promise<Round[]> { | |
| if (!roundsPromise) { | |
| roundsPromise = loadShardedIndex<Round>('rounds', opts).catch((err) => { | |
| roundsPromise = null; | |
| throw err; | |
| }); | |
| } | |
| return roundsPromise; | |
| } | |
| export async function listRounds( | |
| matchId: number, | |
| mapName: string, | |
| opts: FetchOptions = {} | |
| ): Promise<Round[]> { | |
| const all = await listAllRounds(opts); | |
| return all | |
| .filter((r) => r.match_id === matchId && r.map_name === mapName) | |
| .sort((a, b) => a.round - b.round); | |
| } | |
| /** | |
| * Fetch all preview rows for every player on this (match, map) in parallel. | |
| * The shard_id from the match's manifest row is enough to construct each | |
| * player's parquet URL — no tree-API discovery needed. | |
| */ | |
| async function loadMatchPreviews( | |
| matchId: number, | |
| mapName: string, | |
| opts: FetchOptions | |
| ): Promise<PreviewChunk[]> { | |
| const key = `${matchId}/${mapName}`; | |
| const cached = matchPreviewsCache.get(key); | |
| if (cached) return cached; | |
| const promise = (async () => { | |
| const matches = await listMatches(opts); | |
| const match = matches.find((m) => m.match_id === matchId && m.map_name === mapName); | |
| if (!match) return []; | |
| const players = Array.from({ length: PLAYER_COUNT }, (_, i) => i); | |
| const results = await Promise.all( | |
| players.map(async (player) => { | |
| const dir = `data/match_id=${matchId}/map_name=${mapName}/player=${player}`; | |
| try { | |
| const rows = await fetchParquetRows<PreviewChunk>( | |
| resolveUrl(`${dir}/chunks-preview-${match.shard_id}.parquet`), | |
| opts | |
| ); | |
| for (const r of rows) r.preview_video = { src: resolveUrl(`${dir}/${r.preview_path}`) }; | |
| return rows; | |
| } catch { | |
| return [] as PreviewChunk[]; | |
| } | |
| }) | |
| ); | |
| return results.flat(); | |
| })().catch((err) => { | |
| matchPreviewsCache.delete(key); | |
| throw err; | |
| }); | |
| matchPreviewsCache.set(key, promise); | |
| return promise; | |
| } | |
| export async function listRoundPreviews( | |
| matchId: number, | |
| mapName: string, | |
| round: number, | |
| opts: FetchOptions = {} | |
| ): Promise<PreviewChunk[]> { | |
| const all = await loadMatchPreviews(matchId, mapName, opts); | |
| return all | |
| .filter((p) => p.round === round) | |
| .sort((a, b) => a.player - b.player || a.chunk_index - b.chunk_index); | |
| } | |