···11import { GeneratedPools } from '../models/index.js';
22+import { HttpError } from '../errors.js';
23import { getActiveEvent } from './event.js';
44+import { bracketsConfig } from '../external/brackets.js';
55+import { extractSongId, fetchAndCacheChart } from '../external/spinshare.js';
66+import { fetchOpenSets, type StartggSet } from '../external/startgg.js';
77+import { generatePools, type TierChart } from '../util/poolGenerator.js';
38import { logger } from '../logger.js';
49510let generatedPools: {
···160165 songId: c?.songId ?? null,
161166 }));
162167}
168168+169169+// --- Sheet import pipeline (mirrors the bot's /generate flow) ---
170170+171171+// converts a google sheets share URL to its CSV export endpoint
172172+export function toCsvUrl(url: string): string {
173173+ const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
174174+ if (!match) throw new HttpError(400, 'invalid_sheet_url', 'Invalid Google Sheets URL.');
175175+ return `https://docs.google.com/spreadsheets/d/${match[1]}/export?format=csv`;
176176+}
177177+178178+function parseCsvLine(line: string): string[] {
179179+ const cols: string[] = [];
180180+ let cur = '';
181181+ let inQuotes = false;
182182+ for (const ch of line) {
183183+ if (ch === '"') {
184184+ inQuotes = !inQuotes;
185185+ }
186186+ else if (ch === ',' && !inQuotes) {
187187+ cols.push(cur.trim());
188188+ cur = '';
189189+ }
190190+ else {
191191+ cur += ch;
192192+ }
193193+ }
194194+ cols.push(cur.trim());
195195+ return cols;
196196+}
197197+198198+// Parses the tournament spreadsheet format:
199199+// cols[1] = song name, cols[4] = spinshare URL, cols[7] = tier.
200200+// Returns tierPools keyed by tier string with sequential index for difficulty ordering.
201201+export function parseSheetCsv(text: string): Record<string, { songId: number | null }[]> {
202202+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
203203+ const pools: Record<string, TierChart[]> = { '1': [], '2': [], '3': [], '4': [] };
204204+205205+ for (let i = 1; i < lines.length; i++) {
206206+ const cols = parseCsvLine(lines[i]);
207207+ const songName = cols[1];
208208+ const spinshareUrl = cols[4];
209209+ const tier = parseInt(cols[7], 10);
210210+211211+ if (!songName || Number.isNaN(tier)) continue;
212212+ if (!pools[String(tier)]) continue;
213213+214214+ pools[String(tier)]!.push({ index: pools[String(tier)]!.length, songId: extractSongId(spinshareUrl) });
215215+ }
216216+217217+ return pools as unknown as Record<string, { songId: number | null }[]>;
218218+}
219219+220220+// Fetches chart metadata from SpinShare in the background (non-blocking),
221221+// mirroring the bot's fire-and-forget fetchAllChartData.
222222+export function backgroundFetchCharts(tierPools: Record<string, { songId: number | null }[]>): void {
223223+ const songIds = [...new Set(Object.values(tierPools).flat().map((c) => c.songId).filter((id): id is number => id != null))];
224224+ let done = 0;
225225+ const next = () => {
226226+ if (done >= songIds.length) return;
227227+ const songId = songIds[done++]!;
228228+ fetchAndCacheChart(songId)
229229+ .catch((err) => logger.warn({ songId, err }, 'background chart fetch failed'))
230230+ .finally(() => setTimeout(next, 150));
231231+ };
232232+ next();
233233+}
234234+235235+export interface GenerateResult {
236236+ bracketCount: number;
237237+ tierCounts: Record<string, number>;
238238+ chartCount: number;
239239+}
240240+241241+// Full generate pipeline: fetch sheet CSV -> start.gg open sets -> generatePools -> save.
242242+export async function generatePoolsFromSheet(
243243+ sheetUrl: string,
244244+ tournamentSlug?: string | null,
245245+): Promise<GenerateResult> {
246246+ const event = getActiveEvent();
247247+ if (!event) throw new HttpError(400, 'no_active_event', 'No active event! Activate an event first.');
248248+ const slug = tournamentSlug ?? event.tournamentSlug;
249249+ if (!slug) throw new HttpError(400, 'no_slug', 'Active event has no tournamentSlug set.');
250250+251251+ const csvUrl = toCsvUrl(sheetUrl);
252252+ let res: Response;
253253+ try {
254254+ res = await fetch(csvUrl);
255255+ }
256256+ catch (err) {
257257+ throw new HttpError(400, 'sheet_fetch_failed', `Failed to fetch sheet: ${(err as Error).message}`);
258258+ }
259259+ if (!res.ok) throw new HttpError(400, 'sheet_fetch_failed', `Failed to fetch sheet: ${res.status} ${res.statusText}`);
260260+ const text = await res.text();
261261+262262+ const tierPools = parseSheetCsv(text);
263263+ const tierCounts: Record<string, number> = {};
264264+ let chartCount = 0;
265265+ for (const [tier, charts] of Object.entries(tierPools)) {
266266+ tierCounts[tier] = charts.length;
267267+ chartCount += charts.length;
268268+ }
269269+270270+ const sets = await fetchOpenSets(slug);
271271+272272+ const indexedTierPools: Record<number, TierChart[]> = {};
273273+ for (const tier of Object.keys(tierPools)) {
274274+ indexedTierPools[Number(tier)] = tierPools[tier] as unknown as TierChart[];
275275+ }
276276+277277+ const brackets = generatePools(indexedTierPools, bracketsConfig, sets as unknown as import('../util/poolGenerator.js').StartggSet[]);
278278+279279+ await setGeneratedPools(brackets, tierPools, String(event._id));
280280+281281+ backgroundFetchCharts(tierPools);
282282+283283+ return { bracketCount: brackets.length, tierCounts, chartCount };
284284+}
+8
src/external/spinshare.ts
···7788export type FetchFn = typeof fetch;
991010+// Extracts the numeric songId from a SpinShare song/share URL.
1111+// Returns null when no id is present.
1212+export function extractSongId(url?: string | null): number | null {
1313+ if (!url) return null;
1414+ const match = String(url).match(/spinsha\.re\/song\/(\d+)/);
1515+ return match ? parseInt(match[1], 10) : null;
1616+}
1717+1018export interface SpinshareChartApiData {
1119 title: string;
1220 artist: string;