138 lines
4.7 KiB
TypeScript
138 lines
4.7 KiB
TypeScript
|
|
import type { MatchRecord, MatchSyncAdapter, MatchStatusValue, SubGameRecord } from "./types";
|
||
|
|
|
||
|
|
// PandaScore API response types
|
||
|
|
interface PandaScoreOpponent {
|
||
|
|
opponent: { id: number; name: string };
|
||
|
|
type: string;
|
||
|
|
}
|
||
|
|
interface PandaScoreResult {
|
||
|
|
team_id: number;
|
||
|
|
score: number;
|
||
|
|
}
|
||
|
|
interface PandaScoreGame {
|
||
|
|
id: number;
|
||
|
|
position: number; // map number (1, 2, 3)
|
||
|
|
map?: { name?: string };
|
||
|
|
winner?: { id: number } | null;
|
||
|
|
winner_type?: string;
|
||
|
|
teams?: Array<{ team: { id: number }; score?: number }>;
|
||
|
|
status: string; // "not_started" | "running" | "finished"
|
||
|
|
begin_at?: string;
|
||
|
|
end_at?: string;
|
||
|
|
}
|
||
|
|
interface PandaScoreTournament {
|
||
|
|
id: number;
|
||
|
|
name: string;
|
||
|
|
}
|
||
|
|
interface PandaScoreMatch {
|
||
|
|
id: number;
|
||
|
|
name: string;
|
||
|
|
status: string; // "not_started" | "running" | "finished" | "canceled" | "postponed"
|
||
|
|
scheduled_at?: string;
|
||
|
|
begin_at?: string;
|
||
|
|
end_at?: string;
|
||
|
|
opponents?: PandaScoreOpponent[];
|
||
|
|
results?: PandaScoreResult[];
|
||
|
|
winner?: { id: number; name: string } | null;
|
||
|
|
winner_id?: number | null;
|
||
|
|
number_of_games?: number;
|
||
|
|
games?: PandaScoreGame[];
|
||
|
|
tournament?: PandaScoreTournament;
|
||
|
|
}
|
||
|
|
|
||
|
|
function mapStatus(s: string): MatchStatusValue {
|
||
|
|
switch (s) {
|
||
|
|
case "running": return "in_progress";
|
||
|
|
case "finished": return "complete";
|
||
|
|
case "canceled": return "canceled";
|
||
|
|
case "postponed": return "postponed";
|
||
|
|
default: return "scheduled";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function tournamentNameToStage(name: string): number | null {
|
||
|
|
const lower = name.toLowerCase();
|
||
|
|
if (lower.includes("opening")) return 1;
|
||
|
|
if (lower.includes("challengers") || lower.includes("elimination")) return 2;
|
||
|
|
if (lower.includes("legends") || lower.includes("decider")) return 3;
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export class PandaScoreMatchSyncAdapter implements MatchSyncAdapter {
|
||
|
|
readonly supportsLiveScores = true;
|
||
|
|
|
||
|
|
async fetchMatches(externalSeasonId: string): Promise<MatchRecord[]> {
|
||
|
|
const apiKey = process.env.PANDASCORE_API_KEY;
|
||
|
|
if (!apiKey) throw new Error("PANDASCORE_API_KEY is not configured");
|
||
|
|
|
||
|
|
const allMatches: PandaScoreMatch[] = [];
|
||
|
|
let page = 1;
|
||
|
|
const perPage = 100;
|
||
|
|
|
||
|
|
// externalSeasonId is a PandaScore serie_id for CS2 Majors
|
||
|
|
while (true) {
|
||
|
|
const url = `https://api.pandascore.co/csgo/matches?filter[serie_id]=${externalSeasonId}&per_page=${perPage}&page=${page}&sort=scheduled_at`;
|
||
|
|
const resp = await fetch(url, {
|
||
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!resp.ok) {
|
||
|
|
const body = await resp.text();
|
||
|
|
throw new Error(`PandaScore API error ${resp.status}: ${body}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const batch = (await resp.json()) as PandaScoreMatch[];
|
||
|
|
if (batch.length === 0) break;
|
||
|
|
allMatches.push(...batch);
|
||
|
|
if (batch.length < perPage) break;
|
||
|
|
page++;
|
||
|
|
}
|
||
|
|
|
||
|
|
return allMatches.map((m) => this.mapMatch(m));
|
||
|
|
}
|
||
|
|
|
||
|
|
private mapMatch(m: PandaScoreMatch): MatchRecord {
|
||
|
|
const team1 = m.opponents?.[0]?.opponent ?? null;
|
||
|
|
const team2 = m.opponents?.[1]?.opponent ?? null;
|
||
|
|
|
||
|
|
const r1 = m.results?.find((r) => r.team_id === team1?.id);
|
||
|
|
const r2 = m.results?.find((r) => r.team_id === team2?.id);
|
||
|
|
|
||
|
|
const matchStage = m.tournament ? tournamentNameToStage(m.tournament.name) : null;
|
||
|
|
|
||
|
|
const subGames: SubGameRecord[] = (m.games ?? []).map((g): SubGameRecord => {
|
||
|
|
const t1score = g.teams?.find((t) => t.team.id === team1?.id)?.score ?? null;
|
||
|
|
const t2score = g.teams?.find((t) => t.team.id === team2?.id)?.score ?? null;
|
||
|
|
const winnerExternalId = g.winner?.id != null ? String(g.winner.id) : null;
|
||
|
|
return {
|
||
|
|
externalGameId: String(g.id),
|
||
|
|
gameNumber: g.position,
|
||
|
|
gameLabel: g.map?.name ?? undefined,
|
||
|
|
team1Score: t1score !== undefined ? t1score : null,
|
||
|
|
team2Score: t2score !== undefined ? t2score : null,
|
||
|
|
winnerExternalId,
|
||
|
|
status: g.status === "running" ? "in_progress" : g.status === "finished" ? "complete" : "scheduled",
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
externalMatchId: String(m.id),
|
||
|
|
team1ExternalId: team1 ? String(team1.id) : "",
|
||
|
|
team1Name: team1?.name ?? "",
|
||
|
|
team2ExternalId: team2 ? String(team2.id) : "",
|
||
|
|
team2Name: team2?.name ?? "",
|
||
|
|
team1Score: r1?.score ?? null,
|
||
|
|
team2Score: r2?.score ?? null,
|
||
|
|
winnerExternalId: m.winner_id != null ? String(m.winner_id) : null,
|
||
|
|
status: mapStatus(m.status),
|
||
|
|
scheduledAt: m.scheduled_at ? new Date(m.scheduled_at) : null,
|
||
|
|
startedAt: m.begin_at ? new Date(m.begin_at) : null,
|
||
|
|
completedAt: m.end_at ? new Date(m.end_at) : null,
|
||
|
|
matchStage,
|
||
|
|
matchRound: null, // rounds within a stage require additional API calls or ordering
|
||
|
|
isSeries: (m.number_of_games ?? 1) > 1,
|
||
|
|
subGames: subGames.length > 0 ? subGames : undefined,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|