diff --git a/app/api/live/route.ts b/app/api/live/route.ts new file mode 100644 index 0000000..011957e --- /dev/null +++ b/app/api/live/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { fetchMatchesAndTeamsFifa, fetchFifaScores, applyFifaScores, attachFifaGoals } from "@/lib/feeds"; +import type { Match, Team } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +const RECENT_MS = 60 * 60 * 1000; +const MATCH_DURATION = 2.5 * 60 * 60 * 1000; + +function isRecentlyFinished(m: Match): boolean { + if (m.status !== "FINISHED") return false; + const kickoff = new Date(m.utcDate).getTime(); + if (isNaN(kickoff)) return false; + return kickoff + MATCH_DURATION > Date.now() - RECENT_MS; +} + +export async function GET(request: NextRequest) { + const locale = request.nextUrl.searchParams.get("locale") || "de"; + try { + const { matches: rawMatches, teams } = await fetchMatchesAndTeamsFifa(locale); + + const relevant = rawMatches.filter(m => + m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED" || isRecentlyFinished(m), + ); + + if (relevant.length === 0) { + return NextResponse.json({ + updatedAt: new Date().toISOString(), + liveCount: 0, + recentCount: 0, + teams: [] as Team[], + matches: [] as Match[], + }); + } + + let fifaData: Awaited>; + try { + fifaData = await fetchFifaScores(locale); + } catch { + return NextResponse.json({ + updatedAt: new Date().toISOString(), + liveCount: relevant.filter(m => m.status !== "FINISHED").length, + recentCount: relevant.filter(m => m.status === "FINISHED").length, + teams, + matches: relevant, + goalsFailed: true, + }); + } + + let matches = applyFifaScores(relevant, teams, fifaData); + let goalsFailed = false; + + try { + matches = await attachFifaGoals(matches, fifaData, locale); + } catch { + goalsFailed = true; + } + + const liveCount = matches.filter(m => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED").length; + const recentCount = matches.filter(m => m.status === "FINISHED" && isRecentlyFinished(m)).length; + + return NextResponse.json({ + updatedAt: new Date().toISOString(), + liveCount, + recentCount, + teams, + matches, + goalsFailed, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "unbekannter Fehler"; + return NextResponse.json( + { error: "Live-Feed nicht erreichbar", detail: message }, + { status: 502 }, + ); + } +} diff --git a/app/api/matches/route.ts b/app/api/matches/route.ts index 4202647..330385d 100644 --- a/app/api/matches/route.ts +++ b/app/api/matches/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { fetchMatchesAndTeamsFifa, fetchOdds, attachOdds, attachKOOdds, fetchFifaScores, applyFifaScores, attachFifaGoals } from "@/lib/feeds"; +import { fetchMatchesAndTeamsFifa, fetchOdds, attachOdds, attachKOOdds, attachFifaLiveData } from "@/lib/feeds"; import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings"; import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket"; import { securelyQualifiedThirdTeams } from "@/lib/third-place-security"; @@ -27,17 +27,11 @@ export async function GET(request: NextRequest) { matches = attachKOOdds(matches, teams, odds); } - // FIFA-Live-Scores als häufiger gecachtes Overlay + // FIFA-Live-Scores als häufiger gecachtes Overlay (Pipeline: Scores → Goals) try { - const fifaData = await fetchFifaScores(locale); - matches = applyFifaScores(matches, teams, fifaData); - try { - matches = await attachFifaGoals(matches, fifaData, locale); - } catch (err) { - console.warn("[fifa] Goals fehlgeschlagen:", err instanceof Error ? err.message : err); - } + matches = await attachFifaLiveData(matches, teams, locale); } catch (err) { - console.warn("[fifa] Scores fehlgeschlagen:", err instanceof Error ? err.message : err); + console.warn("[fifa] Live-Overlay fehlgeschlagen:", err instanceof Error ? err.message : err); } const groupTables = computeGroupTables(teams, matches); diff --git a/lib/feeds.ts b/lib/feeds.ts index 9a29371..abf9e81 100644 --- a/lib/feeds.ts +++ b/lib/feeds.ts @@ -12,6 +12,28 @@ import { FIFA_GROUP_MAP, FIFA_STAGE_MAP } from "./fifa-constants"; interface CacheEntry { value: T; expires: number; refreshing?: boolean; } const cache = new Map>(); +// Subscription-System: Benachrichtigt Listener (z.B. API-Routen) bei +// erfolgreichem Hintergrund-Refresh, sodass das Frontend nach dem +// nächsten Poll die aktuellen Daten erhält. +type CacheListener = (value: unknown) => void; +const cacheSubscriptions = new Map>(); + +function notifyCacheListeners(key: string, value: unknown): void { + const subs = cacheSubscriptions.get(key); + if (!subs) return; + for (const cb of subs) { + try { cb(value); } catch { /* silent */ } + } +} + +export function onCacheRefresh(key: string, cb: (value: T) => void): () => void { + if (!cacheSubscriptions.has(key)) cacheSubscriptions.set(key, new Set()); + const set = cacheSubscriptions.get(key)!; + const wrapped = cb as CacheListener; + set.add(wrapped); + return () => { set.delete(wrapped); if (set.size === 0) cacheSubscriptions.delete(key); }; +} + export async function cached(key: string, ttlMs: number, fn: () => Promise): Promise { const hit = cache.get(key) as CacheEntry | undefined; const now = Date.now(); @@ -24,7 +46,10 @@ export async function cached(key: string, ttlMs: number, fn: () => Promise if (!hit.refreshing) { hit.refreshing = true; fn() - .then((value) => { cache.set(key, { value, expires: Date.now() + ttlMs }); }) + .then((value) => { + cache.set(key, { value, expires: Date.now() + ttlMs }); + notifyCacheListeners(key, value); + }) .catch((err) => { console.error(`[cache] Hintergrund-Refresh fehlgeschlagen (${key}):`, err); }) .finally(() => { const e = cache.get(key) as CacheEntry | undefined; if (e) e.refreshing = false; }); } @@ -475,6 +500,7 @@ export async function fetchMatchesAndTeamsFifa(locale: string = "de"): Promise<{ }); if (!res.ok) throw new Error(`fifa-calendar ${res.status}`); const data = (await res.json()) as { Results: FifaCalendarMatch[] }; + console.log("[fifa-fetch] Kalender geladen, Result-Array Länge:", (data.Results ?? []).length); const teamMap = new Map(); @@ -543,6 +569,7 @@ export async function fetchMatchesAndTeamsFifa(locale: string = "de"): Promise<{ }); const teams = [...teamMap.values()]; + console.log("[fifa-fetch] Teams:", teams.length, "Matches:", matches.length); return { matches, teams }; }); } @@ -562,6 +589,7 @@ export async function fetchFifaScores(locale: string = "de"): Promise<{ }); if (!res.ok) throw new Error(`fifa ${res.status}`); const data = (await res.json()) as { Results: FifaMatch[] }; + console.log("[fifa-fetch] Scores geladen, Result-Array Länge:", (data.Results ?? []).length); const scores = new Map(); const fifaIdToAppCode = new Map(); @@ -593,6 +621,7 @@ export async function fetchFifaScores(locale: string = "de"): Promise<{ idStage: fm.IdStage ?? null, }); } + console.log("[fifa-fetch] Score-Einträge:", scores.size, "Team-Mappings:", fifaIdToAppCode.size); return { scores, fifaIdToAppCode }; }); } @@ -641,6 +670,16 @@ function normMinuteStr(min: string | null | undefined): string { return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim(); } +const RECENT_FINISHED_MS = 60 * 60 * 1000; // 60 Minuten +const MATCH_DURATION_ESTIMATE = 2.5 * 60 * 60 * 1000; // 2.5h Puffer für Spiel + Pause + Nachspielzeit + +function isRecentlyFinished(m: Match): boolean { + if (m.status !== "FINISHED") return false; + const kickoff = new Date(m.utcDate).getTime(); + if (isNaN(kickoff)) return false; + return kickoff + MATCH_DURATION_ESTIMATE > Date.now() - RECENT_FINISHED_MS; +} + async function fetchFifaGoals(idStage: string, idMatch: string, locale: string = "de"): Promise { return cached(`fifa:detail:${locale}:${idMatch}`, 60_000, async () => { const lang = locale === "en" ? "en" : "de"; @@ -685,26 +724,107 @@ async function fetchFifaGoals(idStage: string, idMatch: string, locale: string = }); } -// Lädt Tor-Details für beendete/laufende Spiele mit Toren und hängt sie an die Matches an. +// Lädt Tor-Details für laufende oder kürzlich beendete Spiele mit Toren. +// FINISHED-Matches werden nur innerhalb eines 60-Minuten-Fensters berücksichtigt, +// um die Request-Zahl bei 79+ Spielen im Turnierverlauf drastisch zu reduzieren. export async function attachFifaGoals( matches: Match[], fifaData: { scores: Map; fifaIdToAppCode: Map }, locale: string = "de", ): Promise { const targets = matches.filter(m => - (m.status === "FINISHED" || m.status === "LIVE" || m.status === "IN_PLAY") && + ((m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED") || isRecentlyFinished(m)) && ((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0, ); + if (targets.length === 0) return matches; + const goalsByMatchId = new Map(); - await Promise.all(targets.map(async (m) => { + const results = await Promise.allSettled(targets.map(async (m) => { const fs = fifaData.scores.get(m.matchNumber); if (!fs?.idMatch || !fs?.idStage) return; const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale); if (goals.length) goalsByMatchId.set(m.id, goals); })); + + const fetched = results.filter(r => r.status === "fulfilled").length; + const failed = results.filter(r => r.status === "rejected").length; + if (goalsByMatchId.size > 0 || failed > 0) { + console.log("[fifa-fetch] Goals: geladen für", goalsByMatchId.size, "Matches,", fetched, "OK,", failed, "Fehler"); + } if (goalsByMatchId.size === 0) return matches; + return matches.map(m => { const g = goalsByMatchId.get(m.id); return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m; }); +} + +// ---------------------------------------------------------------------------- +// FIFA Live-Daten Pipeline: kombiniert Scores + Goals in gestufter Abfolge. +// Step 1: Scores von der FIFA-API holen (enthält idMatch/idStage-Mappings). +// Step 2: Scores auf die Matches anwenden (MatchNumber als Schlüssel). +// Step 3: Goal-Details NUR für live/beendete Matches mit Toren nachladen. +// +// Die Schritte werden sequentiell ausgeführt (kein Promise.all wie zuvor), +// damit fehlende ID-Mappings nicht zu sinnlosen Requests führen. +// Jeder Schritt loggt die Array-/Map-Länge, sodass auf der Vercel-Konsole +// sofort ersichtlich ist, ob der Upstream leer ist oder der Fehler im Mapping liegt. +// ---------------------------------------------------------------------------- +export async function attachFifaLiveData( + matches: Match[], + teams: Team[], + locale: string = "de", +): Promise { + // --- Step 1: Scores von FIFA holen --- + let fifaData: Awaited>; + try { + fifaData = await fetchFifaScores(locale); + } catch (err) { + console.warn("[fifa-fetch] Scores fehlgeschlagen:", err instanceof Error ? err.message : err); + return matches; + } + + // --- Step 2: Scores auf Matches anwenden --- + const updated = applyFifaScores(matches, teams, fifaData); + + // --- Step 3: Goal-Details nur für live + kürzlich beendete Matches mit Toren --- + const targets = updated.filter(m => { + const fs = fifaData.scores.get(m.matchNumber); + return ( + ((m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED") || isRecentlyFinished(m)) && + ((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0 && + fs?.idMatch != null && + fs?.idStage != null + ); + }); + + if (targets.length === 0) { + console.log("[fifa-fetch] Pipeline: Scores aktualisiert, keine Goal-Requests nötig"); + return updated; + } + + const goalsByMatchId = new Map(); + let fetched = 0; + let failed = 0; + + for (const m of targets) { + const fs = fifaData.scores.get(m.matchNumber)!; + if (!fs.idMatch || !fs.idStage) continue; + try { + const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale); + fetched++; + if (goals.length) goalsByMatchId.set(m.id, goals); + } catch { + failed++; + } + } + + console.log("[fifa-fetch] Pipeline: Goals für", goalsByMatchId.size, "Matches geladen (", fetched, "OK,", failed, "Fehler )"); + + if (goalsByMatchId.size === 0) return updated; + + return updated.map(m => { + const g = goalsByMatchId.get(m.id); + return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m; + }); } \ No newline at end of file