feat(live-state): enrich roster with cached Steam profiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-12 22:02:58 +02:00
parent 33899f8c17
commit be476112ee
No known key found for this signature in database
2 changed files with 118 additions and 0 deletions

View file

@ -14,6 +14,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta, UTC from datetime import datetime, timedelta, UTC
from flask import current_app
from sqlalchemy import select from sqlalchemy import select
from l4d2web.db import session_scope from l4d2web.db import session_scope
@ -21,8 +22,10 @@ from l4d2web.models import (
Server, Server,
ServerLiveState, ServerLiveState,
ServerPlayerSession, ServerPlayerSession,
SteamUserProfile,
) )
from l4d2web.services.rcon import RconError, StatusResponse, query_status from l4d2web.services.rcon import RconError, StatusResponse, query_status
from l4d2web.services.steam_users import fetch_profiles_batch
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -42,6 +45,9 @@ def poll_once() -> None:
).all() ).all()
targets = [(s.id, s.port, s.rcon_password) for s in servers] 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: for server_id, port, password in targets:
try: try:
status = query_status("127.0.0.1", port, password, timeout=2.0) 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) _record_snapshot(server_id, status)
_reconcile_sessions(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: def _record_snapshot(server_id: int, status: StatusResponse) -> None:

View file

@ -17,9 +17,11 @@ from l4d2web.models import (
Server, Server,
ServerLiveState, ServerLiveState,
ServerPlayerSession, ServerPlayerSession,
SteamUserProfile,
User, User,
) )
from l4d2web.services import live_state_poller from l4d2web.services import live_state_poller
from l4d2web.services import steam_users
from l4d2web.services.rcon import PlayerRow, StatusResponse 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) select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid)
).all() ).all()
assert sessions == [] 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 == []