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