Files
tsv08kulmbach-website/bin/matchcenter-sync.php
fs a101b0c3ec Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.

Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.

News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.

Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
  Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
  (Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
  Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
  unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
  umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.

Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00

524 lines
23 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Matchcenter-Sync (BFV-Widget-Ersatz): holt Spielplan, Ergebnisse und Tabelle der
* drei Aktivenmannschaften aus der öffentlichen BFV-Widget-JSON-API, lädt die
* Vereinswappen lokal nach public/assets/img/crests/ und schreibt data/matchcenter.json
* atomar. Bei JEDEM Fehler bleibt der letzte gute Cache unangetastet — die Website
* degradiert nur, sie bricht nie. Reverse-engineert aus dem BFV-Widget (das alte
* <BFVWidget.HTML5…>-Embed lud genau diese Endpunkte client-seitig nach).
*
* Aufruf: php bin/matchcenter-sync.php [-v] [--dump]
* Cron: 37 7,19 * * * cd /pfad/zur/site && /usr/bin/php bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1
* (zusätzlich am Wochenende häufiger, siehe config.example.php)
*
* Datenquelle (Host https://widget-prod.bfv.de, liefert sauberes JSON, kein HTML-Scraping):
* - Spiele/Ergebnisse: /api/service/widget/v1/team/{teamPermanentId}/matches
* - Liga-Tabelle: /api/service/widget/v1/competition/{compoundId}/table
* - Vereinswappen: https://app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId} (PNG)
* Ohne Browser-typischen User-Agent antwortet der Host mit HTTP 418 — der $fetch-Helfer
* setzt ihn (wie bin/instagram-sync.php).
*/
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
exit(1);
}
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
// definiert Konstanten und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal.
require_once dirname(__DIR__) . '/app/bootstrap.php';
$verbose = in_array('-v', $argv ?? [], true);
$dump = in_array('--dump', $argv ?? [], true);
$apiHost = 'https://widget-prod.bfv.de';
$crestBase = 'https://app.bfv.de/export.media/-/action/getLogo/format/0/id/';
$referer = (string) config('matchcenter.referer', 'https://widget-prod.bfv.de/');
$maxUpcoming = (int) config('matchcenter.max_upcoming', 5);
$maxResults = (int) config('matchcenter.max_results', 5);
$teamsCfg = (array) config('matchcenter.teams', []);
$cacheFile = DATA_PATH . '/matchcenter.json';
$crestDir = PUBLIC_PATH . '/assets/img/crests';
// Anzeigename/Liga/Slug kommen aus der Single Source of Truth (data/teams.json),
// nicht aus der API — der Scraper erfindet keine kuratierten Namen.
$teamsData = json_load('teams');
$slugMap = [
'erste-mannschaft' => 'fussball/erste-mannschaft',
'zweite-mannschaft' => 'fussball/zweite-mannschaft',
'damen' => 'fussball/damen',
];
$tz = new DateTimeZone('Europe/Berlin');
$today = (new DateTimeImmutable('now', $tz))->setTime(0, 0);
$weekdaysDe = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
// Wettbewerbsart → kurzes, sprechendes Badge-Label.
$competitionLabels = [
'Meisterschaften' => 'Liga',
'Freundschaftsspiele' => 'Freundschaft',
'Pokalspiele' => 'Pokal',
'Pokal' => 'Pokal',
'Hallenrunde' => 'Halle',
];
// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/matchcenter.log;
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('matchcenter', $level, $msg);
if ($verbose) {
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
exit(1);
};
if ($teamsCfg === []) {
$fail('Keine Teams in config.matchcenter.teams konfiguriert');
}
// --- Lock gegen parallele Läufe ---
$lock = fopen(STORAGE_PATH . '/cache/matchcenter.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
// Kein ERROR: ein überlappender Cron-Lauf ist kein Ausfall, der nächste Takt holt es nach.
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
// --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) ---
$fetch = static function (string $url, bool $json = true) use ($referer): string|false {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 20,
CURLOPT_ENCODING => '',
CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
CURLOPT_HTTPHEADER => [
'Accept: ' . ($json ? 'application/json, text/plain, */*' : '*/*'),
'Accept-Language: de-DE,de;q=0.9',
'Referer: ' . $referer,
'Sec-Fetch-Dest: ' . ($json ? 'empty' : 'image'),
'Sec-Fetch-Mode: ' . ($json ? 'cors' : 'no-cors'),
'Sec-Fetch-Site: cross-site',
],
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
return ($body !== false && $status === 200) ? $body : false;
};
/** JSON-Endpoint holen und data-Knoten zurückgeben (oder null). */
$fetchJson = static function (string $url) use ($fetch, $dump, $log): ?array {
$body = $fetch($url, true);
if ($body === false) {
return null;
}
if ($dump) {
echo "DUMP {$url}\n" . substr($body, 0, 4000) . "\n";
}
$data = json_decode($body, true);
return is_array($data) ? $data : null;
};
// --- Wappen-Download: idempotent, normalisiert auf max 96px PNG (Transparenz erhalten) ---
if (!is_dir($crestDir)) {
mkdir($crestDir, 0755, true);
}
$crestKeep = [];
$downloadCrest = static function (?string $clubId) use ($fetch, $crestBase, $crestDir, &$crestKeep, $log): ?string {
$clubId = trim((string) $clubId);
if ($clubId === '' || !preg_match('/^[A-Z0-9-]+$/i', $clubId)) {
return null;
}
$rel = "img/crests/{$clubId}.png";
$file = "{$crestDir}/{$clubId}.png";
$crestKeep["{$clubId}.png"] = true;
if (is_file($file)) {
return $rel;
}
$bin = $fetch($crestBase . $clubId, false);
if ($bin === false) {
$log("Wappen-Download fehlgeschlagen: {$clubId}", 'WARN');
return null;
}
$src = @imagecreatefromstring($bin);
if ($src === false) {
$log("Wappen unlesbar: {$clubId}", 'WARN');
return null;
}
$w = imagesx($src);
$h = imagesy($src);
$scale = min(96 / $w, 96 / $h, 1.0);
$nw = max(1, (int) round($w * $scale));
$nh = max(1, (int) round($h * $scale));
$out = imagecreatetruecolor($nw, $nh);
imagealphablending($out, false);
imagesavealpha($out, true);
imagefilledrectangle($out, 0, 0, $nw, $nh, imagecolorallocatealpha($out, 0, 0, 0, 127));
imagecopyresampled($out, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagepng($out, $file);
return $rel;
};
// --- Datums-/Zeit-Helfer (deutsche Quellformate, Europe/Berlin) ---
$parseKickoff = static function (string $date, string $time) use ($tz): ?DateTimeImmutable {
$date = trim($date);
$time = trim($time) !== '' ? trim($time) : '00:00';
if ($date === '') {
return null;
}
$dt = DateTimeImmutable::createFromFormat('d.m.Y H:i', "{$date} {$time}", $tz);
return $dt ?: null;
};
$dateDisplay = static function (?DateTimeImmutable $dt, string $raw) use ($weekdaysDe): string {
if ($dt === null) {
return $raw; // Roh-String behalten — Anzeige bricht nie weg.
}
return $weekdaysDe[(int) $dt->format('w')] . ', ' . $dt->format('d.m.');
};
// --- Saison-Label (Fußball-Saison läuft JulJun): aus dem jüngsten Spiel ableiten,
// ersatzweise (leere Saison) aus dem heutigen Datum → liefert immer ein Label. ---
$seasonLabel = static function (DateTimeImmutable $d): string {
$y = (int) $d->format('Y');
$start = (int) $d->format('n') >= 7 ? $y : $y - 1;
return sprintf('Saison %d/%02d', $start, ($start + 1) % 100);
};
$seasonFromRows = static function (array ...$rowSets) use ($seasonLabel, $tz, $today): string {
$latest = null;
foreach ($rowSets as $rows) {
foreach ($rows as $r) {
if (empty($r['kickoff'])) {
continue;
}
$dt = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', (string) $r['kickoff'], $tz);
if ($dt && ($latest === null || $dt > $latest)) {
$latest = $dt;
}
}
}
return $seasonLabel($latest ?? $today);
};
/**
* Ein Match aus der API in unser Anzeige-Format überführen.
* @param array<string,mixed> $m Rohes Match aus data.matches[]
* @param string $teamCfgId permanentId der eigenen Mannschaft (für is_home/outcome)
*/
$mapMatch = static function (array $m, string $teamCfgId) use ($parseKickoff, $dateDisplay, $downloadCrest, $competitionLabels, $tz): array {
$rawDate = (string) ($m['kickoffDate'] ?? '');
$rawTime = (string) ($m['kickoffTime'] ?? '');
$dt = $parseKickoff($rawDate, $rawTime);
$isHome = ($m['homeTeamPermanentId'] ?? null) === $teamCfgId;
$type = (string) ($m['competitionType'] ?? '');
$row = [
'kickoff' => $dt?->format('Y-m-d\TH:i'),
'date_display' => $dateDisplay($dt, $rawDate),
'time_display' => $rawTime !== '' ? $rawTime . ' Uhr' : '',
'competition' => $competitionLabels[$type] ?? ($type !== '' ? $type : 'Spiel'),
'home' => (string) ($m['homeTeamName'] ?? ''),
'away' => (string) ($m['guestTeamName'] ?? ''),
'home_crest' => $downloadCrest($m['homeClubId'] ?? null),
'away_crest' => $downloadCrest($m['guestClubId'] ?? null),
'is_home' => $isHome,
];
$result = trim((string) ($m['result'] ?? ''));
if ($result !== '' && preg_match('/^(\d+)\s*:\s*(\d+)/', $result, $mm)) {
$gh = (int) $mm[1];
$ga = (int) $mm[2];
$own = $isHome ? $gh : $ga;
$opp = $isHome ? $ga : $gh;
$row['goals_home'] = $gh;
$row['goals_away'] = $ga;
$row['result'] = $result;
$row['outcome'] = $own > $opp ? 'win' : ($own === $opp ? 'draw' : 'loss');
}
return $row;
};
// --- Vorheriger Cache: bei Teil-Ausfällen (BFV liefert 418/Fehler) behalten wir
// die alten Daten dieses Teams bzw. Teils, statt die Sektion zu leeren. ---
$prev = [];
if (is_file($cacheFile)) {
$decoded = json_decode((string) file_get_contents($cacheFile), true);
if (is_array($decoded)) {
$prev = $decoded;
}
}
$prevByKey = [];
foreach ($prev['teams'] ?? [] as $pt) {
if (!empty($pt['key'])) {
$prevByKey[$pt['key']] = $pt;
}
}
$cacheFallbackUsed = false;
// Wappen-Dateinamen aus (ggf. aus dem Cache übernommenen) Zeilen in die Keep-Liste
// aufnehmen, damit die GC unten sie nicht löscht.
$keepCrests = static function (array $rows) use (&$crestKeep): void {
foreach ($rows as $r) {
foreach (['home_crest', 'away_crest', 'crest'] as $k) {
if (!empty($r[$k]) && is_string($r[$k])) {
$crestKeep[basename($r[$k])] = true;
}
}
}
};
// --- Pro Team: Spiele + Tabelle holen, parsen, Wappen laden (isoliert) ---
$resultTeams = [];
$upcomingAll = [];
foreach ($teamsCfg as $cfg) {
$key = (string) ($cfg['key'] ?? '');
$teamCfgId = (string) ($cfg['team_id'] ?? '');
$cfgCompoundId = (string) ($cfg['compound_id'] ?? '');
if ($key === '' || $teamCfgId === '') {
$log("Team-Eintrag ohne key/team_id übersprungen", 'WARN');
continue;
}
$prevTeam = $prevByKey[$key] ?? null;
try {
$prevCompound = (string) ($prevTeam['compound_id'] ?? '');
$previous = $prevTeam['previous'] ?? null;
// Spiele zuerst — daraus leiten wir die aktuelle Wettbewerbs-ID (Tabelle) ab,
// damit eine neue Saison automatisch übernommen wird (die team_id ist stabil,
// die compound_id der Tabelle wechselt pro Saison).
$matchesData = $fetchJson("{$apiHost}/api/service/widget/v1/team/{$teamCfgId}/matches");
$apiCompound = (string) ($matchesData['data']['team']['compoundId'] ?? '');
// Saisonwechsel: liefert die API eine NEUE compoundId, archivieren wir die eben
// abgelaufene Saison einmalig (verschachteltes previous des Vorgängers wird nicht
// mitkopiert → keine unbegrenzte Schachtelung).
if ($apiCompound !== '' && $prevCompound !== '' && $apiCompound !== $prevCompound && $prevTeam !== null) {
$previous = [
'season' => $prevTeam['season'] ?? null,
'league' => $prevTeam['league'] ?? null,
'last_results' => $prevTeam['last_results'] ?? [],
'table' => $prevTeam['table'] ?? [],
];
}
// Beste bekannte compoundId: API > letzter Cache > Config-Fallback. So triggert ein
// vorübergehender Fetch-Ausfall (apiCompound leer) keinen falschen Saisonwechsel.
$compoundId = $apiCompound !== ''
? $apiCompound
: ($prevCompound !== '' ? $prevCompound : $cfgCompoundId);
$tableData = $compoundId !== '' ? $fetchJson("{$apiHost}/api/service/widget/v1/competition/{$compoundId}/table") : null;
// Name/Liga bevorzugt aus teams.json (Single Source), sonst API/alter Cache.
$apiCompetition = (string) ($matchesData['data']['team']['competitionName'] ?? '');
$name = $teamsData[$key]['name'] ?? ($matchesData['data']['team']['name'] ?? ($prevTeam['name'] ?? $key));
$league = $teamsData[$key]['league'] ?? null;
if (!$league) {
$league = $apiCompetition !== '' ? $apiCompetition : ($prevTeam['league'] ?? null);
}
// --- Spiele-Teil ---
if ($matchesData !== null) {
$matches = $matchesData['data']['matches'] ?? [];
$upcoming = [];
$results = [];
foreach ($matches as $m) {
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
// "SPIELFREI" — kein echtes Spiel, gehört weder in "Nächste
// Spiele" noch in Ergebnisse (tauchte sonst bis in den
// Startseiten-Hero und die Stadionzeitung durch).
if (($m['homeTeamName'] ?? '') === 'SPIELFREI' || ($m['guestTeamName'] ?? '') === 'SPIELFREI') {
continue;
}
$hasResult = trim((string) ($m['result'] ?? '')) !== '';
$row = $mapMatch($m, $teamCfgId);
if ($hasResult) {
$results[] = $row;
} else {
// Ohne Ergebnis nur zeigen, wenn Anstoß heute/künftig.
$dt = $row['kickoff'] !== null ? new DateTimeImmutable($row['kickoff'], $tz) : null;
if ($dt === null || $dt->setTime(0, 0) >= $today) {
$upcoming[] = $row;
}
}
}
usort($upcoming, static fn ($a, $b) => ($a['kickoff'] ?? '') <=> ($b['kickoff'] ?? ''));
usort($results, static fn ($a, $b) => ($b['kickoff'] ?? '') <=> ($a['kickoff'] ?? ''));
$upcoming = array_slice($upcoming, 0, $maxUpcoming);
$results = array_slice($results, 0, $maxResults);
// permanentId → clubId-Karte (für Wappen in der Tabelle).
$clubIdByPermanent = [];
foreach ($matches as $m) {
if (!empty($m['homeTeamPermanentId']) && !empty($m['homeClubId'])) {
$clubIdByPermanent[$m['homeTeamPermanentId']] = $m['homeClubId'];
}
if (!empty($m['guestTeamPermanentId']) && !empty($m['guestClubId'])) {
$clubIdByPermanent[$m['guestTeamPermanentId']] = $m['guestClubId'];
}
}
} else {
// Spiele-Fetch fehlgeschlagen → alten Cache dieses Teams behalten.
$upcoming = $prevTeam['next_matches'] ?? [];
$results = $prevTeam['last_results'] ?? [];
$clubIdByPermanent = [];
$cacheFallbackUsed = true;
$keepCrests($upcoming);
$keepCrests($results);
$log("Team {$key}: Spiele-Fetch fehlgeschlagen — alter Cache übernommen", 'WARN');
}
// --- Tabellen-Teil ---
if ($tableData !== null) {
$tableRows = $tableData['data']['table'] ?? [];
$cfgZone = $tableData['data']['configuration'] ?? [];
$rowCount = count($tableRows);
$promo = (int) ($cfgZone['promotionTeamCount'] ?? 0);
$promoPlayoff = (int) ($cfgZone['promotionPlayoffTeamCount'] ?? 0);
$releg = (int) ($cfgZone['relegationTeamCount'] ?? 0);
$relegPlayoff = (int) ($cfgZone['relegationPlayoffTeamCount'] ?? 0);
$table = [];
foreach ($tableRows as $r) {
$pos = (int) ($r['position'] ?? 0);
$permanentId = (string) ($r['team']['permanentId'] ?? '');
$zone = null;
if ($pos >= 1 && $pos <= $promo) {
$zone = 'promotion';
} elseif ($pos <= $promo + $promoPlayoff) {
$zone = 'promotion-playoff';
} elseif ($releg > 0 && $pos > $rowCount - $releg) {
$zone = 'relegation';
} elseif ($relegPlayoff > 0 && $pos > $rowCount - $releg - $relegPlayoff) {
$zone = 'relegation-playoff';
}
$table[] = [
'rank' => $pos,
'club' => (string) ($r['team']['name'] ?? ''),
'crest' => $downloadCrest($clubIdByPermanent[$permanentId] ?? null),
'played' => (int) ($r['matches'] ?? 0),
'won' => (int) ($r['matchesWon'] ?? 0),
'drawn' => (int) ($r['matchesDrawn'] ?? 0),
'lost' => (int) ($r['matchesLost'] ?? 0),
'goal_diff' => (int) ($r['goalsDiff'] ?? 0),
'points' => (int) ($r['points'] ?? 0),
'zone' => $zone,
'is_own_club' => $permanentId === $teamCfgId,
];
}
} else {
// Tabellen-Fetch fehlgeschlagen → alte Tabelle dieses Teams behalten.
$table = $prevTeam['table'] ?? [];
$cacheFallbackUsed = true;
$keepCrests($table);
$log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen", 'WARN');
}
// Aktuelle Saison immer zeigen, solange der Spiele-Fetch erfolgreich war (auch wenn
// sie noch leer ist) oder Daten/eine Vorsaison vorliegen. Nur überspringen, wenn der
// Fetch fehlschlug UND weder Cache noch Vorsaison existieren.
$hasCurrent = $upcoming !== [] || $results !== [] || $table !== [];
$hasPrevious = $previous !== null
&& (($previous['last_results'] ?? []) !== [] || ($previous['table'] ?? []) !== []);
if (!$hasCurrent && $matchesData === null && !$hasPrevious) {
$log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen", 'WARN');
continue;
}
$season = $seasonFromRows($upcoming, $results);
$teamBlock = [
'key' => $key,
'name' => $name,
'league' => $league,
'slug' => $slugMap[$key] ?? '',
'compound_id' => $compoundId,
'season' => $season,
'next_matches' => $upcoming,
'last_results' => $results,
'table' => $table,
];
if ($hasPrevious) {
$teamBlock['previous'] = $previous;
$keepCrests($previous['last_results'] ?? []);
$keepCrests($previous['table'] ?? []);
}
$resultTeams[] = $teamBlock;
foreach ($upcoming as $u) {
$upcomingAll[] = array_merge($u, ['team_key' => $key, 'team_name' => $name]);
}
$log("Team {$key}: " . count($upcoming) . ' anstehend, ' . count($results) . ' Ergebnisse, ' . count($table) . ' Tabellenplätze'
. ($hasPrevious ? ' (+ Vorsaison archiviert)' : ''));
} catch (\Throwable $e) {
// Ein fehlschlagendes Team darf die anderen nicht killen — alten Block behalten.
$log("Team {$key} übersprungen: " . $e->getMessage(), 'WARN');
if ($prevTeam !== null) {
$resultTeams[] = $prevTeam;
$cacheFallbackUsed = true;
$keepCrests($prevTeam['next_matches'] ?? []);
$keepCrests($prevTeam['last_results'] ?? []);
$keepCrests($prevTeam['table'] ?? []);
$keepCrests($prevTeam['previous']['last_results'] ?? []);
$keepCrests($prevTeam['previous']['table'] ?? []);
foreach ($prevTeam['next_matches'] ?? [] as $u) {
$upcomingAll[] = array_merge($u, ['team_key' => $key, 'team_name' => $prevTeam['name'] ?? $key]);
}
}
}
}
if ($resultTeams === []) {
$fail('Kein einziges Team lieferte Daten (API blockt evtl. die Server-IP oder Struktur geändert)');
}
// Globaler „nächste Spiele"-Slider: teamübergreifend, nach Anstoß sortiert.
usort($upcomingAll, static fn ($a, $b) => ($a['kickoff'] ?? '') <=> ($b['kickoff'] ?? ''));
$upcomingAll = array_slice($upcomingAll, 0, $maxUpcoming);
// --- Nicht mehr referenzierte Wappen aufräumen. Nur, wenn ALLE Teams frisch
// geladen wurden (sonst könnte ein transienter Ausfall ein noch genutztes
// Wappen löschen) und die Keep-Liste nicht leer ist. ---
if (!$cacheFallbackUsed && $crestKeep !== []) {
foreach (glob("{$crestDir}/*.png") ?: [] as $existing) {
if (!isset($crestKeep[basename($existing)])) {
unlink($existing);
}
}
}
// --- Cache atomar schreiben (tmp + rename). Jeder Schritt ist abgesichert: ein
// defektes UTF-8-Zeichen aus der API (json_encode → Exception) oder ein
// Schreib-/Rename-Fehler darf den bestehenden guten Cache NICHT leerschreiben. ---
try {
$payload = json_encode([
'fetched_at' => time(),
'stale' => $cacheFallbackUsed, // true = mind. ein Teil kam aus dem alten Cache
'teams' => $resultTeams,
'upcoming' => $upcomingAll,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
$fail('Cache-Encode fehlgeschlagen: ' . $e->getMessage());
}
$tmp = $cacheFile . '.tmp';
if (file_put_contents($tmp, $payload) === false) {
$fail("Cache-Schreiben fehlgeschlagen (tmp: {$tmp})");
}
if (!rename($tmp, $cacheFile)) {
@unlink($tmp);
$fail("Cache-Rename fehlgeschlagen ({$tmp}{$cacheFile})");
}
$log('OK: ' . count($resultTeams) . ' Teams gecached, ' . count($crestKeep) . ' Wappen lokal in assets/img/crests/');