import { promises as fs } from 'fs' import path from 'path' import { getCurrentSeasonSlug, readSpielplanData } from './spielplan-data.js' import { getServerDataPath } from './paths.js' import { sendLiveTickerFinishedPush, sendLiveTickerScoreChangedPush, sendLiveTickerStartedPush } from './push-notifications.js' import { error as loggerError } from './logger.js' const TICKER_WINDOW_BEFORE_MS = 30 * 60 * 1000 const TICKER_WINDOW_AFTER_MS = 6 * 60 * 60 * 1000 const FINISHED_VISIBLE_MS = 60 * 60 * 1000 const LIVE_START_STATE_FILE = getServerDataPath('live-ticker-notifications.json') const LIVE_TICKER_STATE_FILE = getServerDataPath('live-ticker-observed-state.json') const LIVE_START_STATE_RETENTION_MS = 2 * 24 * 60 * 60 * 1000 const cache = new Map() let liveStartNotifications = null let observedLiveTickerStates = null let refreshInFlight = null function matchTime(row) { const timestamp = Number(row.Timestamp) return Number.isFinite(timestamp) ? timestamp * 1000 : NaN } function score(data) { const home = data.is_completed ? data.matches_home : data.match?.reduce((sum, item) => sum + (Number(item.matches_home) || 0), 0) const away = data.is_completed ? data.matches_guest : data.match?.reduce((sum, item) => sum + (Number(item.matches_guest) || 0), 0) return `${home ?? 0}:${away ?? 0}` } function completedTime(data, fallback) { const timestamp = Date.parse(data.end_date || data.entry_date || '') return Number.isFinite(timestamp) ? timestamp : fallback } async function readLiveStartNotifications() { if (liveStartNotifications) return liveStartNotifications try { const parsed = JSON.parse(await fs.readFile(LIVE_START_STATE_FILE, 'utf8')) liveStartNotifications = parsed && typeof parsed === 'object' ? parsed : {} } catch (error) { if (error?.code !== 'ENOENT') loggerError('[live-ticker] Push-Status konnte nicht gelesen werden', { error }) liveStartNotifications = {} } return liveStartNotifications } async function saveLiveStartNotifications(nowMs) { const state = await readLiveStartNotifications() for (const [meetingId, notifiedAt] of Object.entries(state)) { if (nowMs - Date.parse(notifiedAt) > LIVE_START_STATE_RETENTION_MS) delete state[meetingId] } await fs.mkdir(path.dirname(LIVE_START_STATE_FILE), { recursive: true }) await fs.writeFile(LIVE_START_STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8') } async function notifyLiveStart(entry, nowMs) { const state = await readLiveStartNotifications() if (state[entry.meetingId]) return state[entry.meetingId] = new Date(nowMs).toISOString() try { await saveLiveStartNotifications(nowMs) await sendLiveTickerStartedPush(entry) } catch (error) { loggerError('[live-ticker] Start-Push konnte nicht gesendet werden', { meetingId: entry.meetingId, error }) } } async function readObservedLiveTickerStates() { if (observedLiveTickerStates) return observedLiveTickerStates try { const parsed = JSON.parse(await fs.readFile(LIVE_TICKER_STATE_FILE, 'utf8')) observedLiveTickerStates = parsed && typeof parsed === 'object' ? parsed : {} } catch (error) { if (error?.code !== 'ENOENT') loggerError('[live-ticker] Beobachtete Stände konnten nicht gelesen werden', { error }) observedLiveTickerStates = {} } return observedLiveTickerStates } async function saveObservedLiveTickerStates(nowMs) { const states = await readObservedLiveTickerStates() for (const [meetingId, state] of Object.entries(states)) { if (nowMs - Date.parse(state?.updatedAt || '') > LIVE_START_STATE_RETENTION_MS) delete states[meetingId] } await fs.mkdir(path.dirname(LIVE_TICKER_STATE_FILE), { recursive: true }) await fs.writeFile(LIVE_TICKER_STATE_FILE, `${JSON.stringify(states, null, 2)}\n`, 'utf8') } async function notifyLiveTickerChanges(entry, nowMs) { const states = await readObservedLiveTickerStates() const previous = states[entry.meetingId] const startedNow = entry.hasLiveData && !previous?.hasLiveData const finishedNow = entry.completed && previous && !previous.completed const scoreChanged = entry.hasLiveData && !entry.completed && previous?.hasLiveData && previous.score !== entry.score states[entry.meetingId] = { hasLiveData: entry.hasLiveData, completed: entry.completed, score: entry.score, updatedAt: new Date(nowMs).toISOString() } try { await saveObservedLiveTickerStates(nowMs) if (startedNow) await notifyLiveStart(entry, nowMs) if (scoreChanged) await sendLiveTickerScoreChangedPush(entry) if (finishedNow) await sendLiveTickerFinishedPush(entry) } catch (error) { loggerError('[live-ticker] Live-Push konnte nicht gesendet werden', { meetingId: entry.meetingId, error }) } } async function refreshLiveTickerOnce(now = new Date()) { const season = getCurrentSeasonSlug(now) const { data } = await readSpielplanData({ season }) const nowMs = now.getTime() const candidates = data.filter((row) => { const startsAt = matchTime(row) return Number.isFinite(startsAt) && startsAt - TICKER_WINDOW_BEFORE_MS <= nowMs && nowMs <= startsAt + TICKER_WINDOW_AFTER_MS }) await Promise.all(candidates.map(async (row) => { const meetingId = String(row.MeetingId || row.BegegnungNr || '').trim() if (!/^\d{6,}$/.test(meetingId)) return try { const response = await fetch(`https://www.mytischtennis.de/api/meeting/${meetingId}/live`, { headers: { accept: 'application/json', 'accept-language': 'de-DE,de;q=0.9' }, signal: AbortSignal.timeout(10_000) }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const payload = await response.json() const report = payload?.data if (!report) throw new Error('Keine Spieldaten erhalten') const completedAt = report.is_completed ? completedTime(report, nowMs) : null const previous = cache.get(meetingId) const entry = { meetingId, homeTeam: report.team_home || row.HeimMannschaft, awayTeam: report.team_guest || row.GastMannschaft, score: score(report), startsAt: matchTime(row), hasLiveData: report.has_nu_live_push_data === true, completed: report.is_completed === true, completedAt: completedAt || previous?.completedAt || null, updatedAt: new Date(nowMs).toISOString() } cache.set(meetingId, entry) await notifyLiveTickerChanges(entry, nowMs) } catch (error) { const previous = cache.get(meetingId) if (previous) cache.set(meetingId, { ...previous, updatedAt: previous.updatedAt, error: 'Live-Daten derzeit nicht erreichbar' }) } })) for (const [meetingId, entry] of cache) { if (entry.completed && entry.completedAt && nowMs - entry.completedAt > FINISHED_VISIBLE_MS) cache.delete(meetingId) } return getLiveTicker(now) } export function refreshLiveTicker(now = new Date()) { if (refreshInFlight) return refreshInFlight refreshInFlight = refreshLiveTickerOnce(now).finally(() => { refreshInFlight = null }) return refreshInFlight } export function getLiveTicker(now = new Date()) { const nowMs = now.getTime() const matches = [...cache.values()] .filter((entry) => !entry.completed || !entry.completedAt || nowMs - entry.completedAt <= FINISHED_VISIBLE_MS) .sort((a, b) => Number(b.hasLiveData && !b.completed) - Number(a.hasLiveData && !a.completed) || a.startsAt - b.startsAt) return { matches, updatedAt: new Date(nowMs).toISOString() } }