refactor(project): make pyright & ruff happy

This commit is contained in:
MingxuanGame
2025-08-22 08:21:52 +00:00
parent 3b1d7a2234
commit 598fcc8b38
157 changed files with 2382 additions and 4590 deletions

View File

@@ -145,14 +145,9 @@ class Hub[TState: UserState]:
connection: WebSocket,
) -> Client:
if connection_token in self.clients:
raise ValueError(
f"Client with connection token {connection_token} already exists."
)
raise ValueError(f"Client with connection token {connection_token} already exists.")
if connection_token in self.waited_clients:
if (
self.waited_clients[connection_token]
< time.time() - settings.signalr_negotiate_timeout
):
if self.waited_clients[connection_token] < time.time() - settings.signalr_negotiate_timeout:
raise TimeoutError(f"Connection {connection_id} has waited too long.")
del self.waited_clients[connection_token]
client = Client(connection_id, connection_token, connection, protocol)
@@ -196,9 +191,7 @@ class Hub[TState: UserState]:
try:
await client.send_packet(packet)
except WebSocketDisconnect as e:
logger.info(
f"Client {client.connection_id} disconnected: {e.code}, {e.reason}"
)
logger.info(f"Client {client.connection_id} disconnected: {e.code}, {e.reason}")
await self.remove_client(client)
except RuntimeError as e:
if "disconnect message" in str(e):
@@ -216,9 +209,7 @@ class Hub[TState: UserState]:
tasks.append(self.call_noblock(client, method, *args))
await asyncio.gather(*tasks)
async def broadcast_group_call(
self, group_id: str, method: str, *args: Any
) -> None:
async def broadcast_group_call(self, group_id: str, method: str, *args: Any) -> None:
tasks = []
for client in self.groups.get(group_id, []):
tasks.append(self.call_noblock(client, method, *args))
@@ -241,9 +232,7 @@ class Hub[TState: UserState]:
self.tasks.add(task)
task.add_done_callback(self.tasks.discard)
except WebSocketDisconnect as e:
logger.info(
f"Client {client.connection_id} disconnected: {e.code}, {e.reason}"
)
logger.info(f"Client {client.connection_id} disconnected: {e.code}, {e.reason}")
except RuntimeError as e:
if "disconnect message" in str(e):
logger.info(f"Client {client.connection_id} closed the connection.")
@@ -251,12 +240,8 @@ class Hub[TState: UserState]:
logger.exception(f"RuntimeError in client {client.connection_id}: {e}")
except CloseConnection as e:
if not e.from_client:
await client.send_packet(
ClosePacket(error=e.message, allow_reconnect=e.allow_reconnect)
)
logger.info(
f"Client {client.connection_id} closed the connection: {e.message}"
)
await client.send_packet(ClosePacket(error=e.message, allow_reconnect=e.allow_reconnect))
logger.info(f"Client {client.connection_id} closed the connection: {e.message}")
except Exception:
logger.exception(f"Error in client {client.connection_id}")
@@ -273,15 +258,9 @@ class Hub[TState: UserState]:
result = await self.invoke_method(client, packet.target, args)
except InvokeException as e:
error = e.message
logger.debug(
f"Client {client.connection_token} call {packet.target}"
f" failed: {error}"
)
logger.debug(f"Client {client.connection_token} call {packet.target} failed: {error}")
except Exception:
logger.exception(
f"Error invoking method {packet.target} for "
f"client {client.connection_id}"
)
logger.exception(f"Error invoking method {packet.target} for client {client.connection_id}")
error = "Unknown error occured in server"
if packet.invocation_id is not None:
await client.send_packet(
@@ -303,9 +282,7 @@ class Hub[TState: UserState]:
for name, param in signature.parameters.items():
if name == "self" or param.annotation is Client:
continue
call_params.append(
client.protocol.validate_object(args.pop(0), param.annotation)
)
call_params.append(client.protocol.validate_object(args.pop(0), param.annotation))
return await method_(client, *call_params)
async def call(self, client: Client, method: str, *args: Any) -> Any:

View File

@@ -13,7 +13,7 @@ from app.database.playlist_best_score import PlaylistBestScore
from app.database.playlists import Playlist
from app.database.room import Room
from app.database.score import Score
from app.dependencies.database import get_redis, with_db
from app.dependencies.database import with_db
from app.log import logger
from app.models.metadata_hub import (
TOTAL_SCORE_DISTRIBUTION_BINS,
@@ -44,13 +44,8 @@ class MetadataHub(Hub[MetadataClientState]):
self._today = datetime.now(UTC).date()
self._lock = asyncio.Lock()
def get_daily_challenge_stats(
self, daily_challenge_room: int
) -> MultiplayerRoomStats:
if (
self._daily_challenge_stats is None
or self._today != datetime.now(UTC).date()
):
def get_daily_challenge_stats(self, daily_challenge_room: int) -> MultiplayerRoomStats:
if self._daily_challenge_stats is None or self._today != datetime.now(UTC).date():
self._daily_challenge_stats = MultiplayerRoomStats(
room_id=daily_challenge_room,
playlist_item_stats={},
@@ -65,9 +60,7 @@ class MetadataHub(Hub[MetadataClientState]):
def room_watcher_group(room_id: int) -> str:
return f"metadata:multiplayer-room-watchers:{room_id}"
def broadcast_tasks(
self, user_id: int, store: MetadataClientState | None
) -> set[Coroutine]:
def broadcast_tasks(self, user_id: int, store: MetadataClientState | None) -> set[Coroutine]:
if store is not None and not store.pushable:
return set()
data = store.for_push if store else None
@@ -96,18 +89,15 @@ class MetadataHub(Hub[MetadataClientState]):
# Use centralized offline status management
from app.service.online_status_manager import online_status_manager
await online_status_manager.set_user_offline(user_id)
if state.pushable:
await asyncio.gather(*self.broadcast_tasks(user_id, None))
async with with_db() as session:
async with session.begin():
user = (
await session.exec(
select(User).where(User.id == int(state.connection_id))
)
).one()
user = (await session.exec(select(User).where(User.id == int(state.connection_id)))).one()
user.last_visit = datetime.now(UTC)
await session.commit()
@@ -124,6 +114,7 @@ class MetadataHub(Hub[MetadataClientState]):
# Use centralized online status management
from app.service.online_status_manager import online_status_manager
await online_status_manager.set_user_online(user_id, "metadata")
# CRITICAL FIX: Set online status IMMEDIATELY upon connection
@@ -143,20 +134,14 @@ class MetadataHub(Hub[MetadataClientState]):
).all()
tasks = []
for friend_id in friends:
self.groups.setdefault(
self.friend_presence_watchers_group(friend_id), set()
).add(client)
if (
friend_state := self.state.get(friend_id)
) and friend_state.pushable:
self.groups.setdefault(self.friend_presence_watchers_group(friend_id), set()).add(client)
if (friend_state := self.state.get(friend_id)) and friend_state.pushable:
tasks.append(
self.broadcast_group_call(
self.friend_presence_watchers_group(friend_id),
"FriendPresenceUpdated",
friend_id,
friend_state.for_push
if friend_state.pushable
else None,
friend_state.for_push if friend_state.pushable else None,
)
)
await asyncio.gather(*tasks)
@@ -177,7 +162,7 @@ class MetadataHub(Hub[MetadataClientState]):
room_id=daily_challenge_room.id,
),
)
# CRITICAL FIX: Immediately broadcast the user's online status to all watchers
# This ensures the user appears as "currently online" right after connection
# Similar to the C# implementation's immediate broadcast logic
@@ -185,7 +170,7 @@ class MetadataHub(Hub[MetadataClientState]):
if online_presence_tasks:
await asyncio.gather(*online_presence_tasks)
logger.info(f"[MetadataHub] Broadcasted online status for user {user_id} to watchers")
# Also send the user's own presence update to confirm online status
await self.call_noblock(
client,
@@ -213,9 +198,7 @@ class MetadataHub(Hub[MetadataClientState]):
)
await asyncio.gather(*tasks)
async def UpdateActivity(
self, client: Client, activity: UserActivity | None
) -> None:
async def UpdateActivity(self, client: Client, activity: UserActivity | None) -> None:
user_id = int(client.connection_id)
store = self.get_or_create_state(client)
store.activity = activity
@@ -246,15 +229,16 @@ class MetadataHub(Hub[MetadataClientState]):
]
)
self.add_to_group(client, self.online_presence_watchers_group())
logger.info(f"[MetadataHub] Client {client.connection_id} now watching user presence, sent {len([s for s in self.state.values() if s.pushable])} online users")
logger.info(
f"[MetadataHub] Client {client.connection_id} now watching user presence, "
f"sent {len([s for s in self.state.values() if s.pushable])} online users"
)
async def EndWatchingUserPresence(self, client: Client) -> None:
self.remove_from_group(client, self.online_presence_watchers_group())
async def notify_room_score_processed(self, event: MultiplayerRoomScoreSetEvent):
await self.broadcast_group_call(
self.room_watcher_group(event.room_id), "MultiplayerRoomScoreSet", event
)
await self.broadcast_group_call(self.room_watcher_group(event.room_id), "MultiplayerRoomScoreSet", event)
async def BeginWatchingMultiplayerRoom(self, client: Client, room_id: int):
self.add_to_group(client, self.room_watcher_group(room_id))
@@ -289,9 +273,7 @@ class MetadataHub(Hub[MetadataClientState]):
PlaylistBestScore.room_id == stats.room_id,
PlaylistBestScore.playlist_id == playlist_id,
PlaylistBestScore.score_id > last_processed_score_id,
col(PlaylistBestScore.score).has(
col(Score.passed).is_(True)
),
col(PlaylistBestScore.score).has(col(Score.passed).is_(True)),
)
)
).all()
@@ -311,17 +293,13 @@ class MetadataHub(Hub[MetadataClientState]):
)
totals[bin_index] += 1
item.cumulative_score += sum(
score.total_score for score in scores
)
item.cumulative_score += sum(score.total_score for score in scores)
for j in range(TOTAL_SCORE_DISTRIBUTION_BINS):
item.total_score_distribution[j] += totals.get(j, 0)
if scores:
item.last_processed_score_id = max(
score.score_id for score in scores
)
item.last_processed_score_id = max(score.score_id for score in scores)
async def EndWatchingMultiplayerRoom(self, client: Client, room_id: int):
self.remove_from_group(client, self.room_watcher_group(room_id))

