Stream-Übersicht aller laufenden Vetos und Matches

/stream zeigt alle aktiven Matches als Karten (Veto mit Map-Board, Match
mit Live-Stand und Serie), dazu offene Lobbys und Ergebnisse. Raster passt
sich der Anzahl an, ab 7 Matches wird geblättert. Live per Socket, optional
mit STREAM_KEY geschützt, ohne Codes oder Verbindungsdaten.
This commit is contained in:
mregosar committed 2026-10-25 19:32:53 +01:00
1 parent ea2207899f
commit 85041b2bff
11 files changed
+495 -3

No files matched your search

+2
View File
@@ -39,6 +39,8 @@ export function loadConfig(overrides = {}) {
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
streamKey: str('STREAM_KEY'),
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
trustProxy: bool('TRUST_PROXY', false),
+8
View File
@@ -69,6 +69,14 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
router.get('/stream/overview', wrap((req) => {
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
}
return service.streamOverview();
}));
router.post('/matches', limit('create'), wrap((req) => {
const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {};
if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) {
+70
View File
@@ -561,6 +561,76 @@ export class MatchService extends EventEmitter {
this.emit('changed', matchId);
}
/**
* Übersicht aller aktiven Matches für die Stream-Seite: laufende Vetos und Matches mit Details,
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
* keine Codes, keine Verbindungsdaten.
*/
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) {
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
const now = this.now();
const active = [];
const upcoming = [];
const results = [];
for (const m of rows) {
const pool = this.pools[m.mode];
const maps = JSON.parse(m.maps);
const mapInfo = (id) => {
const map = maps.find((x) => x.id === id);
return { id, name: map?.name ?? id, image: map?.image ?? null, color: map?.color ?? null };
};
const base = {
id: m.id,
teams: { A: m.team_a_name, B: m.team_b_name },
mode: m.mode,
modeLabel: pool?.label ?? m.mode,
format: m.format,
status: m.status,
statusLabel: MATCH_STATUS[m.status],
};
if (m.status === 'lobby') {
const readiness = this.teamReadiness(m);
upcoming.push({ ...base, ready: { A: readiness.A.ready, B: readiness.B.ready } });
continue;
}
const seriesRows = this.seriesMaps(m.id);
const series = seriesRows.map((s) => {
const live = s.live ? JSON.parse(s.live) : null;
return {
number: s.number,
...mapInfo(s.map),
pickedBy: s.picked_by,
status: s.status,
score: { A: s.score_a, B: s.score_b },
winner: s.winner,
live: live && { status: live.status, sides: live.sides, roundsPlayed: live.rounds.length },
};
});
const item = { ...base, seriesScore: seriesScore(seriesRows), currentMapNumber: m.current_map_number, series };
if (CLOSED_STATUSES.includes(m.status)) {
if (m.status === 'finished' && now - (m.finished_at ?? 0) < resultsWithinMs) results.push(item);
continue;
}
if (m.status === 'veto') {
const veto = this.vetoState(m);
item.veto = {
current: veto.current,
maps: veto.maps.map((x) => ({ ...mapInfo(x.id), status: x.status, by: x.by })),
stepsDone: veto.steps.filter((x) => x.done).length,
stepsTotal: veto.steps.length,
};
}
active.push(item);
}
// Laufende Matches zuerst, dann Vetos, dann Serverstart – jeweils älteste zuerst
const order = { live: 0, veto: 1, server_setup: 2 };
active.sort((a, b) => (order[a.status] - order[b.status]) || a.id.localeCompare(b.id));
return { active, upcoming: upcoming.slice(0, 8), results: results.slice(0, 8), updatedAt: now };
}
listMatches() {
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
const counts = this.db.prepare('SELECT team, COUNT(*) AS n FROM players WHERE match_id = ? AND active = 1 GROUP BY team');
+17
View File
@@ -1,4 +1,5 @@
import { Server } from 'socket.io';
import { safeEqual } from './security.js';
/**
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
@@ -50,9 +51,16 @@ export function attachRealtime(httpServer, { service, config }) {
}, 50));
};
let streamTimer = null;
const broadcastStream = () => {
clearTimeout(streamTimer);
streamTimer = setTimeout(() => io.to('stream-overview').emit('stream:overview', service.streamOverview()), 200);
};
service.on('changed', (matchId) => {
broadcastMatch(matchId);
broadcastOverview();
broadcastStream();
});
io.on('connection', (socket) => {
@@ -61,6 +69,15 @@ export function attachRealtime(httpServer, { service, config }) {
socket.emit('overview', { matches: service.listMatches() });
});
socket.on('stream:subscribe', ({ key } = {}) => {
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) {
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
return;
}
socket.join('stream-overview');
socket.emit('stream:overview', service.streamOverview());
});
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
if (typeof matchId !== 'string') return;
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));