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 { NextResponse } from "next/server";
|
||||||
import type { NextRequest } 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 { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
|
||||||
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
|
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
|
||||||
import { securelyQualifiedThirdTeams } from "@/lib/third-place-security";
|
import { securelyQualifiedThirdTeams } from "@/lib/third-place-security";
|
||||||
@@ -27,17 +27,11 @@ export async function GET(request: NextRequest) {
|
|||||||
matches = attachKOOdds(matches, teams, odds);
|
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 {
|
try {
|
||||||
const fifaData = await fetchFifaScores(locale);
|
matches = await attachFifaLiveData(matches, teams, 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);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} 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);
|
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; }
|
interface CacheEntry<T> { value: T; expires: number; refreshing?: boolean; }
|
||||||
const cache = new Map<string, CacheEntry<unknown>>();
|
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> {
|
export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
|
||||||
const hit = cache.get(key) as CacheEntry<T> | undefined;
|
const hit = cache.get(key) as CacheEntry<T> | undefined;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -24,7 +46,10 @@ export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>
|
|||||||
if (!hit.refreshing) {
|
if (!hit.refreshing) {
|
||||||
hit.refreshing = true;
|
hit.refreshing = true;
|
||||||
fn()
|
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); })
|
.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; });
|
.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}`);
|
if (!res.ok) throw new Error(`fifa-calendar ${res.status}`);
|
||||||
const data = (await res.json()) as { Results: FifaCalendarMatch[] };
|
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>();
|
const teamMap = new Map<string, Team>();
|
||||||
|
|
||||||
@@ -543,6 +569,7 @@ export async function fetchMatchesAndTeamsFifa(locale: string = "de"): Promise<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
const teams = [...teamMap.values()];
|
const teams = [...teamMap.values()];
|
||||||
|
console.log("[fifa-fetch] Teams:", teams.length, "Matches:", matches.length);
|
||||||
return { matches, teams };
|
return { matches, teams };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -562,6 +589,7 @@ export async function fetchFifaScores(locale: string = "de"): Promise<{
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`fifa ${res.status}`);
|
if (!res.ok) throw new Error(`fifa ${res.status}`);
|
||||||
const data = (await res.json()) as { Results: FifaMatch[] };
|
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 scores = new Map<number, FifaScores>();
|
||||||
const fifaIdToAppCode = new Map<string, string>();
|
const fifaIdToAppCode = new Map<string, string>();
|
||||||
|
|
||||||
@@ -593,6 +621,7 @@ export async function fetchFifaScores(locale: string = "de"): Promise<{
|
|||||||
idStage: fm.IdStage ?? null,
|
idStage: fm.IdStage ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
console.log("[fifa-fetch] Score-Einträge:", scores.size, "Team-Mappings:", fifaIdToAppCode.size);
|
||||||
return { scores, fifaIdToAppCode };
|
return { scores, fifaIdToAppCode };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -641,6 +670,16 @@ function normMinuteStr(min: string | null | undefined): string {
|
|||||||
return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim();
|
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[]> {
|
async function fetchFifaGoals(idStage: string, idMatch: string, locale: string = "de"): Promise<FifaGoalRaw[]> {
|
||||||
return cached(`fifa:detail:${locale}:${idMatch}`, 60_000, async () => {
|
return cached(`fifa:detail:${locale}:${idMatch}`, 60_000, async () => {
|
||||||
const lang = locale === "en" ? "en" : "de";
|
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(
|
export async function attachFifaGoals(
|
||||||
matches: Match[],
|
matches: Match[],
|
||||||
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
|
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
|
||||||
locale: string = "de",
|
locale: string = "de",
|
||||||
): Promise<Match[]> {
|
): Promise<Match[]> {
|
||||||
const targets = matches.filter(m =>
|
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,
|
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0,
|
||||||
);
|
);
|
||||||
|
if (targets.length === 0) return matches;
|
||||||
|
|
||||||
const goalsByMatchId = new Map<string, GoalEvent[]>();
|
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);
|
const fs = fifaData.scores.get(m.matchNumber);
|
||||||
if (!fs?.idMatch || !fs?.idStage) return;
|
if (!fs?.idMatch || !fs?.idStage) return;
|
||||||
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale);
|
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale);
|
||||||
if (goals.length) goalsByMatchId.set(m.id, goals);
|
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;
|
if (goalsByMatchId.size === 0) return matches;
|
||||||
|
|
||||||
return matches.map(m => {
|
return matches.map(m => {
|
||||||
const g = goalsByMatchId.get(m.id);
|
const g = goalsByMatchId.get(m.id);
|
||||||
return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m;
|
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