Files
romm/backend/handler/activity_handler.py
T

389 lines
15 KiB
Python

"""Real-time user game activity tracking.
Stores ephemeral "currently playing" state in Redis. Each active session is a
Redis key with a short TTL, refreshed by periodic heartbeats from the client
(browser) or the device. When the TTL expires (no heartbeat received), the
session is considered ended automatically.
"""
from __future__ import annotations
import asyncio
import json
from contextlib import suppress
from datetime import datetime, timezone
from typing import Any, TypedDict, cast
from uuid import uuid4
from redis.exceptions import WatchError
from endpoints.responses.activity import ActivityClearSchema
from handler.database import (
db_device_handler,
db_permission_handler,
db_rom_handler,
db_save_handler,
db_user_handler,
)
from handler.redis_handler import async_cache
from handler.socket_handler import socket_handler
from logger.logger import log
from utils.screenshots import continue_playing_screenshot
class ActivityEntry(TypedDict):
user_id: int
username: str
avatar_path: str
rom_id: int
rom_name: str
rom_cover_path: str # small cover path, may be empty
screenshot_path: str # "where they are" image (save/title/screenshot), may be empty
platform_slug: str
platform_name: str
device_id: str
device_type: str # "web", "grout", "argosy-launcher", "streaming", etc.
started_at: str # ISO 8601 timestamp
class ActivityHandler:
"""Redis-backed store for currently active game play sessions."""
ACTIVITY_TTL = 90 # seconds; refreshed by heartbeats
ROM_INDEX_TTL = 120 # slightly longer than ACTIVITY_TTL
KEY_PREFIX = "activity:user:"
ROM_INDEX_PREFIX = "activity:rom:"
REFRESH_DELAY = 5 # seconds; failed lookups within it share one refresh
# Held by the worker that will send the pending refresh. It outlives the
# delay so a worker that dies before sending frees it.
REFRESH_KEY = "activity:refresh:pending"
_refresh: asyncio.Task[None] | None = None
_flush: asyncio.Event | None = None
def _activity_key(self, user_id: int, device_id: str) -> str:
return f"{self.KEY_PREFIX}{user_id}:{device_id}"
def _rom_index_key(self, rom_id: int) -> str:
return f"{self.ROM_INDEX_PREFIX}{rom_id}"
def _member(self, user_id: int, device_id: str) -> str:
return f"{user_id}:{device_id}"
async def build_entry(
self,
*,
user_id: int,
device_id: str,
rom_id: int,
preserve_started_at: bool,
device_type: str | None = None,
) -> ActivityEntry | None:
"""Assemble an entry from the database, or None if the user or ROM is gone.
Args:
preserve_started_at: keep the start time of the entry already
stored, so refreshing does not reset the elapsed label.
device_type: what is being played on, for callers with no registered
device to read it from. Looked up from the device when omitted.
"""
user = db_user_handler.get_user(user_id)
if user is None:
log.debug(f"activity: unknown user_id {user_id}")
return None
rom = db_rom_handler.get_rom(rom_id)
if rom is None:
log.debug(f"activity: unknown rom_id {rom_id}")
return None
started_at = datetime.now(timezone.utc).isoformat()
if preserve_started_at:
existing = await self.get_active(user_id, device_id)
if existing:
started_at = existing["started_at"]
if device_type is None:
device = db_device_handler.get_device(device_id=device_id, user_id=user_id)
device_type = device.client if device else None
# "Where they are" image - the player's latest save screenshot, else the
# title screen / first gameplay screenshot (frontend falls back to cover).
latest_save = db_save_handler.get_latest_saves_for_roms(
user_id=user_id, rom_ids=[rom_id]
).get(rom_id)
platform = rom.platform
return ActivityEntry(
user_id=user.id,
username=user.username,
avatar_path=user.avatar_path or "",
rom_id=rom.id,
rom_name=rom.name or rom.fs_name,
rom_cover_path=rom.path_cover_s or "",
screenshot_path=continue_playing_screenshot(rom, latest_save) or "",
platform_slug=platform.slug if platform else "",
platform_name=((platform.custom_name or platform.name) if platform else ""),
device_id=device_id,
device_type=device_type or "web",
started_at=started_at,
)
async def publish_active(self, entry: ActivityEntry) -> None:
"""Store a session and send it to everyone allowed to see its ROM."""
previous = await self.get_active(entry["user_id"], entry["device_id"])
await self.set_active(entry)
rooms = await _audience_rooms(entry["rom_id"])
await self._emit("activity:update", dict(entry), rooms)
# Without this game's audience the refresh just sent covers everyone.
if rooms is not None and previous and previous["rom_id"] != entry["rom_id"]:
# Whoever saw the last game but can't see this one would keep its card.
stale = await _audience_rooms(previous["rom_id"])
await self._emit(
"activity:clear",
ActivityClearSchema(
user_id=entry["user_id"],
device_id=entry["device_id"],
rom_id=previous["rom_id"],
).model_dump(),
None if stale is None else sorted(set(stale) - set(rooms)),
)
async def publish_clear(self, user_id: int, device_id: str) -> int | None:
"""End a session and announce it. Returns the rom_id cleared, if any."""
rom_id = await self.clear_active(user_id, device_id)
if rom_id is None:
return None
await self._emit(
"activity:clear",
ActivityClearSchema(
user_id=user_id, device_id=device_id, rom_id=rom_id
).model_dump(),
await _audience_rooms(rom_id),
)
return rom_id
async def _emit(
self, event: str, payload: dict[str, Any], rooms: list[str] | None
) -> None:
# The REST app shares this process with the Socket.IO server, so emit
# through the already-initialised, Redis-backed server (it fans out
# across workers) rather than opening a manager per call. An empty
# `to` would reach every socket, so no audience sends nothing.
if rooms is None:
# An unknown audience can't be sent the session; every client
# re-lists what it may see instead.
await self._queue_refresh()
return
if not rooms:
return
try:
await socket_handler.socket_server.emit(event, payload, to=rooms)
except Exception as e: # noqa: BLE001
log.warning(f"Failed to broadcast {event}: {e}")
async def _queue_refresh(self) -> None:
"""Ask every socket to re-list, once for however many lookups fail meanwhile."""
loop = asyncio.get_running_loop()
pending = self._refresh
if pending is not None and not pending.done() and pending.get_loop() is loop:
return
# Each worker's broadcast reaches every socket, so workers share one claim.
token = uuid4().hex
try:
claimed = await async_cache.set(
self.REFRESH_KEY, token, nx=True, ex=2 * self.REFRESH_DELAY
)
except Exception as e: # noqa: BLE001
log.warning(f"Failed to claim activity:refresh: {e}")
claimed = True
if claimed:
self._flush = asyncio.Event()
self._refresh = loop.create_task(self._send_refresh(token, self._flush))
async def _send_refresh(self, token: str, flush: asyncio.Event) -> None:
with suppress(TimeoutError):
await asyncio.wait_for(flush.wait(), self.REFRESH_DELAY)
# Released first, so a lookup that fails from here on queues another.
# Only the claimant releases; an expired claim may be another worker's now.
try:
if await async_cache.get(self.REFRESH_KEY) in (token, token.encode()):
await async_cache.delete(self.REFRESH_KEY)
except Exception as e: # noqa: BLE001
log.warning(f"Failed to release activity:refresh: {e}")
try:
await socket_handler.socket_server.emit("activity:refresh", {})
except Exception as e: # noqa: BLE001
log.warning(f"Failed to broadcast activity:refresh: {e}")
async def flush_refresh(self) -> None:
"""Send a pending refresh now, so it isn't lost when the worker stops."""
pending, flush = self._refresh, self._flush
if pending is None or flush is None or pending.done():
return
flush.set()
await pending
async def set_active(self, entry: ActivityEntry) -> None:
"""Store or refresh a user's active play session."""
key = self._activity_key(entry["user_id"], entry["device_id"])
rom_key = self._rom_index_key(entry["rom_id"])
member = self._member(entry["user_id"], entry["device_id"])
async with async_cache.pipeline() as pipe:
await pipe.set(key, json.dumps(entry), ex=self.ACTIVITY_TTL)
await pipe.sadd(rom_key, member)
await pipe.expire(rom_key, self.ROM_INDEX_TTL)
await pipe.execute()
async def clear_active(self, user_id: int, device_id: str) -> int | None:
"""Clear a user's active play session. Returns the rom_id that was cleared, or None."""
key = self._activity_key(user_id, device_id)
raw = await async_cache.get(key)
if not raw:
return None
try:
entry = json.loads(raw)
rom_id = int(entry["rom_id"])
except (ValueError, KeyError, TypeError) as e:
log.warning(f"Failed to parse activity entry for cleanup: {e}")
await async_cache.delete(key)
return None
member = self._member(user_id, device_id)
async with async_cache.pipeline() as pipe:
await pipe.delete(key)
await pipe.srem(self._rom_index_key(rom_id), member)
await pipe.execute()
return rom_id
async def get_active(self, user_id: int, device_id: str) -> ActivityEntry | None:
"""Get a single active session by user and device."""
key = self._activity_key(user_id, device_id)
raw = await async_cache.get(key)
if not raw:
return None
try:
return cast(ActivityEntry | None, json.loads(raw))
except ValueError:
return None
async def get_all_active(self) -> list[ActivityEntry]:
"""Get all currently active play sessions across all users."""
pattern = f"{self.KEY_PREFIX}*"
keys = [key async for key in async_cache.scan_iter(match=pattern)]
if not keys:
return []
# Single round-trip for every value instead of a GET per key.
entries: list[ActivityEntry] = []
for raw in await async_cache.mget(keys):
if not raw:
continue
try:
entries.append(json.loads(raw))
except ValueError:
continue
return entries
async def get_active_for_rom(self, rom_id: int) -> list[ActivityEntry]:
"""Get all active play sessions for a specific ROM."""
rom_key = self._rom_index_key(rom_id)
members = await async_cache.smembers(rom_key)
entries: list[ActivityEntry] = []
malformed: list[str] = []
keys_by_member: dict[str, str] = {}
for raw_member in members:
# Decoded as the session middleware does, for a client that doesn't.
member = (
raw_member.decode() if isinstance(raw_member, bytes) else raw_member
)
try:
user_id_str, device_id = member.rsplit(":", 1)
user_id = int(user_id_str)
except ValueError:
malformed.append(member)
continue
keys_by_member[member] = self._activity_key(user_id, device_id)
raws = (
await async_cache.mget(list(keys_by_member.values()))
if keys_by_member
else []
)
stale: dict[str, str] = {}
for (member, key), raw in zip(keys_by_member.items(), raws, strict=True):
entry = _live_entry(raw, rom_id)
if entry is None:
stale[member] = key
else:
entries.append(entry)
if malformed or stale:
await self._drop_index_members(rom_id, malformed, stale)
return entries
async def _drop_index_members(
self, rom_id: int, malformed: list[str], stale: dict[str, str]
) -> None:
"""Remove members from a ROM's index, sparing any a heartbeat has revived."""
async with async_cache.pipeline() as pipe:
try:
if stale:
await pipe.watch(*stale.values())
raws = await pipe.mget(list(stale.values()))
still_stale = [
member
for member, raw in zip(stale, raws, strict=True)
if _live_entry(raw, rom_id) is None
]
else:
still_stale = []
if not (malformed or still_stale):
return
pipe.multi()
await pipe.srem(self._rom_index_key(rom_id), *malformed, *still_stale)
await pipe.execute()
except WatchError:
# A heartbeat raced this cleanup; the next read retries it.
pass
async def _audience_rooms(rom_id: int) -> list[str] | None:
"""The ROM's audience, or None if it can't be read, so publishing still succeeds."""
try:
return await asyncio.to_thread(_audience, rom_id)
except Exception: # noqa: BLE001 - activity is best-effort, like its broadcast
log.warning(f"Failed to resolve who can see ROM {rom_id}", exc_info=True)
return None
def _audience(rom_id: int) -> list[str]:
"""Sockets join their `user:{id}` room on connect; anonymous sockets join none."""
rom = db_rom_handler.get_rom_visibility(rom_id)
if rom is None:
return []
return [
f"user:{user_id}" for user_id in db_permission_handler.get_rom_audience(rom)
]
def _live_entry(raw: str | bytes | None, rom_id: int) -> ActivityEntry | None:
"""The stored session, if it exists, parses and is still playing `rom_id`."""
if not raw:
return None
try:
entry = json.loads(raw)
except ValueError:
return None
if not isinstance(entry, dict) or entry.get("rom_id") != rom_id:
return None
return cast(ActivityEntry, entry)
activity_handler = ActivityHandler()