View File

@@ -114,9 +114,7 @@ class MultiplayerEventLogger:
)
await self.log_event(event)
async def game_started(
self, room_id: int, playlist_item_id: int, details: MatchStartedEventDetail
):
async def game_started(self, room_id: int, playlist_item_id: int, details: MatchStartedEventDetail):
event = MultiplayerEvent(
room_id=room_id,
playlist_item_id=playlist_item_id,
@@ -166,6 +164,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
# Use centralized offline status management
from app.service.online_status_manager import online_status_manager
await online_status_manager.set_user_offline(user_id)
if state.room_id != 0 and state.room_id in self.rooms:
@@ -173,9 +172,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
room = server_room.room
user = next((u for u in room.users if u.user_id == user_id), None)
if user is not None:
await self.make_user_leave(
self.get_client_by_id(str(user_id)), server_room, user
)
await self.make_user_leave(self.get_client_by_id(str(user_id)), server_room, user)
async def on_client_connect(self, client: Client) -> None:
"""Track online users when connecting to multiplayer hub"""
@@ -183,6 +180,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
# Use centralized online status management
from app.service.online_status_manager import online_status_manager
await online_status_manager.set_user_online(client.user_id, "multiplayer")
def _ensure_in_room(self, client: Client) -> ServerMultiplayerRoom:
@@ -212,9 +210,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
type=room.settings.match_type,
queue_mode=room.settings.queue_mode,
auto_skip=room.settings.auto_skip,
auto_start_duration=int(
room.settings.auto_start_duration.total_seconds()
),
auto_start_duration=int(room.settings.auto_start_duration.total_seconds()),
host_id=client.user_id,
status=RoomStatus.IDLE,
)
@@ -231,26 +227,20 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
await session.commit()
await session.refresh(channel)
await session.refresh(db_room)
room.channel_id = channel.channel_id # pyright: ignore[reportAttributeAccessIssue]
room.channel_id = channel.channel_id
db_room.channel_id = channel.channel_id
item = room.playlist[0]
item.owner_id = client.user_id
room.room_id = db_room.id
starts_at = db_room.starts_at or datetime.now(UTC)
beatmap_exists = await session.exec(
select(exists().where(col(Beatmap.id) == item.beatmap_id))
)
beatmap_exists = await session.exec(select(exists().where(col(Beatmap.id) == item.beatmap_id)))
if not beatmap_exists.one():
fetcher = await get_fetcher()
try:
await Beatmap.get_or_fetch(
session, fetcher, bid=item.beatmap_id
)
await Beatmap.get_or_fetch(session, fetcher, bid=item.beatmap_id)
except HTTPError:
raise InvokeException(
"Failed to fetch beatmap, please retry later"
)
raise InvokeException("Failed to fetch beatmap, please retry later")
await Playlist.add_to_db(item, room.room_id, session)
server_room = ServerMultiplayerRoom(
@@ -262,9 +252,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
self.rooms[room.room_id] = server_room
await server_room.set_handler()
await self.event_logger.room_created(room.room_id, client.user_id)
return await self.JoinRoomWithPassword(
client, room.room_id, room.settings.password
)
return await self.JoinRoomWithPassword(client, room.room_id, room.settings.password)
async def JoinRoom(self, client: Client, room_id: int):
return self.JoinRoomWithPassword(client, room_id, "")
@@ -350,9 +338,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
beatmap_availability,
)
async def ChangeBeatmapAvailability(
self, client: Client, beatmap_availability: BeatmapAvailability
):
async def ChangeBeatmapAvailability(self, client: Client, beatmap_availability: BeatmapAvailability):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
@@ -371,10 +357,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
logger.info(
f"[MultiplayerHub] {client.user_id} adding "
f"beatmap {item.beatmap_id} to room {room.room_id}"
)
logger.info(f"[MultiplayerHub] {client.user_id} adding beatmap {item.beatmap_id} to room {room.room_id}")
await server_room.queue.add_item(
item,
user,
@@ -388,10 +371,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if user is None:
raise InvokeException("You are not in this room")
logger.info(
f"[MultiplayerHub] {client.user_id} editing "
f"item {item.id} in room {room.room_id}"
)
logger.info(f"[MultiplayerHub] {client.user_id} editing item {item.id} in room {room.room_id}")
await server_room.queue.edit_item(
item,
user,
@@ -405,10 +385,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if user is None:
raise InvokeException("You are not in this room")
logger.info(
f"[MultiplayerHub] {client.user_id} removing "
f"item {item_id} from room {room.room_id}"
)
logger.info(f"[MultiplayerHub] {client.user_id} removing item {item_id} from room {room.room_id}")
await server_room.queue.remove_item(
item_id,
user,
@@ -424,9 +401,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
type=room.room.settings.match_type,
queue_mode=room.room.settings.queue_mode,
auto_skip=room.room.settings.auto_skip,
auto_start_duration=int(
room.room.settings.auto_start_duration.total_seconds()
),
auto_start_duration=int(room.room.settings.auto_start_duration.total_seconds()),
host_id=room.room.host.user_id if room.room.host else None,
)
)
@@ -456,9 +431,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
item_id,
)
async def playlist_changed(
self, room: ServerMultiplayerRoom, item: PlaylistItem, beatmap_changed: bool
):
async def playlist_changed(self, room: ServerMultiplayerRoom, item: PlaylistItem, beatmap_changed: bool):
if item.id == room.room.settings.playlist_item_id:
await self.validate_styles(room)
await self.unready_all_users(room, beatmap_changed)
@@ -468,9 +441,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
item,
)
async def ChangeUserStyle(
self, client: Client, beatmap_id: int | None, ruleset_id: int | None
):
async def ChangeUserStyle(self, client: Client, beatmap_id: int | None, ruleset_id: int | None):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
@@ -496,9 +467,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
)
async with with_db() as session:
try:
beatmap = await Beatmap.get_or_fetch(
session, fetcher, bid=room.queue.current_item.beatmap_id
)
beatmap = await Beatmap.get_or_fetch(session, fetcher, bid=room.queue.current_item.beatmap_id)
except HTTPError:
raise InvokeException("Current item beatmap not found")
beatmap_ids = (
@@ -518,11 +487,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if beatmap_id is not None and user_beatmap is None:
beatmap_id = None
beatmap_ruleset = user_beatmap[1] if user_beatmap else beatmap.mode
if (
ruleset_id is not None
and beatmap_ruleset != GameMode.OSU
and ruleset_id != beatmap_ruleset
):
if ruleset_id is not None and beatmap_ruleset != GameMode.OSU and ruleset_id != beatmap_ruleset:
ruleset_id = None
await self.change_user_style(
beatmap_id,
@@ -532,9 +497,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
)
for user in room.room.users:
is_valid, valid_mods = room.queue.current_item.validate_user_mods(
user, user.mods
)
is_valid, valid_mods = room.queue.current_item.validate_user_mods(user, user.mods)
if not is_valid:
await self.change_user_mods(valid_mods, room, user)
@@ -553,34 +516,24 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
raise InvokeException("Current item does not allow free user styles.")
async with with_db() as session:
item_beatmap = await session.get(
Beatmap, room.queue.current_item.beatmap_id
)
item_beatmap = await session.get(Beatmap, room.queue.current_item.beatmap_id)
if item_beatmap is None:
raise InvokeException("Item beatmap not found")
user_beatmap = (
item_beatmap
if beatmap_id is None
else await session.get(Beatmap, beatmap_id)
)
user_beatmap = item_beatmap if beatmap_id is None else await session.get(Beatmap, beatmap_id)
if user_beatmap is None:
raise InvokeException("Invalid beatmap selected.")
if user_beatmap.beatmapset_id != item_beatmap.beatmapset_id:
raise InvokeException(
"Selected beatmap is not from the same beatmap set."
)
raise InvokeException("Selected beatmap is not from the same beatmap set.")
if (
ruleset_id is not None
and user_beatmap.mode != GameMode.OSU
and ruleset_id != int(user_beatmap.mode)
):
raise InvokeException(
"Selected ruleset is not supported for the given beatmap."
)
raise InvokeException("Selected ruleset is not supported for the given beatmap.")
user.beatmap_id = beatmap_id
user.ruleset_id = ruleset_id
@@ -608,16 +561,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
room: ServerMultiplayerRoom,
user: MultiplayerRoomUser,
):
is_valid, valid_mods = room.queue.current_item.validate_user_mods(
user, new_mods
)
is_valid, valid_mods = room.queue.current_item.validate_user_mods(user, new_mods)
if not is_valid:
incompatible_mods = [
mod["acronym"] for mod in new_mods if mod not in valid_mods
]
raise InvokeException(
f"Incompatible mods were selected: {','.join(incompatible_mods)}"
)
incompatible_mods = [mod["acronym"] for mod in new_mods if mod not in valid_mods]
raise InvokeException(f"Incompatible mods were selected: {','.join(incompatible_mods)}")
if user.mods == valid_mods:
return
@@ -640,16 +587,12 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
match new:
case MultiplayerUserState.IDLE:
if old.is_playing:
raise InvokeException(
"Cannot return to idle without aborting gameplay."
)
raise InvokeException("Cannot return to idle without aborting gameplay.")
case MultiplayerUserState.READY:
if old != MultiplayerUserState.IDLE:
raise InvokeException(f"Cannot change state from {old} to {new}")
if room.queue.current_item.expired:
raise InvokeException(
"Cannot ready up while all items have been played."
)
raise InvokeException("Cannot ready up while all items have been played.")
case MultiplayerUserState.WAITING_FOR_LOAD:
raise InvokeException(f"Cannot change state from {old} to {new}")
case MultiplayerUserState.LOADED:
@@ -688,9 +631,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
MultiplayerRoomState.PLAYING,
)
):
raise InvokeException(
f"Cannot change state from {old} to {new}"
)
raise InvokeException(f"Cannot change state from {old} to {new}")
case _:
raise InvokeException(f"Invalid state transition from {old} to {new}")
@@ -713,9 +654,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if not user.state.is_playing:
return
logger.info(
f"[MultiplayerHub] User {user.user_id} changing state from {user.state} to {state}"
)
logger.info(f"[MultiplayerHub] User {user.user_id} changing state from {user.state} to {state}")
await self.validate_user_stare(
server_room,
@@ -737,10 +676,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
user: MultiplayerRoomUser,
state: MultiplayerUserState,
):
logger.info(
f"[MultiplayerHub] {user.user_id}'s state "
f"changed from {user.state} to {state}"
)
logger.info(f"[MultiplayerHub] {user.user_id}'s state changed from {user.state} to {state}")
user.state = state
await self.broadcast_group_call(
self.group_id(room.room.room_id),
@@ -760,23 +696,17 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
# If switching to spectating during gameplay, immediately request load
if room_state == MultiplayerRoomState.WAITING_FOR_LOAD:
logger.info(
f"[MultiplayerHub] Spectator {user.user_id} joining during load phase"
)
logger.info(f"[MultiplayerHub] Spectator {user.user_id} joining during load phase")
await self.call_noblock(client, "LoadRequested")
elif room_state == MultiplayerRoomState.PLAYING:
logger.info(
f"[MultiplayerHub] Spectator {user.user_id} joining during active gameplay"
)
logger.info(f"[MultiplayerHub] Spectator {user.user_id} joining during active gameplay")
await self.call_noblock(client, "LoadRequested")
# Also sync the spectator with current game state
await self._send_current_gameplay_state_to_spectator(client, room)
async def _send_current_gameplay_state_to_spectator(
self, client: Client, room: ServerMultiplayerRoom
):
async def _send_current_gameplay_state_to_spectator(self, client: Client, room: ServerMultiplayerRoom):
"""
Send current gameplay state information to a newly joined spectator.
This helps spectators sync with ongoing gameplay.
@@ -794,27 +724,20 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
room_user.user_id,
room_user.state,
)
# If the room is in OPEN state but we have users in RESULTS state,
# this means the game just finished and we should send ResultsReady
if (room.room.state == MultiplayerRoomState.OPEN and
any(u.state == MultiplayerUserState.RESULTS for u in room.room.users)):
logger.debug(
f"[MultiplayerHub] Sending ResultsReady to new spectator {client.user_id}"
)
if room.room.state == MultiplayerRoomState.OPEN and any(
u.state == MultiplayerUserState.RESULTS for u in room.room.users
):
logger.debug(f"[MultiplayerHub] Sending ResultsReady to new spectator {client.user_id}")
await self.call_noblock(client, "ResultsReady")
logger.debug(
f"[MultiplayerHub] Sent current gameplay state to spectator {client.user_id}"
)
logger.debug(f"[MultiplayerHub] Sent current gameplay state to spectator {client.user_id}")
except Exception as e:
logger.error(
f"[MultiplayerHub] Failed to send gameplay state to spectator {client.user_id}: {e}"
)
logger.error(f"[MultiplayerHub] Failed to send gameplay state to spectator {client.user_id}: {e}")
async def _send_room_state_to_new_user(
self, client: Client, room: ServerMultiplayerRoom
):
async def _send_room_state_to_new_user(self, client: Client, room: ServerMultiplayerRoom):
"""
Send complete room state to a newly joined user.
Critical for spectators joining ongoing games.
@@ -847,28 +770,21 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
# Critical fix: If room is OPEN but has users in RESULTS state,
# send ResultsReady to new joiners (including spectators)
if (room.room.state == MultiplayerRoomState.OPEN and
any(u.state == MultiplayerUserState.RESULTS for u in room.room.users)):
logger.info(
f"[MultiplayerHub] Sending ResultsReady to newly joined user {client.user_id}"
)
if room.room.state == MultiplayerRoomState.OPEN and any(
u.state == MultiplayerUserState.RESULTS for u in room.room.users
):
logger.info(f"[MultiplayerHub] Sending ResultsReady to newly joined user {client.user_id}")
await self.call_noblock(client, "ResultsReady")
# Critical addition: Send current playing users to SpectatorHub for cross-hub sync
# This ensures spectators can watch multiplayer players properly
await self._sync_with_spectator_hub(client, room)
logger.debug(
f"[MultiplayerHub] Sent complete room state to new user {client.user_id}"
)
logger.debug(f"[MultiplayerHub] Sent complete room state to new user {client.user_id}")
except Exception as e:
logger.error(
f"[MultiplayerHub] Failed to send room state to user {client.user_id}: {e}"
)
logger.error(f"[MultiplayerHub] Failed to send room state to user {client.user_id}: {e}")
async def _sync_with_spectator_hub(
self, client: Client, room: ServerMultiplayerRoom
):
async def _sync_with_spectator_hub(self, client: Client, room: ServerMultiplayerRoom):
"""
Sync with SpectatorHub to ensure cross-hub spectating works properly.
This is crucial for users watching multiplayer players from other pages.
@@ -893,13 +809,16 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
f"[MultiplayerHub] Synced spectator state for user {room_user.user_id} "
f"to new client {client.user_id}"
)
# Critical addition: Notify SpectatorHub about users in RESULTS state
elif room_user.state == MultiplayerUserState.RESULTS:
# Create a synthetic finished state for cross-hub spectating
try:
from app.models.spectator_hub import SpectatedUserState, SpectatorState
from app.models.spectator_hub import (
SpectatedUserState,
SpectatorState,
)
finished_state = SpectatorState(
beatmap_id=room.queue.current_item.beatmap_id,
ruleset_id=room_user.ruleset_id or 0,
@@ -919,9 +838,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
f"to client {client.user_id}"
)
except Exception as e:
logger.debug(
f"[MultiplayerHub] Failed to create synthetic finished state: {e}"
)
logger.debug(f"[MultiplayerHub] Failed to create synthetic finished state: {e}")
except Exception as e:
logger.debug(f"[MultiplayerHub] Failed to sync with SpectatorHub: {e}")
@@ -933,75 +850,55 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if room.room.settings.auto_start_enabled:
if (
not room.queue.current_item.expired
and any(
u.state == MultiplayerUserState.READY
for u in room.room.users
)
and any(u.state == MultiplayerUserState.READY for u in room.room.users)
and not any(
isinstance(countdown, MatchStartCountdown)
for countdown in room.room.active_countdowns
isinstance(countdown, MatchStartCountdown) for countdown in room.room.active_countdowns
)
):
await room.start_countdown(
MatchStartCountdown(
time_remaining=room.room.settings.auto_start_duration
),
MatchStartCountdown(time_remaining=room.room.settings.auto_start_duration),
self.start_match,
)
case MultiplayerRoomState.WAITING_FOR_LOAD:
played_count = len(
[True for user in room.room.users if user.state.is_playing]
)
played_count = len([True for user in room.room.users if user.state.is_playing])
ready_count = len(
[
True
for user in room.room.users
if user.state == MultiplayerUserState.READY_FOR_GAMEPLAY
]
[True for user in room.room.users if user.state == MultiplayerUserState.READY_FOR_GAMEPLAY]
)
if played_count == ready_count:
await self.start_gameplay(room)
case MultiplayerRoomState.PLAYING:
if all(
u.state != MultiplayerUserState.PLAYING for u in room.room.users
):
if all(u.state != MultiplayerUserState.PLAYING for u in room.room.users):
any_user_finished_playing = False
# Handle finished players first
for u in filter(
lambda u: u.state == MultiplayerUserState.FINISHED_PLAY,
room.room.users,
):
any_user_finished_playing = True
await self.change_user_state(
room, u, MultiplayerUserState.RESULTS
)
await self.change_user_state(room, u, MultiplayerUserState.RESULTS)
# Critical fix: Handle spectators who should also see results
# Move spectators to RESULTS state so they can see the results screen
for u in filter(
lambda u: u.state == MultiplayerUserState.SPECTATING,
room.room.users,
):
logger.debug(
f"[MultiplayerHub] Moving spectator {u.user_id} to RESULTS state"
)
await self.change_user_state(
room, u, MultiplayerUserState.RESULTS
)
logger.debug(f"[MultiplayerHub] Moving spectator {u.user_id} to RESULTS state")
await self.change_user_state(room, u, MultiplayerUserState.RESULTS)
await self.change_room_state(room, MultiplayerRoomState.OPEN)
# Send ResultsReady to all room members
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"ResultsReady",
)
# Critical addition: Notify SpectatorHub about finished games
# This ensures cross-hub spectating works properly
await self._notify_spectator_hub_game_ended(room)
if any_user_finished_playing:
await self.event_logger.game_completed(
room.room.room_id,
@@ -1014,13 +911,8 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
)
await room.queue.finish_current_item()
async def change_room_state(
self, room: ServerMultiplayerRoom, state: MultiplayerRoomState
):
logger.debug(
f"[MultiplayerHub] Room {room.room.room_id} state "
f"changed from {room.room.state} to {state}"
)
async def change_room_state(self, room: ServerMultiplayerRoom, state: MultiplayerRoomState):
logger.debug(f"[MultiplayerHub] Room {room.room.room_id} state changed from {room.room.state} to {state}")
room.room.state = state
await self.broadcast_group_call(
self.group_id(room.room.room_id),
@@ -1064,10 +956,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
u
for u in room.room.users
if u.availability.state == DownloadState.LOCALLY_AVAILABLE
and (
u.state == MultiplayerUserState.READY
or u.state == MultiplayerUserState.IDLE
)
and (u.state == MultiplayerUserState.READY or u.state == MultiplayerUserState.IDLE)
]
for u in ready_users:
await self.change_user_state(room, u, MultiplayerUserState.WAITING_FOR_LOAD)
@@ -1080,9 +969,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
"LoadRequested",
)
await room.start_countdown(
ForceGameplayStartCountdown(
time_remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT)
),
ForceGameplayStartCountdown(time_remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT)),
self.start_gameplay,
)
await self.event_logger.game_started(
@@ -1133,9 +1020,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
else:
await room.queue.finish_current_item()
async def send_match_event(
self, room: ServerMultiplayerRoom, event: MatchServerEvent
):
async def send_match_event(self, room: ServerMultiplayerRoom, event: MatchServerEvent):
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"MatchEvent",
@@ -1183,24 +1068,16 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
await self.end_room(room)
return
await self.update_room_state(room)
if (
len(room.room.users) != 0
and room.room.host
and room.room.host.user_id == user.user_id
):
if len(room.room.users) != 0 and room.room.host and room.room.host.user_id == user.user_id:
next_host = room.room.users[0]
await self.set_host(room, next_host)
if kicked:
if client:
await self.call_noblock(client, "UserKicked", user)
await self.broadcast_group_call(
self.group_id(room.room.room_id), "UserKicked", user
)
await self.broadcast_group_call(self.group_id(room.room.room_id), "UserKicked", user)
else:
await self.broadcast_group_call(
self.group_id(room.room.room_id), "UserLeft", user
)
await self.broadcast_group_call(self.group_id(room.room.room_id), "UserLeft", user)
async def end_room(self, room: ServerMultiplayerRoom):
assert room.room.host
@@ -1214,9 +1091,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
type=room.room.settings.match_type,
queue_mode=room.room.settings.queue_mode,
auto_skip=room.room.settings.auto_skip,
auto_start_duration=int(
room.room.settings.auto_start_duration.total_seconds()
),
auto_start_duration=int(room.room.settings.auto_start_duration.total_seconds()),
host_id=room.room.host.user_id,
)
)
@@ -1262,10 +1137,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
)
target_client = self.get_client_by_id(str(user.user_id))
await self.make_user_leave(target_client, server_room, user, kicked=True)
logger.info(
f"[MultiplayerHub] {user.user_id} was kicked from room {room.room_id}"
f"by {client.user_id}"
)
logger.info(f"[MultiplayerHub] {user.user_id} was kicked from room {room.room_id}by {client.user_id}")
async def set_host(self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser):
room.room.host = user
@@ -1289,10 +1161,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
new_host.user_id,
)
await self.set_host(server_room, new_host)
logger.info(
f"[MultiplayerHub] {client.user_id} transferred host to {new_host.user_id}"
f" in room {room.room_id}"
)
logger.info(f"[MultiplayerHub] {client.user_id} transferred host to {new_host.user_id} in room {room.room_id}")
async def AbortGameplay(self, client: Client):
server_room = self._ensure_in_room(client)
@@ -1316,10 +1185,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
room = server_room.room
self._ensure_host(client, server_room)
if (
room.state != MultiplayerRoomState.PLAYING
and room.state != MultiplayerRoomState.WAITING_FOR_LOAD
):
if room.state != MultiplayerRoomState.PLAYING and room.state != MultiplayerRoomState.WAITING_FOR_LOAD:
raise InvokeException("Cannot abort a match that hasn't started.")
await asyncio.gather(
@@ -1335,13 +1201,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
GameplayAbortReason.HOST_ABORTED,
)
await self.update_room_state(server_room)
logger.info(
f"[MultiplayerHub] {client.user_id} aborted match in room {room.room_id}"
)
logger.info(f"[MultiplayerHub] {client.user_id} aborted match in room {room.room_id}")
async def change_user_match_state(
self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser
):
async def change_user_match_state(self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser):
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"MatchUserStateChanged",
@@ -1402,10 +1264,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
)
if countdown is None:
return
if (
isinstance(countdown, MatchStartCountdown)
and room.settings.auto_start_enabled
) or isinstance(
if (isinstance(countdown, MatchStartCountdown) and room.settings.auto_start_enabled) or isinstance(
countdown, (ForceGameplayStartCountdown | ServerShuttingDownCountdown)
):
raise InvokeException("Cannot stop the requested countdown")
@@ -1447,25 +1306,16 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
raise InvokeException("User already invited")
if db_user.is_restricted:
raise InvokeException("User is restricted")
if (
inviter_relationship
and inviter_relationship.type == RelationshipType.BLOCK
):
if inviter_relationship and inviter_relationship.type == RelationshipType.BLOCK:
raise InvokeException("Cannot perform action due to user being blocked")
if (
target_relationship
and target_relationship.type == RelationshipType.BLOCK
):
if target_relationship and target_relationship.type == RelationshipType.BLOCK:
raise InvokeException("Cannot perform action due to user being blocked")
if (
db_user.pm_friends_only
and target_relationship is not None
and target_relationship.type != RelationshipType.FOLLOW
):
raise InvokeException(
"Cannot perform action "
"because user has disabled non-friend communications"
)
raise InvokeException("Cannot perform action because user has disabled non-friend communications")
target_client = self.get_client_by_id(str(user_id))
if target_client is None:
@@ -1478,9 +1328,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
room.settings.password,
)
async def unready_all_users(
self, room: ServerMultiplayerRoom, reset_beatmap_availability: bool
):
async def unready_all_users(self, room: ServerMultiplayerRoom, reset_beatmap_availability: bool):
await asyncio.gather(
*[
self.change_user_state(
@@ -1512,8 +1360,8 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
"""
try:
# Import here to avoid circular imports
from app.signalr.hub import SpectatorHubs
from app.models.spectator_hub import SpectatedUserState, SpectatorState
from app.signalr.hub import SpectatorHubs
# For each user who finished the game, notify SpectatorHub
for room_user in room.room.users:
@@ -1534,13 +1382,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
room_user.user_id,
finished_state,
)
logger.debug(
f"[MultiplayerHub] Notified SpectatorHub that user {room_user.user_id} finished game"
)
logger.debug(f"[MultiplayerHub] Notified SpectatorHub that user {room_user.user_id} finished game")
except Exception as e:
logger.debug(
f"[MultiplayerHub] Failed to notify SpectatorHub about game end: {e}"
)
logger.debug(f"[MultiplayerHub] Failed to notify SpectatorHub about game end: {e}")
# This is not critical, so we don't raise the exception

