reduce traffic
This commit is contained in:
78
app/api/live/route.ts
Normal file
78
app/api/live/route.ts
Normal file
@@ -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<ReturnType<typeof fetchFifaScores>>;
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
128
lib/feeds.ts
128
lib/feeds.ts
@@ -12,6 +12,28 @@ import { FIFA_GROUP_MAP, FIFA_STAGE_MAP } from "./fifa-constants";
|
||||
interface CacheEntry<T> { value: T; expires: number; refreshing?: boolean; }
|
||||
const cache = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
// 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<string, Set<CacheListener>>();
|
||||
|
||||
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<T>(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<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
|
||||
const hit = cache.get(key) as CacheEntry<T> | undefined;
|
||||
const now = Date.now();
|
||||
@@ -24,7 +46,10 @@ export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>
|
||||
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<T> | 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<string, Team>();
|
||||
|
||||
@@ -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<number, FifaScores>();
|
||||
const fifaIdToAppCode = new Map<string, string>();
|
||||
|
||||
@@ -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<FifaGoalRaw[]> {
|
||||
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<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
|
||||
locale: string = "de",
|
||||
): Promise<Match[]> {
|
||||
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<string, GoalEvent[]>();
|
||||
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<Match[]> {
|
||||
// --- Step 1: Scores von FIFA holen ---
|
||||
let fifaData: Awaited<ReturnType<typeof fetchFifaScores>>;
|
||||
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<string, GoalEvent[]>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user