173 lines
7.3 KiB
JavaScript
173 lines
7.3 KiB
JavaScript
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() }
|
|
}
|