View File

@@ -31,7 +31,7 @@ from app.models.spectator_hub import (
StoreClientState,
StoreScore,
)
from app.utils import unix_timestamp_to_windows
from app.utils import bg_tasks, unix_timestamp_to_windows
from .hub import Client, Hub
@@ -111,20 +111,14 @@ async def save_replay(
last_time = 0
for frame in frames:
time = round(frame.time)
frame_strs.append(
f"{time - last_time}|{frame.mouse_x or 0.0}"
f"|{frame.mouse_y or 0.0}|{frame.button_state}"
)
frame_strs.append(f"{time - last_time}|{frame.mouse_x or 0.0}|{frame.mouse_y or 0.0}|{frame.button_state}")
last_time = time
frame_strs.append("-12345|0|0|0")
compressed = lzma.compress(
",".join(frame_strs).encode("ascii"), format=lzma.FORMAT_ALONE
)
compressed = lzma.compress(",".join(frame_strs).encode("ascii"), format=lzma.FORMAT_ALONE)
data.extend(struct.pack("<i", len(compressed)))
data.extend(compressed)
data.extend(struct.pack("<q", score.id))
assert score.id
score_info = LegacyReplaySoloScoreInfo(
online_id=score.id,
mods=score.mods,
@@ -135,16 +129,12 @@ async def save_replay(
user_id=score.user_id,
total_score_without_mods=score.total_score_without_mods,
)
compressed = lzma.compress(
json.dumps(score_info).encode(), format=lzma.FORMAT_ALONE
)
compressed = lzma.compress(json.dumps(score_info).encode(), format=lzma.FORMAT_ALONE)
data.extend(struct.pack("<i", len(compressed)))
data.extend(compressed)
storage_service = get_storage_service()
replay_path = (
f"replays/{score.id}_{score.beatmap_id}_{score.user_id}_lazer_replay.osr"
)
replay_path = f"replays/{score.id}_{score.beatmap_id}_{score.user_id}_lazer_replay.osr"
await storage_service.write_file(
replay_path,
bytes(data),
@@ -173,6 +163,7 @@ class SpectatorHub(Hub[StoreClientState]):
# Use centralized offline status management
from app.service.online_status_manager import online_status_manager
await online_status_manager.set_user_offline(user_id)
if state.state:
@@ -181,13 +172,9 @@ class SpectatorHub(Hub[StoreClientState]):
# Critical fix: Notify all watched users that this spectator has disconnected
# This matches the official CleanUpState implementation
for watched_user_id in state.watched_user:
if (
target_client := self.get_client_by_id(str(watched_user_id))
) is not None:
if (target_client := self.get_client_by_id(str(watched_user_id))) is not None:
await self.call_noblock(target_client, "UserEndedWatching", user_id)
logger.debug(
f"[SpectatorHub] Notified {watched_user_id} that {user_id} stopped watching"
)
logger.debug(f"[SpectatorHub] Notified {watched_user_id} that {user_id} stopped watching")
async def on_client_connect(self, client: Client) -> None:
"""
@@ -198,6 +185,7 @@ class SpectatorHub(Hub[StoreClientState]):
# Use centralized online status management
from app.service.online_status_manager import online_status_manager
await online_status_manager.set_user_online(client.user_id, "spectator")
# Send all current player states to the new client
@@ -208,17 +196,13 @@ class SpectatorHub(Hub[StoreClientState]):
active_states.append((user_id, store.state))
if active_states:
logger.debug(
f"[SpectatorHub] Sending {len(active_states)} active player states to {client.user_id}"
)
logger.debug(f"[SpectatorHub] Sending {len(active_states)} active player states to {client.user_id}")
# Send states sequentially to avoid overwhelming the client
for user_id, state in active_states:
try:
await self.call_noblock(client, "UserBeganPlaying", user_id, state)
except Exception as e:
logger.debug(
f"[SpectatorHub] Failed to send state for user {user_id}: {e}"
)
logger.debug(f"[SpectatorHub] Failed to send state for user {user_id}: {e}")
# Also sync with MultiplayerHub for cross-hub spectating
await self._sync_with_multiplayer_hub(client)
@@ -236,10 +220,7 @@ class SpectatorHub(Hub[StoreClientState]):
for room_id, server_room in MultiplayerHubs.rooms.items():
for room_user in server_room.room.users:
# Send state for users who are playing or in results
if (
room_user.state.is_playing
and room_user.user_id not in self.state
):
if room_user.state.is_playing and room_user.user_id not in self.state:
# Create a synthetic SpectatorState for multiplayer players
# This helps with cross-hub spectating
try:
@@ -261,13 +242,12 @@ class SpectatorHub(Hub[StoreClientState]):
f"[SpectatorHub] Sent synthetic multiplayer state for user {room_user.user_id}"
)
except Exception as e:
logger.debug(
f"[SpectatorHub] Failed to create synthetic state: {e}"
)
logger.debug(f"[SpectatorHub] Failed to create synthetic state: {e}")
# Critical addition: Notify about finished players in multiplayer games
elif (
hasattr(room_user.state, 'name') and room_user.state.name == 'RESULTS'
hasattr(room_user.state, "name")
and room_user.state.name == "RESULTS"
and room_user.user_id not in self.state
):
try:
@@ -286,21 +266,15 @@ class SpectatorHub(Hub[StoreClientState]):
room_user.user_id,
finished_state,
)
logger.debug(
f"[SpectatorHub] Sent synthetic finished state for user {room_user.user_id}"
)
logger.debug(f"[SpectatorHub] Sent synthetic finished state for user {room_user.user_id}")
except Exception as e:
logger.debug(
f"[SpectatorHub] Failed to create synthetic finished state: {e}"
)
logger.debug(f"[SpectatorHub] Failed to create synthetic finished state: {e}")
except Exception as e:
logger.debug(f"[SpectatorHub] Failed to sync with MultiplayerHub: {e}")
# This is not critical, so we don't raise the exception
async def BeginPlaySession(
self, client: Client, score_token: int, state: SpectatorState
) -> None:
async def BeginPlaySession(self, client: Client, score_token: int, state: SpectatorState) -> None:
user_id = int(client.connection_id)
store = self.get_or_create_state(client)
if store.state is not None:
@@ -312,14 +286,10 @@ class SpectatorHub(Hub[StoreClientState]):
async with with_db() as session:
async with session.begin():
try:
beatmap = await Beatmap.get_or_fetch(
session, fetcher, bid=state.beatmap_id
)
beatmap = await Beatmap.get_or_fetch(session, fetcher, bid=state.beatmap_id)
except HTTPError:
raise InvokeException(f"Beatmap {state.beatmap_id} not found.")
user = (
await session.exec(select(User).where(User.id == user_id))
).first()
user = (await session.exec(select(User).where(User.id == user_id))).first()
if not user:
return
name = user.username
@@ -342,8 +312,8 @@ class SpectatorHub(Hub[StoreClientState]):
from app.router.v2.stats import add_playing_user
from app.service.online_status_manager import online_status_manager
asyncio.create_task(add_playing_user(user_id))
bg_tasks.add_task(add_playing_user, user_id)
# Critical fix: Maintain metadata online presence during gameplay
# This ensures the user appears online while playing
await online_status_manager.refresh_user_online_status(user_id, "playing")
@@ -367,6 +337,7 @@ class SpectatorHub(Hub[StoreClientState]):
# Critical fix: Refresh online status during active gameplay
# This prevents users from appearing offline while playing
from app.service.online_status_manager import online_status_manager
await online_status_manager.refresh_user_online_status(user_id, "playing_active")
header = frame_data.header
@@ -377,15 +348,13 @@ class SpectatorHub(Hub[StoreClientState]):
score_info.statistics = header.statistics
store.score.replay_frames.extend(frame_data.frames)
await self.broadcast_group_call(
self.group_id(user_id), "UserSentFrames", user_id, frame_data
)
await self.broadcast_group_call(self.group_id(user_id), "UserSentFrames", user_id, frame_data)
async def EndPlaySession(self, client: Client, state: SpectatorState) -> None:
user_id = int(client.connection_id)
store = self.get_or_create_state(client)
score = store.score
# Early return if no active session
if (
score is None
@@ -398,19 +367,19 @@ class SpectatorHub(Hub[StoreClientState]):
try:
# Process score if conditions are met
if (
settings.enable_all_beatmap_leaderboard
and store.beatmap_status.has_leaderboard()
) and any(k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()):
if (settings.enable_all_beatmap_leaderboard and store.beatmap_status.has_leaderboard()) and any(
k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()
):
await self._process_score(store, client)
# End the play session and notify watchers
await self._end_session(user_id, state, store)
# Remove from playing user tracking
from app.router.v2.stats import remove_playing_user
asyncio.create_task(remove_playing_user(user_id))
bg_tasks.add_task(remove_playing_user, user_id)
finally:
# CRITICAL FIX: Always clear state in finally block to ensure cleanup
# This matches the official C# implementation pattern
@@ -439,9 +408,9 @@ class SpectatorHub(Hub[StoreClientState]):
)
result = await session.exec(
select(Score)
.options(joinedload(Score.beatmap)) # pyright: ignore[reportArgumentType]
.options(joinedload(Score.beatmap))
.where(
Score.id == sub_query,
Score.id == sub_query.scalar_subquery(),
Score.user_id == user_id,
)
)
@@ -472,18 +441,12 @@ class SpectatorHub(Hub[StoreClientState]):
frames=store.score.replay_frames,
)
async def _end_session(
self, user_id: int, state: SpectatorState, store: StoreClientState
) -> None:
async def _end_session(self, user_id: int, state: SpectatorState, store: StoreClientState) -> None:
async def _add_failtime():
async with with_db() as session:
failtime = await session.get(FailTime, state.beatmap_id)
total_length = (
await session.exec(
select(Beatmap.total_length).where(
Beatmap.id == state.beatmap_id
)
)
await session.exec(select(Beatmap.total_length).where(Beatmap.id == state.beatmap_id))
).one()
index = clamp(round((exit_time / total_length) * 100), 0, 99)
if failtime is not None:
@@ -495,7 +458,8 @@ class SpectatorHub(Hub[StoreClientState]):
elif state.state == SpectatedUserState.Quit:
resp.exit[index] += 1
new_failtime = FailTime.from_resp(state.beatmap_id, resp) # pyright: ignore[reportArgumentType]
assert state.beatmap_id
new_failtime = FailTime.from_resp(state.beatmap_id, resp)
if failtime is not None:
await session.merge(new_failtime)
else:
@@ -527,9 +491,7 @@ class SpectatorHub(Hub[StoreClientState]):
if state.state == SpectatedUserState.Playing:
state.state = SpectatedUserState.Quit
logger.debug(
f"[SpectatorHub] Changed state from Playing to Quit for user {user_id}"
)
logger.debug(f"[SpectatorHub] Changed state from Playing to Quit for user {user_id}")
# Calculate exit time safely
exit_time = 0
@@ -558,10 +520,7 @@ class SpectatorHub(Hub[StoreClientState]):
self.tasks.add(task)
task.add_done_callback(self.tasks.discard)
logger.info(
f"[SpectatorHub] {user_id} finished playing {state.beatmap_id} "
f"with {state.state}"
)
logger.info(f"[SpectatorHub] {user_id} finished playing {state.beatmap_id} with {state.state}")
await self.broadcast_group_call(
self.group_id(user_id),
"UserFinishedPlaying",
@@ -585,9 +544,7 @@ class SpectatorHub(Hub[StoreClientState]):
# CRITICAL FIX: Only send state if user is actually playing
# Don't send state for finished/quit games
if target_store.state.state == SpectatedUserState.Playing:
logger.debug(
f"[SpectatorHub] {target_id} is currently playing, sending state"
)
logger.debug(f"[SpectatorHub] {target_id} is currently playing, sending state")
# Send current state to the watcher immediately
await self.call_noblock(
client,
@@ -613,25 +570,17 @@ class SpectatorHub(Hub[StoreClientState]):
# Get watcher's username and notify the target user
try:
async with with_db() as session:
username = (
await session.exec(select(User.username).where(User.id == user_id))
).first()
username = (await session.exec(select(User.username).where(User.id == user_id))).first()
if not username:
logger.warning(
f"[SpectatorHub] Could not find username for user {user_id}"
)
logger.warning(f"[SpectatorHub] Could not find username for user {user_id}")
return
# Notify target user that someone started watching
if (target_client := self.get_client_by_id(str(target_id))) is not None:
# Create watcher info array (matches official format)
watcher_info = [[user_id, username]]
await self.call_noblock(
target_client, "UserStartedWatching", watcher_info
)
logger.debug(
f"[SpectatorHub] Notified {target_id} that {username} started watching"
)
await self.call_noblock(target_client, "UserStartedWatching", watcher_info)
logger.debug(f"[SpectatorHub] Notified {target_id} that {username} started watching")
except Exception as e:
logger.error(f"[SpectatorHub] Error notifying target user {target_id}: {e}")
@@ -654,10 +603,6 @@ class SpectatorHub(Hub[StoreClientState]):
# Notify target user that watcher stopped watching
if (target_client := self.get_client_by_id(str(target_id))) is not None:
await self.call_noblock(target_client, "UserEndedWatching", user_id)
logger.debug(
f"[SpectatorHub] Notified {target_id} that {user_id} stopped watching"
)
logger.debug(f"[SpectatorHub] Notified {target_id} that {user_id} stopped watching")
else:
logger.debug(
f"[SpectatorHub] Target user {target_id} not found for end watching notification"
)
logger.debug(f"[SpectatorHub] Target user {target_id} not found for end watching notification")

View File

@@ -100,10 +100,7 @@ class MsgpackProtocol:
elif issubclass(typ, datetime.timedelta):
return int(v.total_seconds() * 10_000_000)
elif isinstance(v, dict):
return {
cls.serialize_msgpack(k): cls.serialize_msgpack(value)
for k, value in v.items()
}
return {cls.serialize_msgpack(k): cls.serialize_msgpack(value) for k, value in v.items()}
elif issubclass(typ, Enum):
list_ = list(typ)
return list_.index(v) if v in list_ else v.value
@@ -113,9 +110,7 @@ class MsgpackProtocol:
def serialize_to_list(cls, value: BaseModel) -> list[Any]:
values = []
for field, info in value.__class__.model_fields.items():
metadata = next(
(m for m in info.metadata if isinstance(m, SignalRMeta)), None
)
metadata = next((m for m in info.metadata if isinstance(m, SignalRMeta)), None)
if metadata and metadata.member_ignore:
continue
values.append(cls.serialize_msgpack(v=getattr(value, field)))
@@ -130,9 +125,7 @@ class MsgpackProtocol:
d = {}
i = 0
for field, info in typ.model_fields.items():
metadata = next(
(m for m in info.metadata if isinstance(m, SignalRMeta)), None
)
metadata = next((m for m in info.metadata if isinstance(m, SignalRMeta)), None)
if metadata and metadata.member_ignore:
continue
anno = info.annotation
@@ -224,10 +217,7 @@ class MsgpackProtocol:
return list_[v] if isinstance(v, int) and 0 <= v < len(list_) else typ(v)
elif get_origin(typ) is dict:
return {
cls.validate_object(k, get_args(typ)[0]): cls.validate_object(
v, get_args(typ)[1]
)
for k, v in v.items()
cls.validate_object(k, get_args(typ)[0]): cls.validate_object(v, get_args(typ)[1]) for k, v in v.items()
}
elif (origin := get_origin(typ)) is Union or origin is UnionType:
args = get_args(typ)
@@ -242,13 +232,8 @@ class MsgpackProtocol:
# except `X (Other Type) | None`
if NoneType in args and v is None:
return None
if not all(
issubclass(arg, SignalRUnionMessage) or arg is NoneType for arg in args
):
raise ValueError(
f"Cannot validate {v} to {typ}, "
"only SignalRUnionMessage subclasses are supported"
)
if not all(issubclass(arg, SignalRUnionMessage) or arg is NoneType for arg in args):
raise ValueError(f"Cannot validate {v} to {typ}, only SignalRUnionMessage subclasses are supported")
union_type = v[0]
for arg in args:
assert issubclass(arg, SignalRUnionMessage)
@@ -267,9 +252,7 @@ class MsgpackProtocol:
]
)
if packet.arguments is not None:
payload.append(
[MsgpackProtocol.serialize_msgpack(arg) for arg in packet.arguments]
)
payload.append([MsgpackProtocol.serialize_msgpack(arg) for arg in packet.arguments])
if packet.stream_ids is not None:
payload.append(packet.stream_ids)
elif isinstance(packet, CompletionPacket):
@@ -282,9 +265,7 @@ class MsgpackProtocol:
[
packet.invocation_id,
result_kind,
packet.error
or MsgpackProtocol.serialize_msgpack(packet.result)
or None,
packet.error or MsgpackProtocol.serialize_msgpack(packet.result) or None,
]
)
elif isinstance(packet, ClosePacket):
@@ -307,10 +288,7 @@ class JSONProtocol:
if issubclass(typ, BaseModel):
return cls.serialize_model(v, in_union)
elif isinstance(v, dict):
return {
cls.serialize_to_json(k, True): cls.serialize_to_json(value)
for k, value in v.items()
}
return {cls.serialize_to_json(k, True): cls.serialize_to_json(value) for k, value in v.items()}
elif isinstance(v, list):
return [cls.serialize_to_json(item) for item in v]
elif isinstance(v, datetime.datetime):
@@ -333,9 +311,7 @@ class JSONProtocol:
d = {}
is_union = issubclass(v.__class__, SignalRUnionMessage)
for field, info in v.__class__.model_fields.items():
metadata = next(
(m for m in info.metadata if isinstance(m, SignalRMeta)), None
)
metadata = next((m for m in info.metadata if isinstance(m, SignalRMeta)), None)
if metadata and metadata.json_ignore:
continue
name = (
@@ -358,14 +334,10 @@ class JSONProtocol:
return d
@staticmethod
def process_object(
v: Any, typ: type[BaseModel], from_union: bool = False
) -> dict[str, Any]:
def process_object(v: Any, typ: type[BaseModel], from_union: bool = False) -> dict[str, Any]:
d = {}
for field, info in typ.model_fields.items():
metadata = next(
(m for m in info.metadata if isinstance(m, SignalRMeta)), None
)
metadata = next((m for m in info.metadata if isinstance(m, SignalRMeta)), None)
if metadata and metadata.json_ignore:
continue
name = (
@@ -435,9 +407,7 @@ class JSONProtocol:
# d.hh:mm:ss
parts = v.split(":")
if len(parts) == 3:
return datetime.timedelta(
hours=int(parts[0]), minutes=int(parts[1]), seconds=int(parts[2])
)
return datetime.timedelta(hours=int(parts[0]), minutes=int(parts[1]), seconds=int(parts[2]))
elif len(parts) == 2:
return datetime.timedelta(minutes=int(parts[0]), seconds=int(parts[1]))
elif len(parts) == 1:
@@ -449,10 +419,7 @@ class JSONProtocol:
return list_[v] if isinstance(v, int) and 0 <= v < len(list_) else typ(v)
elif get_origin(typ) is dict:
return {
cls.validate_object(k, get_args(typ)[0]): cls.validate_object(
v, get_args(typ)[1]
)
for k, v in v.items()
cls.validate_object(k, get_args(typ)[0]): cls.validate_object(v, get_args(typ)[1]) for k, v in v.items()
}
elif (origin := get_origin(typ)) is Union or origin is UnionType:
args = get_args(typ)
@@ -467,13 +434,8 @@ class JSONProtocol:
# except `X (Other Type) | None`
if NoneType in args and v is None:
return None
if not all(
issubclass(arg, SignalRUnionMessage) or arg is NoneType for arg in args
):
raise ValueError(
f"Cannot validate {v} to {typ}, "
"only SignalRUnionMessage subclasses are supported"
)
if not all(issubclass(arg, SignalRUnionMessage) or arg is NoneType for arg in args):
raise ValueError(f"Cannot validate {v} to {typ}, only SignalRUnionMessage subclasses are supported")
# https://github.com/ppy/osu/blob/98acd9/osu.Game/Online/SignalRDerivedTypeWorkaroundJsonConverter.cs
union_type = v["$dtype"]
for arg in args:
@@ -498,9 +460,7 @@ class JSONProtocol:
if packet.invocation_id is not None:
payload["invocationId"] = packet.invocation_id
if packet.arguments is not None:
payload["arguments"] = [
JSONProtocol.serialize_to_json(arg) for arg in packet.arguments
]
payload["arguments"] = [JSONProtocol.serialize_to_json(arg) for arg in packet.arguments]
if packet.stream_ids is not None:
payload["streamIds"] = packet.stream_ids
elif isinstance(packet, CompletionPacket):

View File

@@ -56,11 +56,9 @@ async def connect(
return
try:
async for session in factory():
if (
user := await get_current_user(
session, SecurityScopes(scopes=["*"]), token_pw=token
)
) is None or str(user.id) != user_id:
if (user := await get_current_user(session, SecurityScopes(scopes=["*"]), token_pw=token)) is None or str(
user.id
) != user_id:
await websocket.close(code=1008)
return
except HTTPException:

View File

@@ -19,9 +19,7 @@ class ResultStore:
self._seq = (self._seq + 1) % sys.maxsize
return str(s)
def add_result(
self, invocation_id: str, result: Any, error: str | None = None
) -> None:
def add_result(self, invocation_id: str, result: Any, error: str | None = None) -> None:
if isinstance(invocation_id, str) and invocation_id.isdecimal():
if future := self._futures.get(invocation_id):
future.set_result((result, error))

View File

@@ -13,9 +13,7 @@ if sys.version_info < (3, 12, 4):
else:
def evaluate_forwardref(type_: ForwardRef, globalns: Any, localns: Any) -> Any:
return cast(Any, type_)._evaluate(
globalns, localns, type_params=(), recursive_guard=set()
)
return cast(Any, type_)._evaluate(globalns, localns, type_params=(), recursive_guard=set())
def get_annotation(param: inspect.Parameter, globalns: dict[str, Any]) -> Any: