from __future__ import annotations import asyncio import json import lzma import struct import time from typing import override from app.calculator import clamp from app.config import settings from app.database import Beatmap, User from app.database.failtime import FailTime, FailTimeResp from app.database.score import Score from app.database.score_token import ScoreToken from app.database.statistics import UserStatistics from app.dependencies.database import get_redis, with_db from app.dependencies.fetcher import get_fetcher from app.dependencies.storage import get_storage_service from app.exception import InvokeException from app.log import logger from app.models.mods import APIMod, mods_to_int from app.models.score import GameMode, LegacyReplaySoloScoreInfo, ScoreStatistics from app.models.spectator_hub import ( APIUser, FrameDataBundle, LegacyReplayFrame, ScoreInfo, SpectatedUserState, SpectatorState, StoreClientState, StoreScore, ) from app.utils import unix_timestamp_to_windows from .hub import Client, Hub from httpx import HTTPError from sqlalchemy.orm import joinedload from sqlmodel import select READ_SCORE_TIMEOUT = 30 REPLAY_LATEST_VER = 30000016 def encode_uleb128(num: int) -> bytes | bytearray: if num == 0: return b"\x00" ret = bytearray() while num != 0: ret.append(num & 0x7F) num >>= 7 if num != 0: ret[-1] |= 0x80 return ret def encode_string(s: str) -> bytes: """Write `s` into bytes (ULEB128 & string).""" if s: encoded = s.encode() ret = b"\x0b" + encode_uleb128(len(encoded)) + encoded else: ret = b"\x00" return ret async def save_replay( ruleset_id: int, md5: str, username: str, score: Score, statistics: ScoreStatistics, maximum_statistics: ScoreStatistics, frames: list[LegacyReplayFrame], ) -> None: data = bytearray() data.extend(struct.pack(" str: return f"watch:{user_id}" @override def create_state(self, client: Client) -> StoreClientState: return StoreClientState( connection_id=client.connection_id, connection_token=client.connection_token, ) @override async def _clean_state(self, state: StoreClientState) -> None: """ Enhanced cleanup based on official osu-server-spectator implementation. Properly notifies watched users when spectator disconnects. """ user_id = int(state.connection_id) if state.state: await self._end_session(user_id, state.state, state) # 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: await self.call_noblock(target_client, "UserEndedWatching", user_id) logger.debug(f"[SpectatorHub] Notified {watched_user_id} that {user_id} stopped watching") async def on_client_connect(self, client: Client) -> None: """ Enhanced connection handling based on official implementation. Send all active player states to newly connected clients. """ logger.info(f"[SpectatorHub] Client {client.user_id} connected") # Send all current player states to the new client # This matches the official OnConnectedAsync behavior active_states = [] for user_id, store in self.state.items(): if store.state is not None: 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}") # 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}") # Also sync with MultiplayerHub for cross-hub spectating await self._sync_with_multiplayer_hub(client) async def _sync_with_multiplayer_hub(self, client: Client) -> None: """ Sync with MultiplayerHub to get active multiplayer game states. This ensures spectators can see multiplayer games from other pages. """ try: # Import here to avoid circular imports from app.signalr.hub import MultiplayerHubs # Check all active multiplayer rooms for playing users 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: # Create a synthetic SpectatorState for multiplayer players # This helps with cross-hub spectating try: synthetic_state = SpectatorState( beatmap_id=server_room.queue.current_item.beatmap_id, ruleset_id=room_user.ruleset_id or 0, # Default to osu! mods=room_user.mods, state=SpectatedUserState.Playing, maximum_statistics={}, ) await self.call_noblock( client, "UserBeganPlaying", room_user.user_id, synthetic_state, ) logger.debug( 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}") # Critical addition: Notify about finished players in multiplayer games elif ( hasattr(room_user.state, "name") and room_user.state.name == "RESULTS" and room_user.user_id not in self.state ): try: # Create a synthetic finished state finished_state = SpectatorState( beatmap_id=server_room.queue.current_item.beatmap_id, ruleset_id=room_user.ruleset_id or 0, mods=room_user.mods, state=SpectatedUserState.Passed, # Assume passed for results maximum_statistics={}, ) await self.call_noblock( client, "UserFinishedPlaying", room_user.user_id, finished_state, ) 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}") 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: user_id = int(client.connection_id) store = self.get_or_create_state(client) if store.state is not None: logger.warning(f"[SpectatorHub] User {user_id} began new session without ending previous one; cleaning up") try: await self._end_session(user_id, store.state, store) finally: store.state = None store.beatmap_status = None store.checksum = None store.ruleset_id = None store.score_token = None store.score = None if state.beatmap_id is None or state.ruleset_id is None: return fetcher = await get_fetcher() async with with_db() as session: async with session.begin(): try: 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() if not user: return name = user.username store.state = state store.beatmap_status = beatmap.beatmap_status store.checksum = beatmap.checksum store.ruleset_id = state.ruleset_id store.score_token = score_token store.score = StoreScore( score_info=ScoreInfo( mods=state.mods, user=APIUser(id=user_id, name=name), ruleset=state.ruleset_id, maximum_statistics=state.maximum_statistics, ) ) logger.info(f"[SpectatorHub] {client.user_id} began playing {state.beatmap_id}") await self.broadcast_group_call( self.group_id(user_id), "UserBeganPlaying", user_id, state, ) async def SendFrameData(self, client: Client, frame_data: FrameDataBundle) -> None: user_id = int(client.connection_id) store = self.get_or_create_state(client) if store.state is None or store.score is None: return header = frame_data.header score_info = store.score.score_info score_info.accuracy = header.accuracy score_info.combo = header.combo score_info.max_combo = header.max_combo 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) 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 or store.score_token is None or store.beatmap_status is None or store.state is None or store.score is None ): return 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() ): await self._process_score(store, client) # End the play session and notify watchers await self._end_session(user_id, state, store) finally: # CRITICAL FIX: Always clear state in finally block to ensure cleanup # This matches the official C# implementation pattern store.state = None store.beatmap_status = None store.checksum = None store.ruleset_id = None store.score_token = None store.score = None logger.info(f"[SpectatorHub] Cleared all session state for user {user_id}") async def _process_score(self, store: StoreClientState, client: Client) -> None: user_id = int(client.connection_id) assert store.state is not None assert store.score_token is not None assert store.checksum is not None assert store.ruleset_id is not None assert store.score is not None async with with_db() as session: async with session: start_time = time.time() score_record = None while time.time() - start_time < READ_SCORE_TIMEOUT: sub_query = select(ScoreToken.score_id).where( ScoreToken.id == store.score_token, ) result = await session.exec( select(Score) .options(joinedload(Score.beatmap)) .where( Score.id == sub_query.scalar_subquery(), Score.user_id == user_id, ) ) score_record = result.first() if score_record: break if not score_record: return if not score_record.passed: return await self.call_noblock( client, "UserScoreProcessed", user_id, score_record.id, ) # save replay score_record.has_replay = True await session.commit() await session.refresh(score_record) await save_replay( ruleset_id=store.ruleset_id, md5=store.checksum, username=store.score.score_info.user.name, score=score_record, statistics=store.score.score_info.statistics, maximum_statistics=store.score.score_info.maximum_statistics, frames=store.score.replay_frames, ) 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)) ).one() index = clamp(round((exit_time / total_length) * 100), 0, 99) if failtime is not None: resp = FailTimeResp.from_db(failtime) else: resp = FailTimeResp() if state.state == SpectatedUserState.Failed: resp.fail[index] += 1 elif state.state == SpectatedUserState.Quit: resp.exit[index] += 1 assert state.beatmap_id new_failtime = FailTime.from_resp(state.beatmap_id, resp) if failtime is not None: await session.merge(new_failtime) else: session.add(new_failtime) await session.commit() async def _edit_playtime(token: int, ruleset_id: int, mods: list[APIMod]): redis = get_redis() key = f"score:existed_time:{token}" messages = await redis.xrange(key, min="-", max="+", count=1) if not messages: return before_time = int(messages[0][1]["time"]) await redis.delete(key) async with with_db() as session: gamemode = GameMode.from_int(ruleset_id).to_special_mode(mods) statistics = ( await session.exec( select(UserStatistics).where( UserStatistics.user_id == user_id, UserStatistics.mode == gamemode, ) ) ).first() if statistics is None: return statistics.play_time -= before_time statistics.play_time += round(min(before_time, exit_time)) if state.state == SpectatedUserState.Playing: state.state = SpectatedUserState.Quit logger.debug(f"[SpectatorHub] Changed state from Playing to Quit for user {user_id}") # Calculate exit time safely exit_time = 0 if store.score and store.score.replay_frames: exit_time = max(frame.time for frame in store.score.replay_frames) // 1000 # Background task for playtime editing - only if we have valid data if store.score_token and store.ruleset_id and store.score: task = asyncio.create_task( _edit_playtime( store.score_token, store.ruleset_id, store.score.score_info.mods, ) ) self.tasks.add(task) task.add_done_callback(self.tasks.discard) # Background task for failtime tracking - only for failed/quit states with valid data if ( state.beatmap_id is not None and exit_time > 0 and state.state in (SpectatedUserState.Failed, SpectatedUserState.Quit) ): task = asyncio.create_task(_add_failtime()) self.tasks.add(task) task.add_done_callback(self.tasks.discard) 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", user_id, state, ) async def StartWatchingUser(self, client: Client, target_id: int) -> None: """ Enhanced StartWatchingUser based on official osu-server-spectator implementation. Properly handles state synchronization and watcher notifications. """ user_id = int(client.connection_id) logger.info(f"[SpectatorHub] {user_id} started watching {target_id}") try: # Get target user's current state if it exists target_store = self.state.get(target_id) if not target_store or not target_store.state: logger.info(f"[SpectatorHub] Rejecting watch request for {target_id}: user not playing") raise InvokeException("Target user is not currently playing") if target_store.state.state != SpectatedUserState.Playing: logger.info( f"[SpectatorHub] Rejecting watch request for {target_id}: state is {target_store.state.state}" ) raise InvokeException("Target user is not currently playing") logger.debug(f"[SpectatorHub] {target_id} is currently playing, sending state") # Send current state to the watcher immediately await self.call_noblock( client, "UserBeganPlaying", target_id, target_store.state, ) except InvokeException: # Re-raise to inform caller without adding to group raise except Exception as e: # User isn't tracked or error occurred - this is not critical logger.debug(f"[SpectatorHub] Could not get state for {target_id}: {e}") raise InvokeException("Target user is not currently playing") from e # Add watcher to our tracked users only after validation store = self.get_or_create_state(client) store.watched_user.add(target_id) # Add to SignalR group for this target user self.add_to_group(client, self.group_id(target_id)) # 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() if not username: 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") except Exception as e: logger.error(f"[SpectatorHub] Error notifying target user {target_id}: {e}") async def EndWatchingUser(self, client: Client, target_id: int) -> None: """ Enhanced EndWatchingUser based on official osu-server-spectator implementation. Properly cleans up watcher state and notifies target user. """ user_id = int(client.connection_id) logger.info(f"[SpectatorHub] {user_id} ended watching {target_id}") # Remove from SignalR group self.remove_from_group(client, self.group_id(target_id)) # Remove from our tracked watched users store = self.get_or_create_state(client) store.watched_user.discard(target_id) # 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") else: logger.debug(f"[SpectatorHub] Target user {target_id} not found for end watching notification")