/
/
/
1"""
2Music Quiz Plugin Provider for Music Assistant.
3
4Provides the backend game engine for multiplayer music quiz games. Guests
5join with a QR code on their own device and play the selected quiz type:
6guess-the-song uses multiple-choice answers, while Music Timeline uses a shared
7chronological timeline with optional artist and title bonuses. Trivia uses
8AI-worded multiple-choice questions grounded in selected library metadata.
9
10Playback is hosted by a SharedPlaybackSession in one of two modes selected for
11each game:
12
13- venue: a selected real player plays the rounds out loud; guests may
14 optionally listen in on their own device when the player supports grouping.
15- remote: a hidden virtual player leads the rounds and every guest listens
16 on their own device (silent-disco style).
17
18Game state changes are pushed to all connected clients as PROVIDER_EVENT
19events with ``object_id`` set to this provider's instance_id. Event payload
20contract (all payloads are JSON objects)::
21
22 {"event": "game_updated", "state": {<public game state>}}
23 {"event": "game_removed"}
24
25The public game state is guest-safe by construction. Common state contains:
26
27- always: ``phase`` (lobby/answering/reveal/finished), ``name``, ``quiz_type``,
28 ``answer_type``, ``mode`` (venue/remote), ``round_count``, ``answer_duration``,
29 ``include_similar_music`` and public player progress. ``auto_start_at`` contains
30 the authoritative replay deadline while a lobby countdown is active.
31 ``preparing`` is true while a reset loads the sources and first round of the
32 next run. ``join_url`` is included once resolved and omitted until then.
33 Private player IDs never appear in broadcasts. Trivia additionally
34 exposes its canonical ``language`` and ``play_reveal_audio`` setting.
35- answering rounds expose common timing and question fields plus a strategy
36 fragment. Multiple-choice exposes opaque ``suggestions``. Timeline exposes
37 the revealed shared ``timeline`` and redacted ``bonus_definitions``; the
38 current song, year, correct placement and bonus answers remain protected.
39- reveal/finished rounds additionally expose common ``answer_label``,
40 ``track_uri``, ``image_url``, ``duration``, ``audio_started_at`` and
41 ``ended_at`` fields. ``audio_started_at`` is when the round's track became
42 audible: it trails ``started_at`` by the playback startup latency, so clients
43 following the audio (e.g. synced lyrics) should prefer it over ``started_at``.
44 The answer strategy adds the revealed correct option or timeline entry and
45 answer-specific player results. ``auto_advance_at`` contains the authoritative
46 next-round deadline when the backend scheduled automatic advancement.
47
48Guests authenticate through the standard guest access flow (join code in
49the join URL) and register themselves as quiz player via ``music_quiz/join``,
50which returns their private ``player_id``. That ID acts as the player's
51credential for ``music_quiz/submit_answer``, ``music_quiz/ready``,
52``music_quiz/heartbeat`` and ``music_quiz/state`` and must be kept client-side.
53The compatibility ``music_quiz/answer`` command remains multiple-choice only.
54"""
55
56from __future__ import annotations
57
58import asyncio
59import secrets
60import time
61from collections.abc import Callable, Coroutine, Iterable, Iterator
62from contextlib import contextmanager, suppress
63from dataclasses import dataclass
64from typing import TYPE_CHECKING, Any, TypedDict, cast
65
66from music_assistant_models.auth import Scope
67from music_assistant_models.config_entries import (
68 ConfigEntry,
69 ProviderConfig,
70)
71from music_assistant_models.enums import ConfigEntryType, ProviderFeature, QueueOption
72from music_assistant_models.errors import (
73 AudioError,
74 InvalidDataError,
75 MediaNotFoundError,
76 MusicAssistantError,
77 SetupFailedError,
78)
79from music_assistant_models.media_items import Track
80
81from music_assistant.constants import ATTR_ANNOUNCEMENT_IN_PROGRESS
82from music_assistant.controllers.webserver.helpers.auth_middleware import (
83 current_user,
84 impersonated_user,
85)
86from music_assistant.helpers import guest_access
87from music_assistant.helpers.config_entries import PLAYBACK_TARGET_TYPES
88from music_assistant.helpers.json import SerializableType
89from music_assistant.helpers.plugin_engines import (
90 create_ai_engine_config_entries,
91 get_ai_engines,
92 select_ai_engine,
93)
94from music_assistant.helpers.shared_playback import (
95 SENDSPIN_DOMAIN,
96 SharedPlaybackMode,
97 SharedPlaybackSession,
98 is_remote_session_host,
99)
100from music_assistant.helpers.uri import parse_uri
101from music_assistant.models.plugin import PluginProvider
102from music_assistant.providers.music_quiz.answer_types import get_answer_type
103from music_assistant.providers.music_quiz.answer_types.base import (
104 QuizAnswerSubmission,
105 QuizAnswerSubmissionPayload,
106 QuizAnswerType,
107)
108from music_assistant.providers.music_quiz.answer_types.multiple_choice import (
109 MultipleChoiceSubmission,
110)
111from music_assistant.providers.music_quiz.errors import (
112 TRANSLATION_OWNER,
113 MusicQuizGameActiveError,
114 MusicQuizGameFullError,
115 MusicQuizInvalidAnswerError,
116 MusicQuizNoGameError,
117 MusicQuizNoPlaybackTargetError,
118 MusicQuizUnknownPlayerError,
119 MusicQuizWrongPhaseError,
120)
121from music_assistant.providers.music_quiz.game import (
122 add_player,
123 all_active_players_complete,
124 are_active_players_ready,
125 finish_game,
126 get_current_round,
127 mark_player_ready,
128 reset_game,
129 reveal_round,
130 start_round,
131)
132from music_assistant.providers.music_quiz.game import (
133 remove_player as remove_game_player,
134)
135from music_assistant.providers.music_quiz.game import (
136 submit_answer as submit_game_answer,
137)
138from music_assistant.providers.music_quiz.models import (
139 DEFAULT_TRIVIA_LANGUAGE,
140 MusicQuizAnswerType,
141 MusicQuizConfig,
142 MusicQuizDifficulty,
143 MusicQuizGame,
144 MusicQuizPhase,
145 MusicQuizPlaybackOptions,
146 MusicQuizPlaybackSummary,
147 MusicQuizPlayer,
148 MusicQuizRound,
149 MusicQuizSource,
150 MusicQuizVenuePlayerOption,
151 TimelineBonusMode,
152)
153from music_assistant.providers.music_quiz.quiz_types import (
154 get_available_quiz_types,
155 get_quiz_type,
156)
157from music_assistant.providers.music_quiz.quiz_types.base import (
158 QuizType,
159 is_supported_source,
160)
161
162if TYPE_CHECKING:
163 from music_assistant_models.provider import ProviderManifest
164
165 from music_assistant.mass import MusicAssistant
166 from music_assistant.models import ProviderInstanceType
167 from music_assistant.models.player import Player
168
169SUPPORTED_FEATURES: set[ProviderFeature] = set()
170
171_ApiHandler = Callable[..., Coroutine[Any, Any, Any]]
172
173CONF_MODE = "mode"
174CONF_PLAYER = "player"
175CONF_PLAYER_AUTO = "__auto__"
176CONF_USE_AI_DISTRACTORS = "use_ai_distractors"
177CONF_AI_ENGINE = "ai_engine"
178
179PLAYBACK_PREFERENCE_CACHE_KEY = "playback_preference"
180PLAYBACK_PREFERENCE_CACHE_EXPIRATION = 86400 * 3650
181
182MUSIC_QUIZ_GUEST_USER = "music_quiz_guest"
183MUSIC_QUIZ_GUEST_DISPLAY_NAME = "Music Quiz Guest"
184
185# defence-in-depth cap: a leaked join URL must not be able to flood a game
186MAX_PLAYER_COUNT = 100
187# the joined name is broadcast to every client on each state update; bound it
188MAX_PLAYER_NAME_LENGTH = 40
189PLAYER_RECONNECT_GRACE_SECONDS = 60.0
190MAX_PLAYBACK_ATTEMPTS = 5
191REPLAY_AUTO_START_SECONDS = 30
192
193# minimum time players get to see the reveal/scoreboard before the game
194# advances, even when the round track has (almost) finished playing
195MIN_REVEAL_SECONDS = 10.0
196
197# a track is not audible the instant playback is commanded: the stream still has to be
198# resolved, encoded and buffered by the receiver. ASSUMED_AUDIO_START_LATENCY is what we
199# assume when the player never reported a real position, while the MIN/MAX pair bounds a
200# player-reported start relative to the play command so a stale report is discarded.
201ASSUMED_AUDIO_START_LATENCY = 1.0
202MIN_REPORTED_AUDIO_START_LATENCY = -1.0
203MAX_REPORTED_AUDIO_START_LATENCY = 10.0
204
205
206class _PlaybackPreference(TypedDict):
207 """Persisted playback preference for this provider instance."""
208
209 playback_mode: str
210 venue_player_id: str | None
211
212
213@dataclass(frozen=True, slots=True)
214class _PlaybackDefaults:
215 """Resolved playback defaults and the venue preference they came from."""
216
217 options: MusicQuizPlaybackOptions
218 stored_venue_player_id: str | None
219
220
221async def setup(
222 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
223) -> ProviderInstanceType:
224 """Initialize provider(instance) with given configuration."""
225 return MusicQuizPlugin(mass, manifest, config, SUPPORTED_FEATURES)
226
227
228class MusicQuizPlugin(PluginProvider):
229 """Music Quiz plugin provider for Music Assistant."""
230
231 def __init__(
232 self,
233 mass: MusicAssistant,
234 manifest: ProviderManifest,
235 config: ProviderConfig,
236 supported_features: set[ProviderFeature],
237 ) -> None:
238 """Initialize the Music Quiz plugin."""
239 super().__init__(mass, manifest, config, supported_features)
240 self._game: MusicQuizGame | None = None
241 self._quiz_type: QuizType | None = None
242 self._answer_type: QuizAnswerType | None = None
243 self._game_lock = asyncio.Lock()
244 self._game_generation = 0
245 self._playback_session: SharedPlaybackSession | None = None
246 self._playback_lock = asyncio.Lock()
247 self._next_round_task: asyncio.Task[MusicQuizRound] | None = None
248 self._warm_next_track_task: asyncio.Task[None] | None = None
249 self._reveal_playback_task: asyncio.Task[None] | None = None
250 self._unregister_handles: list[Callable[[], None]] = []
251 # public state is broadcast from sync code paths that cannot await the join URL,
252 # and the guest-scope getter must not mint a join code as a side effect
253 self._join_url: str | None = None
254
255 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
256 """Return Config entries to configure this provider."""
257 ai_available = bool(await get_ai_engines(self.mass))
258 return (
259 ConfigEntry(
260 key=CONF_USE_AI_DISTRACTORS,
261 type=ConfigEntryType.BOOLEAN,
262 required=False,
263 default_value=False,
264 read_only=not ai_available,
265 ),
266 # ungated: the Trivia quiz type needs an AI engine regardless of the
267 # distractor toggle, so pinning one must stay reachable with it off
268 *await create_ai_engine_config_entries(self.mass, CONF_AI_ENGINE),
269 )
270
271 async def loaded_in_mass(self) -> None:
272 """Call after the provider has been loaded."""
273 await self._migrate_legacy_playback_preference()
274 host_commands: tuple[tuple[str, _ApiHandler], ...] = (
275 ("music_quiz/available_quiz_types", self.available_quiz_types),
276 ("music_quiz/playback_options", self.playback_options),
277 ("music_quiz/create", self.create_game),
278 ("music_quiz/get", self.get_game),
279 ("music_quiz/start", self.start_game),
280 ("music_quiz/reveal", self.reveal),
281 ("music_quiz/next", self.next_round),
282 ("music_quiz/reset", self.reset),
283 ("music_quiz/delete", self.delete_game),
284 )
285 for command, handler in host_commands:
286 self._unregister_handles.append(
287 self.mass.register_api_command(command, handler, required_scope=Scope.USERS_INVITE)
288 )
289 # Participant game commands are available to any authenticated user.
290 guest_commands: tuple[tuple[str, _ApiHandler], ...] = (
291 ("music_quiz/info", self.get_game_info),
292 ("music_quiz/join", self.join_game),
293 ("music_quiz/state", self.get_player_state),
294 ("music_quiz/public_state", self.get_public_state),
295 ("music_quiz/heartbeat", self.heartbeat),
296 ("music_quiz/submit_answer", self.submit_answer),
297 ("music_quiz/answer", self.answer),
298 ("music_quiz/ready", self.ready),
299 )
300 for command, handler in guest_commands:
301 self._unregister_handles.append(self.mass.register_api_command(command, handler))
302 # listen-in commands control playback on the guest's own device
303 listen_in_commands: tuple[tuple[str, _ApiHandler], ...] = (
304 ("music_quiz/listen_in", self.listen_in),
305 ("music_quiz/stop_listen_in", self.stop_listen_in),
306 ("music_quiz/can_listen_in", self.can_listen_in),
307 )
308 for command, handler in listen_in_commands:
309 self._unregister_handles.append(
310 self.mass.register_api_command(
311 command, handler, required_scope=Scope.PLAYERS_CONTROL
312 )
313 )
314
315 async def unload(self, is_removed: bool = False) -> None:
316 """
317 Call when the provider is being unloaded.
318
319 :param is_removed: Whether the provider is being removed (vs just reloaded).
320 """
321 for unregister in self._unregister_handles:
322 unregister()
323 self._unregister_handles.clear()
324 async with self._game_lock:
325 quiz_type = self._quiz_type
326 self._cancel_timers()
327 self._cancel_next_round_task()
328 await self._cancel_reveal_playback_task()
329 if quiz_type is None or quiz_type.uses_audio:
330 await self._stop_playback()
331 self._game_generation += 1
332 # clear game state before tearing down the session so a guest listen-in
333 # racing with unload cannot (re)create or join a session mid-teardown
334 self._game = None
335 self._quiz_type = None
336 self._answer_type = None
337 await self._close_playback_session()
338 if is_removed:
339 await guest_access.revoke_guest_access(self.mass, MUSIC_QUIZ_GUEST_USER)
340 await super().unload(is_removed)
341
342 # ==================== Host API Commands ====================
343
344 async def available_quiz_types(self) -> list[str]:
345 """Return quiz types currently available for game creation."""
346 return await get_available_quiz_types(self.mass)
347
348 async def playback_options(self) -> MusicQuizPlaybackOptions:
349 """Return the host's available and recommended playback options."""
350 return (await self._resolve_playback_defaults()).options
351
352 async def create_game( # noqa: PLR0913
353 self,
354 quiz_type: str = "guess_the_song",
355 round_count: int = 5,
356 suggestion_count: int = 4,
357 answer_duration: int = 30,
358 source_uris: list[str] | None = None,
359 include_similar_music: bool = False,
360 name: str | None = None,
361 difficulty: str = MusicQuizDifficulty.NORMAL.value,
362 language: str = DEFAULT_TRIVIA_LANGUAGE,
363 play_reveal_audio: bool = True,
364 artist_bonus_mode: str = TimelineBonusMode.OFF.value,
365 title_bonus_mode: str = TimelineBonusMode.OFF.value,
366 playback_mode: str | None = None,
367 venue_player_id: str | None = None,
368 ) -> dict[str, Any]:
369 """
370 Create a new Music Quiz game, replacing a previous (finished) game.
371
372 :param quiz_type: The quiz type to play (e.g. "guess_the_song").
373 :param round_count: Number of rounds to play.
374 :param suggestion_count: Number of answer suggestions per round.
375 :param answer_duration: Answering duration in seconds.
376 :param source_uris: Track, playlist, album, artist or genre URIs to draw rounds from.
377 :param include_similar_music: Add bounded similar tracks to the selected source pool.
378 :param name: Optional game name.
379 :param difficulty: Guess-the-song difficulty ("easy", "normal" or "hard").
380 :param language: Language tag for Trivia question content.
381 :param play_reveal_audio: Play Trivia's grounded track during each reveal.
382 :param artist_bonus_mode: Music Timeline artist bonus mode.
383 :param title_bonus_mode: Music Timeline title bonus mode.
384 :param playback_mode: Playback mode for this game ("venue" or "remote").
385 :param venue_player_id: Venue player selected for this game.
386 """
387 quiz_type_class = get_quiz_type(quiz_type)
388 get_answer_type(quiz_type_class.answer_type)
389 try:
390 parsed_artist_bonus_mode = TimelineBonusMode(artist_bonus_mode)
391 parsed_title_bonus_mode = TimelineBonusMode(title_bonus_mode)
392 except ValueError as err:
393 raise InvalidDataError(
394 "Unknown timeline bonus mode",
395 translation_key="music_quiz_invalid_bonus_mode",
396 translation_owner=TRANSLATION_OWNER,
397 ) from err
398 playback_defaults = await self._resolve_playback_defaults()
399 effective_mode, effective_player_id, effective_player_name = self._resolve_create_playback(
400 playback_mode,
401 venue_player_id,
402 playback_defaults.options,
403 )
404 game_config = quiz_type_class.normalize_config(
405 MusicQuizConfig(
406 round_count=round_count,
407 suggestion_count=suggestion_count,
408 answer_duration=answer_duration,
409 source_uris=source_uris or [],
410 include_similar_music=include_similar_music,
411 name=_clean_game_name(name),
412 playback_mode=effective_mode,
413 venue_player_id=effective_player_id,
414 venue_player_name=effective_player_name,
415 difficulty=difficulty,
416 use_ai_distractors=bool(self.config.get_value(CONF_USE_AI_DISTRACTORS)),
417 # selected on first use rather than at init: providers load concurrently,
418 # so the plugin supplying the engines may not have been available back then
419 ai_engine=(
420 engine.uid if (engine := await select_ai_engine(self, CONF_AI_ENGINE)) else None
421 ),
422 language=language,
423 play_reveal_audio=play_reveal_audio,
424 artist_bonus_mode=parsed_artist_bonus_mode,
425 title_bonus_mode=parsed_title_bonus_mode,
426 )
427 )
428 quiz_type_class.validate_config(game_config)
429 async with self._game_lock:
430 if self._game is not None and self._game.phase in (
431 MusicQuizPhase.ANSWERING,
432 MusicQuizPhase.REVEAL,
433 ):
434 raise MusicQuizGameActiveError("A Music Quiz game is already in progress")
435 # resolved before the game goes live so every public-state broadcast, including
436 # the very first lobby one a cast dashboard renders its QR from, carries it
437 self._join_url = await self._get_join_url()
438 game = MusicQuizGame(
439 config=game_config,
440 quiz_type=quiz_type,
441 answer_type=quiz_type_class.answer_type,
442 sources=await self._resolve_sources(game_config.source_uris),
443 created_at=time.time(),
444 )
445 quiz_strategy, answer_strategy = self._resolve_game_strategies(
446 game,
447 recent_track_uris=self._recent_track_uris_for_game(self._game),
448 )
449 initial_round_task = await self._prepare_initial_round(quiz_strategy)
450 selected_player = self._validate_playback_config(game_config)
451 if selected_player is not None:
452 game_config.venue_player_name = selected_player.display_name
453 remembered_venue_player_id = (
454 game_config.venue_player_id
455 if game_config.playback_mode == SharedPlaybackMode.VENUE
456 else playback_defaults.stored_venue_player_id
457 )
458 previous_game = self._game
459 previous_quiz_type = self._quiz_type
460 await self._cancel_reveal_playback_task()
461 if previous_quiz_type is not None and previous_quiz_type.uses_audio:
462 await self._stop_playback()
463 async with self._playback_lock:
464 if not quiz_strategy.uses_audio or (
465 previous_game is not None and _playback_selection_changed(previous_game, game)
466 ):
467 await self._close_playback_session_locked()
468 self._cancel_timers()
469 self._cancel_next_round_task()
470 self._game_generation += 1
471 self._game = game
472 self._quiz_type = quiz_strategy
473 self._answer_type = answer_strategy
474 self._next_round_task = initial_round_task
475 self._signal_game_updated()
476 await self._store_playback_preference(
477 game_config.playback_mode,
478 remembered_venue_player_id,
479 )
480 return await self._host_state()
481
482 async def get_game(self) -> dict[str, Any] | None:
483 """
484 Return the host-visible state of the current game.
485
486 :return: The current host state, or None when no game is active.
487 """
488 # take the game lock so the empty/active decision and snapshot cannot
489 # tear against a lifecycle or state change while _host_state awaits the join URL
490 async with self._game_lock:
491 if self._game is None:
492 return None
493 return await self._host_state()
494
495 async def start_game(self) -> dict[str, Any]:
496 """Start the first round of the current game."""
497 async with self._game_lock:
498 await self._start_game_from_lobby()
499 return await self._host_state()
500
501 async def reveal(self) -> dict[str, Any]:
502 """Reveal the current round and apply scoring."""
503 async with self._game_lock:
504 self._require_game()
505 self._do_reveal()
506 return await self._host_state()
507
508 async def next_round(self) -> dict[str, Any]:
509 """Advance to the next round or finish the game."""
510 async with self._game_lock:
511 game = self._require_game()
512 if game.phase != MusicQuizPhase.REVEAL:
513 raise MusicQuizWrongPhaseError("Next round can only start after reveal")
514 await self._advance_from_reveal()
515 return await self._host_state()
516
517 async def reset(self, auto_start: bool = False) -> dict[str, Any]:
518 """
519 Reset the current game for a new run with the same settings and players.
520
521 :param auto_start: Start a replay countdown when an active player remains.
522 """
523 async with self._game_lock:
524 game = self._require_game()
525 quiz_strategy, answer_strategy = self._resolve_game_strategies(
526 game,
527 recent_track_uris=self._recent_track_uris_for_game(game),
528 )
529 try:
530 # announce the preparation up front so clients stop rendering the
531 # previous run while the sources and first round load
532 game.preparing = True
533 self._signal_game_updated()
534 initial_round_task = await self._prepare_initial_round(quiz_strategy)
535 self._cancel_timers()
536 self._cancel_next_round_task()
537 await self._cancel_reveal_playback_task()
538 if quiz_strategy.uses_audio:
539 await self._stop_playback()
540 now = time.time()
541 reset_game(game)
542 self._game_generation += 1
543 self._quiz_type = quiz_strategy
544 self._answer_type = answer_strategy
545 self._next_round_task = initial_round_task
546 self._schedule_presence_expiry(now)
547 if auto_start and _has_active_players(game, now):
548 self._schedule_replay_auto_start(game, now)
549 finally:
550 # a failed preparation keeps the previous game, so clear and
551 # broadcast here too or clients wait on the preparing state forever
552 game.preparing = False
553 self._signal_game_updated()
554 return await self._host_state()
555
556 async def delete_game(self) -> None:
557 """Delete the current game and stop its playback."""
558 async with self._game_lock:
559 self._require_game()
560 uses_audio = self._quiz_type is None or self._quiz_type.uses_audio
561 self._cancel_timers()
562 self._cancel_next_round_task()
563 await self._cancel_reveal_playback_task()
564 self._game_generation += 1
565 # clear game state before tearing down the session so a guest listen-in
566 # racing with delete cannot (re)create or join a session mid-teardown
567 self._game = None
568 self._quiz_type = None
569 self._answer_type = None
570 if uses_audio:
571 await self._stop_playback()
572 # tear down the shared session so its virtual player / listen-in
573 # guests do not linger once the game is gone
574 await self._close_playback_session()
575 self.signal_provider_event({"event": "game_removed"})
576
577 # ==================== Guest API Commands ====================
578
579 async def get_game_info(self) -> dict[str, Any] | None:
580 """Return public metadata of the current game (e.g. for the join screen)."""
581 async with self._game_lock:
582 if (game := self._game) is None:
583 return None
584 return {
585 "name": game.config.name,
586 "quiz_type": game.quiz_type,
587 "answer_type": game.answer_type.value,
588 "phase": game.phase.value,
589 "mode": game.config.playback_mode.value,
590 "player_count": len(game.players),
591 "round_count": game.config.round_count,
592 "auto_start_at": game.auto_start_at,
593 **get_quiz_type(game.quiz_type).serialize_game_config(game),
594 }
595
596 async def join_game(self, name: str) -> dict[str, Any]:
597 """
598 Join the current game as a player.
599
600 :param name: Unique player display name.
601 :return: The player's private player_id (their credential for further
602 game commands) and their personalized game state.
603 """
604 async with self._game_lock:
605 game, _, answer_type = self._require_game_strategies()
606 player_name = name.strip()[:MAX_PLAYER_NAME_LENGTH]
607 if not player_name:
608 raise InvalidDataError(
609 "Player name is required",
610 translation_key="music_quiz_name_required",
611 translation_owner=TRANSLATION_OWNER,
612 )
613 if len(game.players) >= MAX_PLAYER_COUNT:
614 raise MusicQuizGameFullError("Music Quiz game is full")
615 joined_at = time.time()
616 player = MusicQuizPlayer(
617 player_id=secrets.token_urlsafe(24),
618 name=player_name,
619 joined_at=joined_at,
620 active_from_round=_get_join_round(game),
621 last_seen=joined_at,
622 )
623 add_player(game, player)
624 self._schedule_presence_expiry(joined_at)
625 self._signal_game_updated()
626 return {
627 "player_id": player.player_id,
628 "state": _player_state(game, player, answer_type, join_url=self._join_url),
629 }
630
631 async def get_player_state(self, player_id: str) -> dict[str, Any]:
632 """
633 Return the personalized game state for a player (initial load/reconnect).
634
635 :param player_id: The player's private player_id.
636 """
637 async with self._game_lock:
638 game, _, answer_type = self._require_game_strategies()
639 player = _get_player(game, player_id)
640 self._refresh_player_presence(player)
641 return _player_state(game, player, answer_type, join_url=self._join_url)
642
643 async def get_public_state(self) -> dict[str, Any] | None:
644 """
645 Return the guest-safe public game state for a non-participant display.
646
647 Mirrors the ``game_updated`` broadcast payload so a display client (e.g. a
648 lobby dashboard) can render the full current state on cold launch without
649 joining as a player.
650
651 :return: The full public game state, or None when no game is active.
652 """
653 async with self._game_lock:
654 if self._game is None:
655 return None
656 game, _, answer_type = self._require_game_strategies()
657 return _public_state(game, answer_type, join_url=self._join_url)
658
659 async def heartbeat(self, player_id: str) -> bool:
660 """
661 Refresh a player's reconnect grace period.
662
663 :param player_id: The player's private player_id.
664 :return: True when the player still exists, otherwise False.
665 """
666 async with self._game_lock:
667 if self._game is None or (player := _find_player(self._game, player_id)) is None:
668 return False
669 self._refresh_player_presence(player)
670 return True
671
672 async def submit_answer(
673 self,
674 player_id: str,
675 submission: QuizAnswerSubmissionPayload,
676 ) -> dict[str, SerializableType]:
677 """
678 Submit a typed answer for the current round.
679
680 :param player_id: The player's private player_id.
681 :param submission: Discriminated answer submission.
682 """
683 async with self._game_lock:
684 game, _, answer_type = self._require_game_strategies()
685 submission_type = submission.get("answer_type")
686 if not isinstance(submission_type, str):
687 raise MusicQuizInvalidAnswerError(
688 "Answer submission requires an answer_type string"
689 )
690 submitted_answer_type = get_answer_type(submission_type)
691 if submitted_answer_type.answer_type != game.answer_type:
692 raise MusicQuizInvalidAnswerError("Submission answer type does not match the game")
693 parsed_submission = answer_type.parse_submission(submission)
694 player = _get_player(game, player_id)
695 return self._submit_player_answer(game, player, parsed_submission, answer_type)
696
697 async def answer(self, player_id: str, suggestion_id: str) -> dict[str, Any]:
698 """
699 Submit and lock a player's answer for the current round.
700
701 :param player_id: The player's private player_id.
702 :param suggestion_id: Selected suggestion ID.
703 """
704 async with self._game_lock:
705 game, _, answer_type = self._require_game_strategies()
706 if game.answer_type != MusicQuizAnswerType.MULTIPLE_CHOICE:
707 raise MusicQuizInvalidAnswerError(
708 "The compatibility answer command requires multiple_choice"
709 )
710 player = _get_player(game, player_id)
711 submission = MultipleChoiceSubmission(suggestion_id=suggestion_id)
712 return self._submit_player_answer(game, player, submission, answer_type)
713
714 async def ready(self, player_id: str) -> dict[str, Any]:
715 """
716 Mark a player ready for the next round during reveal.
717
718 :param player_id: The player's private player_id.
719 """
720 async with self._game_lock:
721 game, _, answer_type = self._require_game_strategies()
722 player = _get_player(game, player_id)
723 self._refresh_player_presence(player)
724 # a repeat ready is a no-op: it cannot newly satisfy the all-ready
725 # check, so return current state without re-broadcasting
726 if game.phase != MusicQuizPhase.REVEAL or player.ready:
727 return _player_state(game, player, answer_type, join_url=self._join_url)
728 mark_player_ready(game, player.player_id)
729 # advance early when every player is ready for the next round
730 if are_active_players_ready(game):
731 await self._advance_from_reveal()
732 else:
733 self._signal_game_updated()
734 return _player_state(game, player, answer_type, join_url=self._join_url)
735
736 async def listen_in(self, web_player_id: str) -> None:
737 """
738 Attach a guest's web player to the game audio.
739
740 :param web_player_id: The player_id of the guest's web player.
741 """
742 # hold the playback lock across resolving and joining the session so a
743 # guest can never be attached to a session that is being torn down
744 async with self._playback_lock:
745 self._require_game()
746 session = await self._get_or_create_session_locked()
747 if session is None:
748 raise MusicQuizNoPlaybackTargetError("Listen-in is not available for this game")
749 await session.add_guest_listener(web_player_id)
750
751 async def stop_listen_in(self, web_player_id: str) -> None:
752 """
753 Detach a guest's web player from the game audio.
754
755 :param web_player_id: The player_id of the guest's web player.
756 """
757 async with self._playback_lock:
758 if self._playback_session is not None:
759 await self._playback_session.remove_guest_listener(web_player_id)
760
761 async def can_listen_in(self, web_player_id: str) -> bool:
762 """
763 Return whether the given guest web player can listen in on the game audio.
764
765 :param web_player_id: The player_id of the guest's web player.
766 """
767 async with self._playback_lock:
768 session = await self._get_or_create_session_locked()
769 return session is not None and session.can_listen_in(web_player_id)
770
771 # ==================== Internals ====================
772
773 def _require_game(self) -> MusicQuizGame:
774 """Return the current game or raise when there is none."""
775 if self._game is None:
776 raise MusicQuizNoGameError("There is no active Music Quiz game")
777 return self._game
778
779 def _resolve_game_strategies(
780 self,
781 game: MusicQuizGame,
782 *,
783 recent_track_uris: Iterable[str] = (),
784 ) -> tuple[QuizType, QuizAnswerType]:
785 """
786 Resolve the strategies declared by game state.
787
788 :param game: Game whose strategy identities should be resolved.
789 :param recent_track_uris: Earlier game tracks to deprioritize.
790 """
791 quiz_type_class = get_quiz_type(game.quiz_type)
792 answer_type_class = get_answer_type(game.answer_type)
793 if quiz_type_class.answer_type != game.answer_type:
794 raise InvalidDataError("Quiz type answer type does not match the game")
795 quiz_type = quiz_type_class(self.mass, game.config)
796 quiz_type.add_recent_track_uris(recent_track_uris)
797 return quiz_type, answer_type_class()
798
799 def _recent_track_uris_for_game(self, game: MusicQuizGame | None) -> set[str]:
800 """Return source tracks represented by an earlier game."""
801 if game is None:
802 return set()
803 quiz_type_class = get_quiz_type(game.quiz_type)
804 quiz_type = self._quiz_type
805 if quiz_type is None or type(quiz_type) is not quiz_type_class:
806 quiz_type = quiz_type_class(self.mass, game.config)
807 return quiz_type.get_recent_track_uris(game.rounds)
808
809 def _require_game_strategies(
810 self,
811 ) -> tuple[MusicQuizGame, QuizType, QuizAnswerType]:
812 """Return the game and matching cached strategies."""
813 game = self._require_game()
814 if self._quiz_type is None or self._answer_type is None:
815 raise InvalidDataError("Music Quiz game strategies are unavailable")
816 quiz_type_class = get_quiz_type(game.quiz_type)
817 answer_type_class = get_answer_type(game.answer_type)
818 if (
819 quiz_type_class.answer_type != game.answer_type
820 or type(self._quiz_type) is not quiz_type_class
821 or type(self._answer_type) is not answer_type_class
822 ):
823 raise InvalidDataError("Music Quiz game strategy identity mismatch")
824 return game, self._quiz_type, self._answer_type
825
826 def _submit_player_answer(
827 self,
828 game: MusicQuizGame,
829 player: MusicQuizPlayer,
830 submission: QuizAnswerSubmission,
831 answer_type: QuizAnswerType,
832 ) -> dict[str, SerializableType]:
833 """
834 Apply a typed submission and return personalized state.
835
836 :param game: Game receiving the submission.
837 :param player: Player submitting the answer.
838 :param submission: Validated answer submission.
839 :param answer_type: Answer strategy for the game.
840 """
841 submitted_at = time.time()
842 submit_game_answer(game, player.player_id, submission, submitted_at, answer_type)
843 self._refresh_player_presence(player, submitted_at)
844 if all_active_players_complete(game, answer_type):
845 self._do_reveal(completed=True)
846 else:
847 self._signal_game_updated()
848 return _player_state(game, player, answer_type, join_url=self._join_url)
849
850 async def _host_state(self) -> dict[str, Any]:
851 """Return the host-visible state of the current game."""
852 game, _, answer_type = self._require_game_strategies()
853 return {
854 **_public_state(game, answer_type),
855 "created_at": game.created_at,
856 "sources": [source.to_dict() for source in game.sources],
857 "join_url": self._join_url or await self._get_join_url(),
858 "rounds": [_host_round(game_round, answer_type) for game_round in game.rounds],
859 "playback": _playback_summary(game),
860 }
861
862 async def _get_join_url(self) -> str:
863 """Return the guest join URL, creating the guest user and join code if needed."""
864 guest_user = await guest_access.get_or_create_guest_user(
865 self.mass, MUSIC_QUIZ_GUEST_USER, MUSIC_QUIZ_GUEST_DISPLAY_NAME
866 )
867 code = await guest_access.get_or_create_join_code(
868 self.mass, guest_user, device_name="Music Quiz Guest"
869 )
870 return guest_access.build_join_url(self.mass, code)
871
872 def _signal_game_updated(self) -> None:
873 """Broadcast the public game state to all connected clients."""
874 if self._game is None:
875 return
876 game, _, answer_type = self._require_game_strategies()
877 state = _public_state(game, answer_type, join_url=self._join_url)
878 self.signal_provider_event({"event": "game_updated", "state": state})
879
880 async def _resolve_sources(self, source_uris: list[str]) -> list[MusicQuizSource]:
881 """Resolve configured source URIs into host-visible source metadata."""
882 sources: list[MusicQuizSource] = []
883 for source_uri in source_uris:
884 try:
885 source_media_type, provider_instance, item_id = await parse_uri(source_uri)
886 except Exception as err:
887 self.logger.warning("Ignoring invalid Music Quiz source %s: %s", source_uri, err)
888 continue
889 if not is_supported_source(source_media_type, provider_instance):
890 self.logger.warning(
891 "Ignoring unsupported Music Quiz source %s (%s)",
892 source_uri,
893 source_media_type,
894 )
895 continue
896 try:
897 media_item = await self.mass.music.get_item(
898 media_type=source_media_type,
899 item_id=item_id,
900 provider_instance_id_or_domain=provider_instance,
901 allow_update_metadata=False,
902 )
903 except Exception as err:
904 # the real failure otherwise only surfaces at round start,
905 # minutes later and far from the cause
906 self.logger.warning("Could not resolve Music Quiz source %s: %s", source_uri, err)
907 sources.append(MusicQuizSource(uri=source_uri, name=source_uri))
908 continue
909 if not is_supported_source(media_item.media_type, media_item.provider):
910 self.logger.warning(
911 "Ignoring unsupported Music Quiz source %s (%s)",
912 source_uri,
913 media_item.media_type,
914 )
915 continue
916 sources.append(
917 MusicQuizSource(
918 uri=source_uri,
919 name=media_item.name or source_uri,
920 media_type=media_item.media_type.value,
921 )
922 )
923 return sources
924
925 # ---------- round/phase progression (call with self._game_lock held) ----------
926
927 async def _start_game_from_lobby(self, *, timer_owned: bool = False) -> None:
928 """Start the first round from the lobby."""
929 game = self._require_game()
930 if game.phase != MusicQuizPhase.LOBBY:
931 raise MusicQuizWrongPhaseError("The game has already started")
932 self._cancel_replay_auto_start(cancel_task=not timer_owned)
933 try:
934 await self._start_next_round()
935 except Exception:
936 if self._game is game and game.phase == MusicQuizPhase.LOBBY:
937 self._signal_game_updated()
938 raise
939
940 async def _start_next_round(self) -> None:
941 """Prepare the next round, start its playback (if any) and open the answering phase."""
942 with _system_auth_context():
943 game, _, answer_type = self._require_game_strategies()
944 round_index = len(game.rounds)
945 next_round = await self._prepare_playable_round(round_index)
946 started_at = time.time()
947 start_round(game, next_round, started_at, answer_type)
948 answer_window = _answer_window(game, next_round)
949 self.mass.call_later(
950 answer_window,
951 self._on_answer_deadline,
952 game,
953 self._game_generation,
954 round_index,
955 started_at + answer_window,
956 task_id=self._reveal_timer_id,
957 )
958 self._prefetch_round(round_index + 1)
959 self._signal_game_updated()
960
961 async def _prepare_playable_round(self, round_index: int) -> MusicQuizRound:
962 """Return a prepared round after its audio starts successfully."""
963 _, quiz_type, _ = self._require_game_strategies()
964 if not quiz_type.plays_track_before_answering:
965 return await self._get_prepared_round(round_index)
966 rejected_uris: set[str] = set()
967 last_error: AudioError | MediaNotFoundError | None = None
968 for _attempt in range(MAX_PLAYBACK_ATTEMPTS):
969 next_round = await self._get_prepared_round(round_index)
970 track_uri = next_round.track_uri
971 if track_uri is None:
972 raise InvalidDataError("Prepared audio round is missing a track URI")
973 if track_uri in rejected_uris:
974 quiz_type.reject_track(track_uri)
975 continue
976 if await self._advance_to_queued_track(track_uri):
977 return next_round
978 try:
979 # This public queue operation is both the production resolution boundary and
980 # the intended start of playback. A temporary QueueItem would bypass URI,
981 # user/provider and target resolution performed by this path.
982 await self._play_track(track_uri)
983 except (AudioError, MediaNotFoundError) as err:
984 rejected_uris.add(track_uri)
985 last_error = err
986 quiz_type.reject_track(track_uri)
987 self.logger.warning(
988 "Could not play Music Quiz track %s; preparing a replacement: %s",
989 track_uri,
990 err,
991 )
992 continue
993 return next_round
994 raise MediaNotFoundError(
995 f"No playable Music Quiz track found after {MAX_PLAYBACK_ATTEMPTS} attempts"
996 ) from last_error
997
998 def _do_reveal(self, *, completed: bool = False) -> None:
999 """Reveal the current round, apply scoring and schedule the auto-advance."""
1000 game, quiz_type, answer_type = self._require_game_strategies()
1001 reveal_round(game, answer_type)
1002 self.mass.cancel_timer(self._reveal_timer_id)
1003 current_round = get_current_round(game)
1004 current_round.auto_advance_at = None
1005 if quiz_type.plays_track_before_answering:
1006 current_round.audio_started_at = self._audio_started_at(current_round)
1007 now = time.time()
1008 advance_delay = quiz_type.completed_reveal_auto_advance_delay if completed else None
1009 if advance_delay is None:
1010 advance_delay = quiz_type.reveal_auto_advance_delay
1011 if advance_delay is None and current_round.duration and current_round.started_at:
1012 remaining = current_round.started_at + current_round.duration - now
1013 advance_delay = max(remaining, MIN_REVEAL_SECONDS)
1014 if advance_delay is not None:
1015 auto_advance_at = now + advance_delay
1016 self.mass.call_later(
1017 advance_delay,
1018 self._on_reveal_auto_advance,
1019 game,
1020 self._game_generation,
1021 current_round.round_index,
1022 auto_advance_at,
1023 task_id=self._advance_timer_id,
1024 )
1025 current_round.auto_advance_at = auto_advance_at
1026 # a reveal held past the track's end would glide into the next round's queued track
1027 if (
1028 quiz_type.plays_track_before_answering
1029 and not quiz_type.plays_track_on_reveal
1030 and current_round.duration
1031 and current_round.started_at
1032 ):
1033 remaining = current_round.started_at + current_round.duration - now
1034 if advance_delay is not None and advance_delay > remaining:
1035 self.mass.call_later(
1036 max(remaining, 0),
1037 self._stop_playback,
1038 task_id=self._track_end_timer_id,
1039 )
1040 self._signal_game_updated()
1041 if quiz_type.plays_track_on_reveal:
1042 if current_round.track_uri is None:
1043 self.logger.warning("Prepared Music Quiz reveal round is missing a track URI")
1044 else:
1045 self._start_reveal_playback(
1046 game,
1047 self._game_generation,
1048 current_round.round_index,
1049 current_round.track_uri,
1050 )
1051
1052 async def _advance_from_reveal(self) -> None:
1053 """Advance a revealed game to the next round or finish it."""
1054 game, quiz_type, _ = self._require_game_strategies()
1055 get_current_round(game).auto_advance_at = None
1056 self.mass.cancel_timer(self._advance_timer_id)
1057 self.mass.cancel_timer(self._track_end_timer_id)
1058 if quiz_type.plays_track_on_reveal:
1059 await self._cancel_reveal_playback_task()
1060 await self._stop_playback()
1061 if len(game.rounds) >= game.config.round_count:
1062 if quiz_type.uses_audio and not quiz_type.plays_track_on_reveal:
1063 await self._stop_playback()
1064 finish_game(game)
1065 self._cancel_presence_expiry()
1066 self._signal_game_updated()
1067 return
1068 await self._start_next_round()
1069
1070 async def _expire_inactive_players(self) -> None:
1071 """Remove players whose reconnect grace period elapsed."""
1072 async with self._game_lock:
1073 if self._game is None or self._game.phase == MusicQuizPhase.FINISHED:
1074 self._cancel_presence_expiry()
1075 return
1076 game, _, answer_type = self._require_game_strategies()
1077 now = time.time()
1078 expired_player_ids = [
1079 player.player_id
1080 for player in game.players.values()
1081 if not _is_player_active(player, now)
1082 ]
1083 if not expired_player_ids:
1084 self._schedule_presence_expiry(now)
1085 return
1086
1087 for player_id in expired_player_ids:
1088 remove_game_player(game, player_id, answer_type)
1089
1090 if (
1091 game.phase == MusicQuizPhase.LOBBY
1092 and game.auto_start_at is not None
1093 and not _has_active_players(game, now)
1094 ):
1095 self._cancel_replay_auto_start(cancel_task=True)
1096
1097 if game.players and game.phase == MusicQuizPhase.ANSWERING:
1098 if all_active_players_complete(game, answer_type):
1099 self._do_reveal(completed=True)
1100 else:
1101 self._signal_game_updated()
1102 elif game.players and game.phase == MusicQuizPhase.REVEAL:
1103 if are_active_players_ready(game):
1104 await self._advance_from_reveal()
1105 else:
1106 self._signal_game_updated()
1107 else:
1108 self._signal_game_updated()
1109 self._schedule_presence_expiry()
1110
1111 async def _on_replay_auto_start(
1112 self,
1113 game: MusicQuizGame,
1114 generation: int,
1115 auto_start_at: float,
1116 ) -> None:
1117 """
1118 Start a replay whose authoritative countdown reached its deadline.
1119
1120 :param game: Game for which the countdown was scheduled.
1121 :param generation: Lifecycle generation for which the countdown was scheduled.
1122 :param auto_start_at: Authoritative deadline for this countdown.
1123 """
1124 with _system_auth_context():
1125 async with self._game_lock:
1126 if (
1127 self._game is not game
1128 or self._game_generation != generation
1129 or game.phase != MusicQuizPhase.LOBBY
1130 or game.auto_start_at != auto_start_at
1131 ):
1132 return
1133 if not _has_active_players(game, time.time()):
1134 self._cancel_replay_auto_start(cancel_task=False)
1135 self._signal_game_updated()
1136 return
1137 try:
1138 await self._start_game_from_lobby(timer_owned=True)
1139 except Exception as err:
1140 self.logger.error(
1141 "Could not automatically start Music Quiz replay: %s",
1142 err,
1143 exc_info=err,
1144 )
1145
1146 async def _on_answer_deadline(
1147 self,
1148 game: MusicQuizGame,
1149 generation: int,
1150 round_index: int,
1151 answer_deadline: float,
1152 ) -> None:
1153 """
1154 Reveal an answering round whose authoritative deadline was reached.
1155
1156 :param game: Game for which the deadline was scheduled.
1157 :param generation: Lifecycle generation for which the deadline was scheduled.
1158 :param round_index: Answering round index.
1159 :param answer_deadline: Authoritative deadline for this answer window.
1160 """
1161 async with self._game_lock:
1162 if (
1163 self._game is not game
1164 or self._game_generation != generation
1165 or not self._is_current_round(round_index, MusicQuizPhase.ANSWERING)
1166 ):
1167 return
1168 current_round = get_current_round(game)
1169 if (
1170 current_round.started_at is None
1171 or current_round.started_at + _answer_window(game, current_round) != answer_deadline
1172 ):
1173 return
1174 self._do_reveal()
1175
1176 async def _on_reveal_auto_advance(
1177 self,
1178 game: MusicQuizGame,
1179 generation: int,
1180 round_index: int,
1181 auto_advance_at: float,
1182 ) -> None:
1183 """
1184 Advance a reveal whose authoritative deadline was reached.
1185
1186 :param game: Game for which advancement was scheduled.
1187 :param generation: Lifecycle generation for which advancement was scheduled.
1188 :param round_index: Revealed round index.
1189 :param auto_advance_at: Authoritative deadline for this reveal.
1190 """
1191 async with self._game_lock:
1192 if (
1193 self._game is not game
1194 or self._game_generation != generation
1195 or not self._is_current_round(round_index, MusicQuizPhase.REVEAL)
1196 or get_current_round(game).auto_advance_at != auto_advance_at
1197 ):
1198 return
1199 try:
1200 await self._advance_from_reveal()
1201 except Exception as err:
1202 # leave the game in reveal so the host or players can retry manually
1203 self.logger.error("Could not advance Music Quiz game: %s", err, exc_info=err)
1204 if (
1205 self._game is game
1206 and self._game_generation == generation
1207 and self._is_current_round(round_index, MusicQuizPhase.REVEAL)
1208 ):
1209 get_current_round(game).auto_advance_at = None
1210 self._signal_game_updated()
1211
1212 def _is_current_round(self, round_index: int, phase: MusicQuizPhase) -> bool:
1213 """Return whether the game is still in the given round and phase."""
1214 return (
1215 self._game is not None
1216 and self._game.phase == phase
1217 and self._game.current_round_index == round_index
1218 )
1219
1220 # ---------- round preparation ----------
1221
1222 async def _prepare_initial_round(
1223 self,
1224 quiz_type: QuizType,
1225 ) -> asyncio.Task[MusicQuizRound]:
1226 """Initialize a quiz type and complete its first round before opening the lobby."""
1227 with _system_auth_context():
1228 await quiz_type.initialize()
1229 task = self.mass.create_task(self._prepare_round(quiz_type, 0, []))
1230 await task
1231 return task
1232
1233 async def _prepare_round(
1234 self,
1235 quiz_type: QuizType,
1236 round_index: int,
1237 previous_rounds: list[MusicQuizRound],
1238 ) -> MusicQuizRound:
1239 """Prepare a round and its required assets."""
1240 game_round = await quiz_type.prepare_round(round_index, previous_rounds)
1241 if game_round.track_uri and quiz_type.prefetch_lyrics:
1242 await self._fetch_lyrics(game_round.track_uri)
1243 return game_round
1244
1245 def _prefetch_round(self, round_index: int) -> None:
1246 """Prepare an upcoming round in the background."""
1247 self._cancel_next_round_task()
1248 if self._game is None or round_index >= self._game.config.round_count:
1249 return
1250 game, quiz_type, _ = self._require_game_strategies()
1251 with _system_auth_context():
1252 self._next_round_task = self.mass.create_task(
1253 self._prepare_round(quiz_type, round_index, list(game.rounds))
1254 )
1255 if quiz_type.plays_track_before_answering:
1256 self._warm_next_track_task = self.mass.create_task(
1257 self._warm_next_track(self._next_round_task)
1258 )
1259
1260 async def _get_prepared_round(self, round_index: int) -> MusicQuizRound:
1261 """Return the (prefetched) round with the given index."""
1262 game, quiz_type, _ = self._require_game_strategies()
1263 task = self._next_round_task
1264 self._next_round_task = None
1265 if task is not None:
1266 try:
1267 prepared = await task
1268 if prepared.round_index == round_index:
1269 return prepared
1270 except asyncio.CancelledError:
1271 current_task = asyncio.current_task()
1272 if current_task is None or current_task.cancelling():
1273 raise
1274 except Exception as err:
1275 self.logger.warning(
1276 "Prefetched Music Quiz round failed, preparing a fresh one: %s", err
1277 )
1278 with _system_auth_context():
1279 return await self._prepare_round(quiz_type, round_index, list(game.rounds))
1280
1281 def _cancel_next_round_task(self) -> None:
1282 """Cancel a pending round prefetch task."""
1283 if self._warm_next_track_task is not None:
1284 self._warm_next_track_task.cancel()
1285 self._warm_next_track_task.add_done_callback(_consume_task_exception)
1286 self._warm_next_track_task = None
1287 if self._next_round_task is not None:
1288 self._next_round_task.cancel()
1289 # retrieve the result/exception once the task settles so a prefetch that
1290 # already failed is not reported by asyncio as an unhandled exception
1291 self._next_round_task.add_done_callback(_consume_task_exception)
1292 self._next_round_task = None
1293
1294 def _start_reveal_playback(
1295 self,
1296 game: MusicQuizGame,
1297 generation: int,
1298 round_index: int,
1299 track_uri: str,
1300 ) -> None:
1301 """
1302 Start best-effort reveal playback for the current Trivia round.
1303
1304 :param game: Game owning the revealed round.
1305 :param generation: Lifecycle generation owning the playback.
1306 :param round_index: Revealed round index.
1307 :param track_uri: Grounded track to play.
1308 """
1309 if self._reveal_playback_task is not None:
1310 self._reveal_playback_task.cancel()
1311 self._reveal_playback_task.add_done_callback(_consume_task_exception)
1312 self._reveal_playback_task = None
1313 playback = self._play_reveal_track(game, generation, round_index, track_uri)
1314 try:
1315 with _system_auth_context():
1316 self._reveal_playback_task = self.mass.create_task(
1317 playback,
1318 eager_start=False,
1319 )
1320 except Exception as err:
1321 playback.close()
1322 self.logger.warning(
1323 "Could not start Music Quiz reveal playback for %s: %s",
1324 track_uri,
1325 err,
1326 )
1327
1328 async def _play_reveal_track(
1329 self,
1330 game: MusicQuizGame,
1331 generation: int,
1332 round_index: int,
1333 track_uri: str,
1334 ) -> None:
1335 """
1336 Play a track while its owning Trivia reveal remains current.
1337
1338 :param game: Game owning the revealed round.
1339 :param generation: Lifecycle generation owning the playback.
1340 :param round_index: Revealed round index.
1341 :param track_uri: Grounded track to play.
1342 """
1343 with _system_auth_context():
1344 if not self._is_reveal_playback_current(game, generation, round_index):
1345 return
1346 try:
1347 await self._play_track(track_uri)
1348 except asyncio.CancelledError:
1349 raise
1350 except Exception as err:
1351 self.logger.warning(
1352 "Could not play Music Quiz reveal track %s: %s",
1353 track_uri,
1354 err,
1355 )
1356 return
1357 if not self._is_reveal_playback_current(game, generation, round_index):
1358 await self._stop_playback()
1359
1360 async def _cancel_reveal_playback_task(self) -> None:
1361 """Cancel and await the provider-owned reveal playback task, if any."""
1362 task = self._reveal_playback_task
1363 self._reveal_playback_task = None
1364 if task is None:
1365 return
1366 task.cancel()
1367 with suppress(asyncio.CancelledError):
1368 await task
1369
1370 def _is_reveal_playback_current(
1371 self,
1372 game: MusicQuizGame,
1373 generation: int,
1374 round_index: int,
1375 ) -> bool:
1376 """
1377 Return whether reveal playback still belongs to the active round.
1378
1379 :param game: Game owning the playback.
1380 :param generation: Lifecycle generation owning the playback.
1381 :param round_index: Revealed round index.
1382 """
1383 return (
1384 self._game is game
1385 and self._game_generation == generation
1386 and game.phase == MusicQuizPhase.REVEAL
1387 and game.current_round_index == round_index
1388 )
1389
1390 async def _fetch_lyrics(self, track_uri: str) -> None:
1391 """Fetch lyrics for a prepared round."""
1392 try:
1393 track = await self.mass.music.get_item_by_uri(track_uri)
1394 if isinstance(track, Track):
1395 await self.mass.metadata.get_track_lyrics(track)
1396 except Exception as err:
1397 self.logger.debug("Lyrics prefetch failed for %s: %s", track_uri, err)
1398
1399 # ---------- playback ----------
1400
1401 async def _prepare_session_for_playback(self) -> SharedPlaybackSession:
1402 """Return the game's playback session with its guest listeners restored."""
1403 session = await self._get_playback_session()
1404 if session is None:
1405 raise MusicQuizNoPlaybackTargetError(
1406 "No playback target is available for the Music Quiz game"
1407 )
1408 player = self.mass.players.get_player(session.player_id)
1409 if player is None or not player.state.available:
1410 raise MusicQuizNoPlaybackTargetError(
1411 "No playback target is available for the Music Quiz game"
1412 )
1413 if player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
1414 raise MusicQuizNoPlaybackTargetError(
1415 "The Music Quiz playback target is handling an announcement"
1416 )
1417 async with self._playback_lock:
1418 if self._playback_session is not session:
1419 raise MusicQuizNoPlaybackTargetError(
1420 "No playback target is available for the Music Quiz game"
1421 )
1422 await session.restore_guest_listeners()
1423 return session
1424
1425 async def _play_track(self, track_uri: str) -> None:
1426 """Play the given track on the game's playback session."""
1427 with _system_auth_context():
1428 session = await self._prepare_session_for_playback()
1429 await self.mass.player_queues.play_media(
1430 session.queue_id, track_uri, option=QueueOption.REPLACE
1431 )
1432
1433 async def _enqueue_track(self, track_uri: str) -> None:
1434 """Append a track to the game's playback session queue without starting it."""
1435 with _system_auth_context():
1436 # don't create a session for a round that may never be reached
1437 session = self._playback_session
1438 if session is None:
1439 return
1440 player = self.mass.players.get_player(session.player_id)
1441 if player is None or not player.state.available:
1442 return
1443 if player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
1444 return
1445 await self.mass.player_queues.play_media(
1446 session.queue_id, track_uri, option=QueueOption.ADD
1447 )
1448
1449 async def _warm_next_track(self, task: asyncio.Task[MusicQuizRound]) -> None:
1450 """Queue the prefetched round's track so its stream details resolve before it plays."""
1451 try:
1452 game_round = await task
1453 except asyncio.CancelledError:
1454 raise
1455 except Exception:
1456 # a failed prefetch is reported when the round is actually requested
1457 return
1458 if game_round.track_uri is None:
1459 return
1460 try:
1461 await self._enqueue_track(game_round.track_uri)
1462 except MusicAssistantError as err:
1463 self.logger.debug("Could not pre-queue next Music Quiz track: %s", err)
1464 return
1465 with _system_auth_context():
1466 session = self._playback_session
1467 if session is None:
1468 return
1469 queue = self.mass.player_queues.get(session.queue_id)
1470 if queue is None or queue.current_item is None:
1471 return
1472 try:
1473 await self.mass.player_queues.load_next_queue_item(
1474 session.queue_id, queue.current_item.queue_item_id
1475 )
1476 except MusicAssistantError as err:
1477 # the round is resolved on demand instead if this best-effort warm-up misses
1478 self.logger.debug("Could not resolve next Music Quiz track: %s", err)
1479
1480 async def _advance_to_queued_track(self, track_uri: str) -> bool:
1481 """Start an already-queued track; False when the caller must fall back."""
1482 with _system_auth_context():
1483 session = self._playback_session
1484 if session is None:
1485 return False
1486 # match by uri: the queue's next-item helpers silently skip unplayable items
1487 queued_item = next(
1488 (
1489 item
1490 for item in self.mass.player_queues.items(session.queue_id)
1491 if item.uri == track_uri and item.available
1492 ),
1493 None,
1494 )
1495 if queued_item is None:
1496 return False
1497 # re-prepare per round like _play_track, or listen-in guests drop after round one
1498 if await self._prepare_session_for_playback() is not session:
1499 return False
1500 try:
1501 await self.mass.player_queues.play_index(
1502 session.queue_id, queued_item.queue_item_id
1503 )
1504 except (AudioError, MediaNotFoundError) as err:
1505 self.logger.warning(
1506 "Queued Music Quiz track %s could not start; falling back: %s",
1507 track_uri,
1508 err,
1509 )
1510 return False
1511 return True
1512
1513 async def _stop_playback(self) -> None:
1514 """Stop playback on the game's playback session, if any."""
1515 with _system_auth_context():
1516 if self._playback_session is None:
1517 return
1518 if self.mass.players.get_player(self._playback_session.player_id) is None:
1519 return
1520 try:
1521 await self.mass.player_queues.stop(self._playback_session.queue_id)
1522 except Exception as err:
1523 self.logger.warning("Could not stop Music Quiz playback: %s", err)
1524
1525 def _audio_started_at(self, game_round: MusicQuizRound) -> float:
1526 """
1527 Return the timestamp at which the round's track became audible.
1528
1529 :param game_round: Started round whose playback should be timed.
1530 """
1531 started_at = game_round.started_at or 0
1532 latency = ASSUMED_AUDIO_START_LATENCY
1533 if (reported := self._reported_playback_start()) is not None:
1534 reported_latency = reported - started_at
1535 if (
1536 MIN_REPORTED_AUDIO_START_LATENCY
1537 <= reported_latency
1538 <= MAX_REPORTED_AUDIO_START_LATENCY
1539 ):
1540 latency = reported_latency
1541 return started_at + max(latency, 0.0)
1542
1543 def _reported_playback_start(self) -> float | None:
1544 """Return when the playback target's reported position started, or None if it has none."""
1545 if (session := self._playback_session) is None:
1546 return None
1547 if (player := self.mass.players.get_player(session.player_id)) is None:
1548 return None
1549 queue = self.mass.player_queues.get(session.queue_id)
1550 # a flow stream reports its position across the whole queue rather than the
1551 # current track, so its anchor is not this round's track start
1552 if queue is not None and queue.flow_mode:
1553 return None
1554 elapsed = player.state.elapsed_time
1555 last_updated = player.state.elapsed_time_last_updated
1556 # providers report no position (Sendspin's play_media sets _attr_elapsed_time to None)
1557 # or a zero position with a fresh timestamp before real playback progress lands,
1558 # which would otherwise resolve to "audio started now"
1559 if not elapsed or last_updated is None:
1560 return None
1561 return last_updated - elapsed
1562
1563 async def _close_playback_session(self) -> None:
1564 """Close and drop the shared playback session under the playback lock."""
1565 # use the same lock that guards session creation/refresh so a concurrent
1566 # _get_playback_session() cannot resurrect a session we are tearing down
1567 async with self._playback_lock:
1568 await self._close_playback_session_locked()
1569
1570 async def _close_playback_session_locked(self) -> None:
1571 """Close and drop the shared playback session while holding the playback lock."""
1572 if self._playback_session is not None:
1573 await self._playback_session.close()
1574 self._playback_session = None
1575
1576 async def _get_playback_session(self) -> SharedPlaybackSession | None:
1577 """
1578 Get the shared playback session for the quiz, creating it if needed.
1579
1580 In remote mode the session is backed by a hidden virtual player; the
1581 session is (re)created here when that player does not exist (e.g.
1582 after a Sendspin provider reload). In venue mode a session only exists
1583 while the player selected for the game remains eligible.
1584
1585 :return: The session, or None when no session is available.
1586 """
1587 async with self._playback_lock:
1588 return await self._get_or_create_session_locked()
1589
1590 async def _get_or_create_session_locked(self) -> SharedPlaybackSession | None:
1591 """
1592 Get or (re)create the shared playback session.
1593
1594 The caller must hold ``_playback_lock``; guest listen-in and session
1595 teardown share the lock so a session is never created or joined while it
1596 is being closed.
1597
1598 :return: The session, or None when no game is active or none is available.
1599 """
1600 # a session only makes sense while a game is active; without one, never
1601 # (re)create it - this also stops a guest listen-in that races with game
1602 # teardown from leaking a fresh session / virtual player
1603 if (game := self._game) is None:
1604 return None
1605 if self._quiz_type is not None and not self._quiz_type.uses_audio:
1606 return None
1607 if self._quiz_type is None:
1608 quiz_type = get_quiz_type(game.quiz_type)(self.mass, game.config)
1609 if not quiz_type.uses_audio:
1610 return None
1611 # drop a stale session whose player no longer exists
1612 if self._playback_session is not None and (
1613 self.mass.players.get_player(self._playback_session.player_id) is None
1614 ):
1615 await self._playback_session.close()
1616 self._playback_session = None
1617
1618 if (
1619 self._playback_session is not None
1620 and game.config.playback_mode == SharedPlaybackMode.VENUE
1621 and (
1622 self._playback_session.player_id != game.config.venue_player_id
1623 or not self._is_eligible_venue_player_id(self._playback_session.player_id)
1624 )
1625 ):
1626 await self._playback_session.close()
1627 self._playback_session = None
1628
1629 if self._playback_session is not None:
1630 return self._playback_session
1631
1632 if game.config.playback_mode == SharedPlaybackMode.REMOTE:
1633 if not self._remote_playback_available():
1634 return None
1635 try:
1636 self._playback_session = await self._create_remote_playback_session()
1637 except SetupFailedError as err:
1638 self.logger.warning("Unable to create remote quiz session: %s", err)
1639 elif (player_id := game.config.venue_player_id) and self._is_eligible_venue_player_id(
1640 player_id
1641 ):
1642 try:
1643 self._playback_session = await SharedPlaybackSession.create_venue(
1644 self.mass, player_id
1645 )
1646 except SetupFailedError as err:
1647 self.logger.warning("Unable to create venue quiz session: %s", err)
1648 return self._playback_session
1649
1650 async def _create_remote_playback_session(self) -> SharedPlaybackSession:
1651 """Create the remote playback session for the active game."""
1652 game_name = self._game.config.name if self._game else None
1653 return await SharedPlaybackSession.create_remote(
1654 self.mass,
1655 owner_instance_id=self.instance_id,
1656 display_name=game_name or "Music Quiz",
1657 session_id=self.instance_id,
1658 )
1659
1660 async def _resolve_playback_defaults(self) -> _PlaybackDefaults:
1661 """Resolve current playback availability and the recommended defaults."""
1662 preference = await self._load_playback_preference()
1663 legacy_mode, legacy_player_id, _ = self._legacy_playback_preference()
1664 eligible_players = self._eligible_venue_players()
1665 venue_players: list[MusicQuizVenuePlayerOption] = [
1666 {"player_id": player.player_id, "name": player.display_name}
1667 for player in eligible_players
1668 ]
1669 eligible_player_ids = {player["player_id"] for player in venue_players}
1670 stored_venue_player_id = (
1671 preference["venue_player_id"] if preference is not None else legacy_player_id
1672 )
1673 default_venue_player_id = next(
1674 (
1675 player_id
1676 for player_id in (
1677 preference["venue_player_id"] if preference is not None else None,
1678 legacy_player_id,
1679 )
1680 if player_id in eligible_player_ids
1681 ),
1682 venue_players[0]["player_id"] if venue_players else None,
1683 )
1684 venue_available = bool(venue_players)
1685 remote_available = self._remote_playback_available()
1686 preferred_mode = (
1687 SharedPlaybackMode(preference["playback_mode"])
1688 if preference is not None
1689 else legacy_mode
1690 )
1691 if preferred_mode == SharedPlaybackMode.VENUE and venue_available:
1692 default_mode = SharedPlaybackMode.VENUE
1693 elif preferred_mode == SharedPlaybackMode.REMOTE and remote_available:
1694 default_mode = SharedPlaybackMode.REMOTE
1695 elif venue_available:
1696 default_mode = SharedPlaybackMode.VENUE
1697 elif remote_available:
1698 default_mode = SharedPlaybackMode.REMOTE
1699 else:
1700 default_mode = preferred_mode
1701 return _PlaybackDefaults(
1702 options={
1703 "default_playback_mode": default_mode.value,
1704 "default_venue_player_id": default_venue_player_id,
1705 "venue_available": venue_available,
1706 "remote_available": remote_available,
1707 "venue_players": venue_players,
1708 },
1709 stored_venue_player_id=stored_venue_player_id,
1710 )
1711
1712 def _resolve_create_playback(
1713 self,
1714 playback_mode: str | None,
1715 venue_player_id: str | None,
1716 options: MusicQuizPlaybackOptions,
1717 ) -> tuple[SharedPlaybackMode, str | None, str | None]:
1718 """
1719 Resolve and validate playback requested for a new game.
1720
1721 :param playback_mode: Requested playback mode, or None for the recommended default.
1722 :param venue_player_id: Requested venue player.
1723 :param options: Current playback options.
1724 :return: Effective mode, venue player ID and venue player name.
1725 """
1726 if playback_mode is None:
1727 mode = SharedPlaybackMode(options["default_playback_mode"])
1728 else:
1729 try:
1730 mode = SharedPlaybackMode(playback_mode)
1731 except ValueError as err:
1732 raise InvalidDataError(
1733 f"Unknown Music Quiz playback mode: {playback_mode}",
1734 translation_key="music_quiz_invalid_playback_mode",
1735 translation_owner=TRANSLATION_OWNER,
1736 ) from err
1737 if mode == SharedPlaybackMode.REMOTE:
1738 if not options["remote_available"]:
1739 raise MusicQuizNoPlaybackTargetError("Remote Music Quiz playback is not available")
1740 return mode, None, None
1741
1742 selected_player_id = venue_player_id
1743 if selected_player_id is None and playback_mode is None:
1744 selected_player_id = options["default_venue_player_id"]
1745 for player in options["venue_players"]:
1746 if player["player_id"] == selected_player_id:
1747 return mode, player["player_id"], player["name"]
1748 raise MusicQuizNoPlaybackTargetError(
1749 "The selected Music Quiz venue player is not available"
1750 )
1751
1752 def _validate_playback_config(self, config: MusicQuizConfig) -> Player | None:
1753 """
1754 Validate a game's playback target immediately before publication.
1755
1756 :param config: Game configuration to validate.
1757 :return: The selected venue player, or None for remote playback.
1758 """
1759 if config.playback_mode == SharedPlaybackMode.REMOTE:
1760 if not self._remote_playback_available():
1761 raise MusicQuizNoPlaybackTargetError("Remote Music Quiz playback is not available")
1762 return None
1763 if config.venue_player_id:
1764 for player in self._eligible_venue_players():
1765 if player.player_id == config.venue_player_id:
1766 return player
1767 raise MusicQuizNoPlaybackTargetError(
1768 "The selected Music Quiz venue player is not available"
1769 )
1770
1771 def _eligible_venue_players(self) -> list[Player]:
1772 """Return stable, visible and playable venue targets."""
1773 eligible_players = [
1774 player
1775 for player in self.mass.players.all_players(False, False)
1776 if self._is_eligible_venue_player(player)
1777 ]
1778 return sorted(
1779 eligible_players,
1780 key=lambda player: (player.display_name.casefold(), player.player_id),
1781 )
1782
1783 def _is_eligible_venue_player_id(self, player_id: str) -> bool:
1784 """
1785 Return whether a player remains eligible for venue playback.
1786
1787 :param player_id: Player to validate.
1788 """
1789 player = self.mass.players.get_player(player_id)
1790 return player is not None and self._is_eligible_venue_player(player)
1791
1792 def _is_eligible_venue_player(self, player: Player) -> bool:
1793 """
1794 Return whether a player is a real venue playback target.
1795
1796 :param player: Player to inspect.
1797 """
1798 state = player.state
1799 return (
1800 state.available
1801 and state.enabled
1802 and not state.hide_in_ui
1803 and not state.needs_setup
1804 and state.synced_to is None
1805 and state.active_group is None
1806 and state.type in PLAYBACK_TARGET_TYPES
1807 and any(protocol.available for protocol in state.output_protocols)
1808 and not is_remote_session_host(self.mass, player.player_id)
1809 )
1810
1811 def _remote_playback_available(self) -> bool:
1812 """Return whether remote playback can create its Sendspin session."""
1813 return self.mass.get_provider(SENDSPIN_DOMAIN) is not None
1814
1815 async def _load_playback_preference(self) -> _PlaybackPreference | None:
1816 """Return the valid persisted playback preference for this provider instance."""
1817 data = await self.mass.cache.get(
1818 PLAYBACK_PREFERENCE_CACHE_KEY,
1819 provider=self.instance_id,
1820 )
1821 if data is None:
1822 return None
1823 if not isinstance(data, dict):
1824 self.logger.warning("Ignoring invalid Music Quiz playback preference")
1825 return None
1826 playback_mode = data.get("playback_mode")
1827 venue_player_id = data.get("venue_player_id")
1828 valid_venue_player_id = venue_player_id is None or (
1829 isinstance(venue_player_id, str) and bool(venue_player_id)
1830 )
1831 if (
1832 playback_mode not in {mode.value for mode in SharedPlaybackMode}
1833 or not valid_venue_player_id
1834 ):
1835 self.logger.warning("Ignoring invalid Music Quiz playback preference")
1836 return None
1837 return {
1838 "playback_mode": cast("str", playback_mode),
1839 "venue_player_id": venue_player_id,
1840 }
1841
1842 async def _store_playback_preference(
1843 self,
1844 playback_mode: SharedPlaybackMode,
1845 venue_player_id: str | None,
1846 ) -> None:
1847 """
1848 Best-effort persist a successful playback selection for this provider instance.
1849
1850 :param playback_mode: Effective mode selected for the game.
1851 :param venue_player_id: Last successful venue player, if any.
1852 """
1853 try:
1854 await self.mass.cache.set(
1855 PLAYBACK_PREFERENCE_CACHE_KEY,
1856 {
1857 "playback_mode": playback_mode.value,
1858 "venue_player_id": venue_player_id,
1859 },
1860 expiration=PLAYBACK_PREFERENCE_CACHE_EXPIRATION,
1861 provider=self.instance_id,
1862 persistent=True,
1863 )
1864 except asyncio.CancelledError:
1865 raise
1866 except Exception as err:
1867 self.logger.warning("Could not persist Music Quiz playback preference: %s", err)
1868
1869 async def _migrate_legacy_playback_preference(self) -> None:
1870 """Persist legacy provider playback values when no preference exists yet."""
1871 if await self._load_playback_preference() is not None:
1872 return
1873 playback_mode, venue_player_id, has_legacy_value = self._legacy_playback_preference()
1874 if has_legacy_value:
1875 await self._store_playback_preference(playback_mode, venue_player_id)
1876
1877 def _legacy_playback_preference(self) -> tuple[SharedPlaybackMode, str | None, bool]:
1878 """Return compatible playback defaults from legacy provider settings."""
1879 raw_mode = self.config.get_value(CONF_MODE)
1880 raw_player_id = self.config.get_value(CONF_PLAYER)
1881 if isinstance(raw_mode, str):
1882 try:
1883 playback_mode = SharedPlaybackMode(raw_mode)
1884 except ValueError:
1885 playback_mode = SharedPlaybackMode.VENUE
1886 else:
1887 playback_mode = SharedPlaybackMode.VENUE
1888 venue_player_id = (
1889 raw_player_id
1890 if isinstance(raw_player_id, str)
1891 and raw_player_id
1892 and raw_player_id != CONF_PLAYER_AUTO
1893 else None
1894 )
1895 return playback_mode, venue_player_id, raw_mode is not None or raw_player_id is not None
1896
1897 # ---------- timers ----------
1898
1899 def _schedule_replay_auto_start(self, game: MusicQuizGame, now: float) -> None:
1900 """
1901 Schedule automatic replay startup for a lobby.
1902
1903 :param game: Lobby game to start.
1904 :param now: Current server timestamp.
1905 """
1906 auto_start_at = now + REPLAY_AUTO_START_SECONDS
1907 self.mass.call_later(
1908 REPLAY_AUTO_START_SECONDS,
1909 self._on_replay_auto_start,
1910 game,
1911 self._game_generation,
1912 auto_start_at,
1913 task_id=self._replay_auto_start_timer_id,
1914 )
1915 game.auto_start_at = auto_start_at
1916
1917 def _cancel_replay_auto_start(self, *, cancel_task: bool) -> None:
1918 """
1919 Cancel and clear automatic replay startup.
1920
1921 :param cancel_task: Also cancel a callback that already started.
1922 """
1923 had_countdown = self._game is not None and self._game.auto_start_at is not None
1924 self.mass.cancel_timer(self._replay_auto_start_timer_id)
1925 if cancel_task and had_countdown:
1926 self.mass.cancel_task(self._replay_auto_start_timer_id)
1927 if self._game is not None:
1928 self._game.auto_start_at = None
1929
1930 def _refresh_player_presence(
1931 self,
1932 player: MusicQuizPlayer,
1933 seen_at: float | None = None,
1934 ) -> None:
1935 """
1936 Refresh a player's reconnect grace period.
1937
1938 :param player: Player whose presence should be refreshed.
1939 :param seen_at: Server timestamp of the activity.
1940 """
1941 player.last_seen = seen_at if seen_at is not None else time.time()
1942 self._schedule_presence_expiry(player.last_seen)
1943
1944 def _schedule_presence_expiry(self, now: float | None = None) -> None:
1945 """
1946 Schedule the next inactive-player expiry.
1947
1948 :param now: Current server timestamp.
1949 """
1950 if (
1951 self._game is None
1952 or self._game.phase == MusicQuizPhase.FINISHED
1953 or not self._game.players
1954 ):
1955 self.mass.cancel_timer(self._presence_timer_id)
1956 return
1957 current_time = now if now is not None else time.time()
1958 expires_at = min(
1959 player.last_seen + PLAYER_RECONNECT_GRACE_SECONDS
1960 for player in self._game.players.values()
1961 )
1962 self.mass.call_later(
1963 max(expires_at - current_time, 0),
1964 self._expire_inactive_players,
1965 task_id=self._presence_timer_id,
1966 )
1967
1968 def _cancel_presence_expiry(self, *, cancel_task: bool = False) -> None:
1969 """
1970 Cancel scheduled player expiry work.
1971
1972 :param cancel_task: Also cancel an expiry callback that already started.
1973 """
1974 self.mass.cancel_timer(self._presence_timer_id)
1975 if cancel_task:
1976 self.mass.cancel_task(self._presence_timer_id)
1977
1978 @property
1979 def _reveal_timer_id(self) -> str:
1980 """Return the task_id of the answering deadline timer."""
1981 return f"music_quiz_reveal_{self.instance_id}"
1982
1983 @property
1984 def _advance_timer_id(self) -> str:
1985 """Return the task_id of the reveal auto-advance timer."""
1986 return f"music_quiz_advance_{self.instance_id}"
1987
1988 @property
1989 def _track_end_timer_id(self) -> str:
1990 """Return the task_id of the reveal-outlives-track stop timer."""
1991 return f"music_quiz_track_end_{self.instance_id}"
1992
1993 @property
1994 def _presence_timer_id(self) -> str:
1995 """Return the task_id of the player presence timer."""
1996 return f"music_quiz_presence_{self.instance_id}"
1997
1998 @property
1999 def _replay_auto_start_timer_id(self) -> str:
2000 """Return the task_id of the replay auto-start timer."""
2001 return f"music_quiz_replay_{self.instance_id}"
2002
2003 def _cancel_timers(self) -> None:
2004 """Cancel all scheduled game timers."""
2005 self.mass.cancel_timer(self._reveal_timer_id)
2006 self.mass.cancel_timer(self._advance_timer_id)
2007 self.mass.cancel_timer(self._track_end_timer_id)
2008 self._cancel_replay_auto_start(cancel_task=True)
2009 self._cancel_presence_expiry(cancel_task=True)
2010
2011
2012def _playback_selection_changed(previous: MusicQuizGame, current: MusicQuizGame) -> bool:
2013 """Return whether two games require different playback sessions."""
2014 return (
2015 previous.config.playback_mode != current.config.playback_mode
2016 or previous.config.venue_player_id != current.config.venue_player_id
2017 )
2018
2019
2020def _playback_summary(game: MusicQuizGame) -> MusicQuizPlaybackSummary:
2021 """Return the host-only summary of a game's playback selection."""
2022 is_venue = game.config.playback_mode == SharedPlaybackMode.VENUE
2023 return {
2024 "mode": game.config.playback_mode.value,
2025 "venue_player_id": game.config.venue_player_id if is_venue else None,
2026 "venue_player_name": game.config.venue_player_name if is_venue else None,
2027 }
2028
2029
2030def _clean_game_name(name: str | None) -> str | None:
2031 """Return a normalized optional game name."""
2032 if not name:
2033 return None
2034 return name.strip() or None
2035
2036
2037def _consume_task_exception(task: asyncio.Task[Any]) -> None:
2038 """Retrieve a settled task's exception so asyncio does not report it as unhandled."""
2039 if not task.cancelled():
2040 task.exception()
2041
2042
2043@contextmanager
2044def _system_auth_context() -> Iterator[None]:
2045 """Temporarily run provider-owned work without a requesting user."""
2046 current_user_token = current_user.set(None)
2047 impersonated_user_token = impersonated_user.set(None)
2048 try:
2049 yield
2050 finally:
2051 impersonated_user.reset(impersonated_user_token)
2052 current_user.reset(current_user_token)
2053
2054
2055def _get_join_round(game: MusicQuizGame) -> int:
2056 """Return the first round a newly joined player may answer."""
2057 if game.phase == MusicQuizPhase.LOBBY:
2058 return 0
2059 if game.current_round_index is None:
2060 return len(game.rounds)
2061 return game.current_round_index + 1
2062
2063
2064def _get_player(game: MusicQuizGame, player_id: str) -> MusicQuizPlayer:
2065 """Return a player by their private player_id."""
2066 if player := _find_player(game, player_id):
2067 return player
2068 raise MusicQuizUnknownPlayerError("Unknown Music Quiz player")
2069
2070
2071def _find_player(game: MusicQuizGame, player_id: str) -> MusicQuizPlayer | None:
2072 """Return a player by their private player_id, if present."""
2073 for player in game.players.values():
2074 if secrets.compare_digest(player.player_id, player_id):
2075 return player
2076 return None
2077
2078
2079def _is_player_active(player: MusicQuizPlayer, now: float) -> bool:
2080 """
2081 Return whether a player's reconnect grace period is active.
2082
2083 :param player: Player to inspect.
2084 :param now: Current server timestamp.
2085 """
2086 return player.last_seen + PLAYER_RECONNECT_GRACE_SECONDS > now
2087
2088
2089def _has_active_players(game: MusicQuizGame, now: float) -> bool:
2090 """
2091 Return whether the game has a player within the reconnect grace period.
2092
2093 :param game: Game to inspect.
2094 :param now: Current server timestamp.
2095 """
2096 return any(_is_player_active(player, now) for player in game.players.values())
2097
2098
2099def _answer_window(game: MusicQuizGame, game_round: MusicQuizRound) -> float:
2100 """Return the effective answering window of a round in seconds."""
2101 answer_window = float(game.config.answer_duration)
2102 if game_round.duration and game_round.duration > 0:
2103 answer_window = min(answer_window, game_round.duration)
2104 return answer_window
2105
2106
2107def _host_round(
2108 game_round: MusicQuizRound,
2109 answer_type: QuizAnswerType,
2110) -> dict[str, SerializableType]:
2111 """Return the host-visible flat representation of a round."""
2112 return {
2113 "round_index": game_round.round_index,
2114 "answer_label": game_round.answer_label,
2115 **answer_type.serialize_host_round(game_round.answer_state),
2116 "track_uri": game_round.track_uri,
2117 "question": game_round.question,
2118 "image_url": game_round.image_url,
2119 "duration": game_round.duration,
2120 "started_at": game_round.started_at,
2121 "audio_started_at": game_round.audio_started_at,
2122 "ended_at": game_round.ended_at,
2123 "auto_advance_at": game_round.auto_advance_at,
2124 }
2125
2126
2127def _public_state(
2128 game: MusicQuizGame,
2129 answer_type: QuizAnswerType,
2130 *,
2131 join_url: str | None = None,
2132) -> dict[str, Any]:
2133 """Return the guest-safe public game state (see the module docstring)."""
2134 current_round = (
2135 game.rounds[game.current_round_index] if game.current_round_index is not None else None
2136 )
2137 answer_state = current_round.answer_state if current_round else None
2138 revealed = game.phase in (MusicQuizPhase.REVEAL, MusicQuizPhase.FINISHED)
2139 players = []
2140 for player in sorted(game.players.values(), key=lambda item: item.joined_at):
2141 entry: dict[str, Any] = {
2142 "name": player.name,
2143 "score": player.score,
2144 "ready": player.ready,
2145 "active_from_round": player.active_from_round,
2146 **answer_type.serialize_public_player(
2147 answer_state,
2148 player.player_id,
2149 revealed=revealed,
2150 ),
2151 }
2152 players.append(entry)
2153 return {
2154 "phase": game.phase.value,
2155 "name": game.config.name,
2156 "quiz_type": game.quiz_type,
2157 "answer_type": game.answer_type.value,
2158 "mode": game.config.playback_mode.value,
2159 "round_count": game.config.round_count,
2160 "answer_duration": game.config.answer_duration,
2161 "auto_start_at": game.auto_start_at,
2162 "preparing": game.preparing,
2163 # omitted rather than empty while unresolved, so a display can hide its join QR
2164 **({"join_url": join_url} if join_url else {}),
2165 **answer_type.serialize_game_config(game),
2166 **get_quiz_type(game.quiz_type).serialize_game_config(game),
2167 "players": players,
2168 "current_round": _public_round(
2169 game,
2170 current_round,
2171 answer_type,
2172 revealed=revealed,
2173 ),
2174 }
2175
2176
2177def _public_round(
2178 game: MusicQuizGame,
2179 game_round: MusicQuizRound | None,
2180 answer_type: QuizAnswerType,
2181 *,
2182 revealed: bool,
2183) -> dict[str, Any] | None:
2184 """Return the guest-safe view of a round, redacted while unrevealed."""
2185 if game_round is None:
2186 return None
2187 state: dict[str, Any] = {
2188 "round_index": game_round.round_index,
2189 "started_at": game_round.started_at,
2190 "deadline": (game_round.started_at or 0) + _answer_window(game, game_round),
2191 "auto_advance_at": game_round.auto_advance_at,
2192 "question": game_round.question,
2193 **answer_type.serialize_round(game_round.answer_state, revealed=revealed),
2194 }
2195 if revealed:
2196 state["answer_label"] = game_round.answer_label
2197 state["track_uri"] = game_round.track_uri
2198 state["image_url"] = game_round.image_url
2199 state["duration"] = game_round.duration
2200 state["audio_started_at"] = game_round.audio_started_at
2201 state["ended_at"] = game_round.ended_at
2202 return state
2203
2204
2205def _player_state(
2206 game: MusicQuizGame,
2207 player: MusicQuizPlayer,
2208 answer_type: QuizAnswerType,
2209 *,
2210 join_url: str | None = None,
2211) -> dict[str, Any]:
2212 """Return the personalized (still guest-safe) game state for a player."""
2213 current_round = (
2214 game.rounds[game.current_round_index] if game.current_round_index is not None else None
2215 )
2216 answer_state = current_round.answer_state if current_round else None
2217 revealed = game.phase in (MusicQuizPhase.REVEAL, MusicQuizPhase.FINISHED)
2218 you: dict[str, Any] = {
2219 "name": player.name,
2220 "score": player.score,
2221 "ready": player.ready,
2222 "active_from_round": player.active_from_round,
2223 **answer_type.serialize_personal_player(
2224 answer_state,
2225 player.player_id,
2226 revealed=revealed,
2227 ),
2228 }
2229 return {**_public_state(game, answer_type, join_url=join_url), "you": you}
2230