File size: 4,598 Bytes
95e3d2a
 
 
31d3580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a79c533
 
 
31d3580
 
 
 
 
95e3d2a
a79c533
 
 
 
 
 
 
 
 
 
 
 
 
31d3580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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);
}