feat(live-state): enrich roster with cached Steam profiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
33899f8c17
commit
be476112ee
2 changed files with 118 additions and 0 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 == []
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue