diff --git a/AGENTS.md b/AGENTS.md index 22e1bab..de8047d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,5 @@ # Agenten-Regeln -- Nach jeder Änderung am Proxy (server.py) oder an den Requests (app.js) muss der Proxy neu gestartet werden. +- Nach jeder Änderung am Proxy (server.py) oder an den Requests (app.js) muss der Proxy neu gestartet werden: erst alten Prozess killen (`Get-Process -Name python | Stop-Process -Force`), dann unsichtbar starten. - Commits und Push erfolgen nur durch expliziten Befehl des Nutzers. +- Der Server (server.py) wird immer unsichtbar gestartet: `Start-Process -NoNewWindow -FilePath "python" -ArgumentList "server.py"` diff --git a/app.js b/app.js index 2fb9f1f..1348b88 100644 --- a/app.js +++ b/app.js @@ -14,44 +14,59 @@ let laraColor = null; let currentMoveIndex = -1; let userSelectedGame = false; let userScrolledMoves = false; -let lastMtime = 0; let currentRound = 0; +let roundPgns = {}; let pollId = 0; -/** - * Lädt die PGN-Datei und aktualisiert die Anzeige - */ +async function fetchRoundPGN(round) { + const res = await fetch(`http://localhost:8111/pgn/${round}`); + if (!res.ok) return null; + return await res.text(); +} + async function loadPGN(showOverlay = true) { - const currentPollId = ++pollId; + const currentPollId = pollId; if (showOverlay) showLoading(true); hideError(); try { - // Zuerst Tabelle abrufen, um die aktuelle Runde zu ermitteln await updateStandings(); if (currentRound === 0) { if (showOverlay) showLoading(false); return; } - const [pgnResponse, statusResponse] = await Promise.all([ - fetch(`http://localhost:8111/pgn/${currentRound}?since=${lastMtime}`), - fetch('http://localhost:8111/status').catch(() => null) - ]); - + + // Fehlende vergangene Runden einmalig nachladen + for (let r = 1; r < currentRound; r++) { + if (roundPgns[r] === undefined) { + const text = await fetchRoundPGN(r); + if (text !== null) roundPgns[r] = text; + } + } + + const statusResponse = await fetch('http://localhost:8111/status').catch(() => null); + + // Aktuelle Runde immer frisch holen + const pgnText = await fetchRoundPGN(currentRound); if (currentPollId !== pollId) return; - if (!pgnResponse.ok) throw new Error(`HTTP ${pgnResponse.status}`); - - const mtimeHeader = pgnResponse.headers.get('X-Cache-Mtime'); - if (mtimeHeader) lastMtime = parseFloat(mtimeHeader); - + if (pgnText !== null) roundPgns[currentRound] = pgnText; + + // Nächste Runde prüfen (sobald verfügbar, einmalig holen) + const nextRound = currentRound + 1; + if (roundPgns[nextRound] === undefined) { + const text = await fetchRoundPGN(nextRound).catch(() => null); + if (text) roundPgns[nextRound] = text; + } + if (statusResponse && statusResponse.ok) { const status = await statusResponse.json(); serverLastFetch = status.last_fetch ? status.last_fetch * 1000 : null; } - const pgnText = await pgnResponse.text(); - const allGames = parsePGN(pgnText); + // Alle PGNs kombinieren + const combinedPgn = Object.values(roundPgns).join('\n\n'); + const allGames = parsePGN(combinedPgn); allLaraGames = filterLaraGames(allGames); if (allLaraGames.length === 0) { @@ -59,7 +74,6 @@ async function loadPGN(showOverlay = true) { return; } - // Nur automatisch wechseln, wenn der Benutzer keine andere Partie ausgewählt hat if (!userSelectedGame) { const liveGame = getLiveGame(allLaraGames); const newGame = liveGame || getLatestGame(allLaraGames); @@ -439,18 +453,29 @@ function updateTimestamp() { } /** - * Start long-polling: nach jeder Antwort sofort die nächste Anfrage stellen + * Startet Auto-Refresh alle 30 Sekunden */ function startAutoRefresh() { - document.getElementById('refresh-timer').textContent = '● Live'; - document.getElementById('refresh-timer').style.color = '#4ade80'; - const myId = ++pollId; - (async function poll() { - while (pollId === myId) { - await loadPGN(false); - } - })(); + let lastUpdate = Date.now(); + let pollInterval, timer; + + document.getElementById('refresh-timer').textContent = '0s'; + document.getElementById('refresh-timer').style.color = '#4ade80'; + + loadPGN(true); + + pollInterval = setInterval(() => { + if (pollId !== myId) { clearInterval(pollInterval); clearInterval(timer); return; } + loadPGN(false); + lastUpdate = Date.now(); + }, 30000); + + timer = setInterval(() => { + if (pollId !== myId) { clearInterval(pollInterval); clearInterval(timer); return; } + const s = Math.floor((Date.now() - lastUpdate) / 1000); + document.getElementById('refresh-timer').textContent = `${s}s`; + }, 1000); } /** @@ -474,7 +499,7 @@ function hideError() { */ document.getElementById('refresh-btn').addEventListener('click', () => { pollId++; - loadPGN(true).then(() => startAutoRefresh()); + startAutoRefresh(); }); /** diff --git a/server.py b/server.py index ec998d4..526c5a2 100644 --- a/server.py +++ b/server.py @@ -25,73 +25,70 @@ CACHE_TTL = int(os.environ.get("CACHE_TTL", 31)) os.makedirs(CACHE_DIR, exist_ok=True) -last_fetch_time = None last_standings_fetch_time = None current_round = 0 # wird aus der Tabelle ermittelt -def get_pgn_url(): - return f"https://www.deutsche-schachjugend.de/2026/odjm-d/partien/{current_round}.pgn" +def get_pgn_url(round_num=None): + if round_num is None: + round_num = current_round + return f"https://www.deutsche-schachjugend.de/2026/odjm-d/partien/{round_num}.pgn" -def get_cache_file(): - return os.path.join(CACHE_DIR, f"runde-{current_round}.pgn") +def get_cache_file(round_num=None): + if round_num is None: + round_num = current_round + return os.path.join(CACHE_DIR, f"runde-{round_num}.pgn") -def fetch_pgn(): +def fetch_pgn(round_num=None): """Lädt die PGN-Datei von der URL als Bytes. - Nutzt If-Modified-Since – gibt (data, changed) zurück. - changed=False bedeutet 304 Not Modified (keine neuen Daten).""" - global last_fetch_time - if current_round == 0: + Gibt (data, True) bei Erfolg oder (None, False) bei Fehler zurück.""" + if round_num is None: + round_num = current_round + if round_num == 0: return None, False - url = get_pgn_url() + url = get_pgn_url(round_num) try: headers = {"User-Agent": "Mozilla/5.0"} - if last_fetch_time: - headers["If-Modified-Since"] = datetime.utcfromtimestamp(last_fetch_time).strftime("%a, %d %b %Y %H:%M:%S GMT") req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=31) as response: data = response.read() - last_fetch_time = time.time() return data, True except urllib.error.HTTPError as e: - if e.code == 304: - return None, False - print(f"[{datetime.now().strftime('%H:%M:%S')}] HTTP-Fehler: {e}") + print(f"[{datetime.now().strftime('%H:%M:%S')}] HTTP-Fehler (Runde {round_num}): {e}") return None, False except Exception as e: - print(f"[{datetime.now().strftime('%H:%M:%S')}] Fehler beim Laden: {e}") + print(f"[{datetime.now().strftime('%H:%M:%S')}] Fehler beim Laden (Runde {round_num}): {e}") return None, False -def get_pgn_content_longpoll(since=0): - """Long-Poll: Blockiert, bis sich die PGN-Datei tatsächlich ändert. - Antwortet nur bei neuen Daten, nie mit unverändertem Stand.""" - while True: - cache_file = get_cache_file() - if os.path.exists(cache_file): - mtime = os.path.getmtime(cache_file) - if mtime > since: - with open(cache_file, "rb") as f: - return f.read(), mtime - age = time.time() - mtime - if age >= CACHE_TTL: - content, changed = fetch_pgn() - if changed and content is not None: - with open(cache_file, "wb") as f: - f.write(content) - new_mtime = os.path.getmtime(cache_file) - print(f"[{datetime.now().strftime('%H:%M:%S')}] PGN via Long-Poll aktualisiert ({len(content)} Bytes)") - return content, new_mtime - else: - content, changed = fetch_pgn() - if changed and content is not None: - with open(cache_file, "wb") as f: - f.write(content) - return content, os.path.getmtime(cache_file) +def get_round_pgn(round_num): + """Gibt PGN-Daten für eine Runde zurück. + Bei der aktuellen Runde wird immer live von der DSJ geholt.""" + cache_file = get_cache_file(round_num) - time.sleep(5) + if round_num == current_round: + content, changed = fetch_pgn(round_num) + if changed and content is not None: + with open(cache_file, "wb") as f: + f.write(content) + return content, os.path.getmtime(cache_file) + if os.path.exists(cache_file): + with open(cache_file, "rb") as f: + return f.read(), os.path.getmtime(cache_file) + return None, 0 + + if os.path.exists(cache_file): + with open(cache_file, "rb") as f: + return f.read(), os.path.getmtime(cache_file) + + content, changed = fetch_pgn(round_num) + if changed and content is not None: + with open(cache_file, "wb") as f: + f.write(content) + return content, os.path.getmtime(cache_file) + return None, 0 def fetch_standings(): @@ -165,19 +162,27 @@ class PGNHandler(http.server.BaseHTTPRequestHandler): parts = parsed_path.path.strip("/").split("/") if len(parts) == 2 and parts[0] == "pgn" and parts[1].isdigit(): - params = urllib.parse.parse_qs(parsed_path.query) - since = float(params.get("since", [0])[0]) - content, mtime = get_pgn_content_longpoll(since) + round_num = int(parts[1]) + + if round_num <= 0 or round_num > current_round + 1: + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps({"error": "Runde nicht verf\u00fcgbar"}).encode()) + return + + content, mtime = get_round_pgn(round_num) + if content: self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Cache-Control", "no-cache") - self.send_header("X-Cache-Mtime", str(mtime)) self.end_headers() self.wfile.write(content) else: - self.send_response(502) + self.send_response(404) self.send_header("Content-Type", "application/json") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() @@ -191,8 +196,6 @@ class PGNHandler(http.server.BaseHTTPRequestHandler): self.end_headers() status = { "status": "ok", - "last_fetch": last_fetch_time, - "cache_ttl": CACHE_TTL, "server_time": time.time() } self.wfile.write(json.dumps(status).encode()) @@ -259,6 +262,7 @@ def background_refresh(): cache_file = get_cache_file() with open(cache_file, "wb") as f: f.write(content) + print(f"[{datetime.now().strftime('%H:%M:%S')}] Hintergrund-Refresh: PGN aktualisiert ({len(content)} Bytes)") except Exception as e: print(f"[{datetime.now().strftime('%H:%M:%S')}] Hintergrund-Refresh Fehler: {e}")