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