From be476112ee5c4586c1ded206d91f5286613d7afd Mon Sep 17 00:00:00 2001 From: mwiegand Date: Tue, 12 May 2026 22:02:58 +0200 Subject: [PATCH] feat(live-state): enrich roster with cached Steam profiles Co-Authored-By: Claude Sonnet 4.6 --- l4d2web/services/live_state_poller.py | 47 ++++++++++++++++ l4d2web/tests/test_live_state_poller.py | 71 +++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/l4d2web/services/live_state_poller.py b/l4d2web/services/live_state_poller.py index b3a8332..ece58d9 100644 --- a/l4d2web/services/live_state_poller.py +++ b/l4d2web/services/live_state_poller.py @@ -14,6 +14,7 @@ from __future__ import annotations import logging from datetime import datetime, timedelta, UTC +from flask import current_app from sqlalchemy import select from l4d2web.db import session_scope @@ -21,8 +22,10 @@ from l4d2web.models import ( Server, ServerLiveState, ServerPlayerSession, + SteamUserProfile, ) from l4d2web.services.rcon import RconError, StatusResponse, query_status +from l4d2web.services.steam_users import fetch_profiles_batch logger = logging.getLogger(__name__) @@ -42,6 +45,9 @@ def poll_once() -> None: ).all() targets = [(s.id, s.port, s.rcon_password) for s in servers] + api_key = current_app.config.get("STEAM_WEB_API_KEY", "") or "" + ttl_seconds = int(current_app.config.get("STEAM_PROFILE_TTL_SECONDS", 86400)) + for server_id, port, password in targets: try: status = query_status("127.0.0.1", port, password, timeout=2.0) @@ -51,6 +57,47 @@ def poll_once() -> None: _record_snapshot(server_id, status) _reconcile_sessions(server_id, status) + if api_key: + _enrich_profiles(status, api_key=api_key, ttl_seconds=ttl_seconds) + + +def _enrich_profiles(status: StatusResponse, *, api_key: str, ttl_seconds: int) -> None: + """Fetch+cache Steam profile data for any roster IDs missing or stale.""" + roster_ids = {p.steam_id_64 for p in status.roster if _is_valid_steam_id_64(p.steam_id_64)} + if not roster_ids: + return + cutoff = _now() - timedelta(seconds=ttl_seconds) + with session_scope() as db: + fresh = set(db.scalars( + select(SteamUserProfile.steam_id_64).where( + SteamUserProfile.steam_id_64.in_(roster_ids), + SteamUserProfile.fetched_at >= cutoff, + ) + ).all()) + needs_fetch = sorted(roster_ids - fresh) + if not needs_fetch: + return + try: + profiles = fetch_profiles_batch(needs_fetch, api_key=api_key) + except Exception: # network / API errors are soft-fail + logger.warning("steam profile enrichment failed", exc_info=True) + return + + now = _now() + with session_scope() as db: + for p in profiles: + row = db.get(SteamUserProfile, p.steam_id_64) + if row is None: + db.add(SteamUserProfile( + steam_id_64=p.steam_id_64, + persona_name=p.persona_name, + avatar_url=p.avatar_url, + fetched_at=now, + )) + else: + row.persona_name = p.persona_name + row.avatar_url = p.avatar_url + row.fetched_at = now def _record_snapshot(server_id: int, status: StatusResponse) -> None: diff --git a/l4d2web/tests/test_live_state_poller.py b/l4d2web/tests/test_live_state_poller.py index 8636b72..45026ce 100644 --- a/l4d2web/tests/test_live_state_poller.py +++ b/l4d2web/tests/test_live_state_poller.py @@ -17,9 +17,11 @@ from l4d2web.models import ( Server, ServerLiveState, ServerPlayerSession, + SteamUserProfile, User, ) from l4d2web.services import live_state_poller +from l4d2web.services import steam_users from l4d2web.services.rcon import PlayerRow, StatusResponse @@ -225,3 +227,72 @@ def test_session_skips_bots(tmp_path, monkeypatch) -> None: select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid) ).all() assert sessions == [] + + +def test_enriches_missing_profile(tmp_path, monkeypatch) -> None: + app, sid = _seed(tmp_path) + app.config["STEAM_WEB_API_KEY"] = "KEY" + monkeypatch.setattr( + live_state_poller, "query_status", + lambda *a, **kw: _status(players=1, roster=[_player()]), + ) + captured: list = [] + def fake_fetch(ids, *, api_key): + captured.append((list(ids), api_key)) + return [steam_users.SteamProfile( + steam_id_64="76561197960828710", + persona_name="Alice", + avatar_url="https://avatars.../alice_medium.jpg", + )] + monkeypatch.setattr(live_state_poller, "fetch_profiles_batch", fake_fetch) + + with app.app_context(): + live_state_poller.poll_once() + + assert captured and captured[0][1] == "KEY" + with session_scope() as db: + p = db.scalar(select(SteamUserProfile)) + assert p is not None + assert p.persona_name == "Alice" + + +def test_skips_enrichment_when_api_key_unset(tmp_path, monkeypatch) -> None: + app, sid = _seed(tmp_path) + app.config["STEAM_WEB_API_KEY"] = "" + monkeypatch.setattr( + live_state_poller, "query_status", + lambda *a, **kw: _status(players=1, roster=[_player()]), + ) + monkeypatch.setattr( + live_state_poller, "fetch_profiles_batch", + lambda ids, *, api_key: pytest.fail("must not call without key"), + ) + + with app.app_context(): + live_state_poller.poll_once() + + +def test_skips_enrichment_when_cache_is_fresh(tmp_path, monkeypatch) -> None: + app, sid = _seed(tmp_path) + app.config["STEAM_WEB_API_KEY"] = "KEY" + with session_scope() as db: + db.add(SteamUserProfile( + steam_id_64="76561197960828710", + persona_name="cached", + avatar_url="cached.jpg", + fetched_at=datetime.now(UTC).replace(tzinfo=None), + )) + monkeypatch.setattr( + live_state_poller, "query_status", + lambda *a, **kw: _status(players=1, roster=[_player()]), + ) + called: list = [] + monkeypatch.setattr( + live_state_poller, "fetch_profiles_batch", + lambda ids, *, api_key: called.append(list(ids)) or [], + ) + + with app.app_context(): + live_state_poller.poll_once() + + assert called == []