/
/
/
1"""Data models for the Music Quiz provider."""
2
3from __future__ import annotations
4
5from dataclasses import dataclass, field
6from enum import StrEnum
7from typing import Literal, TypedDict
8
9from mashumaro import DataClassDictMixin, field_options
10from mashumaro.config import BaseConfig
11from mashumaro.types import Discriminator
12
13from music_assistant.helpers.shared_playback import SharedPlaybackMode
14
15DEFAULT_TRIVIA_LANGUAGE = "en"
16
17
18class MusicQuizPhase(StrEnum):
19 """Music Quiz game phases."""
20
21 LOBBY = "lobby"
22 ANSWERING = "answering"
23 REVEAL = "reveal"
24 FINISHED = "finished"
25
26
27class MusicQuizAnswerType(StrEnum):
28 """Supported Music Quiz answer types."""
29
30 MULTIPLE_CHOICE = "multiple_choice"
31 TIMELINE = "timeline"
32
33
34class MusicQuizDifficulty(StrEnum):
35 """Difficulty levels for the guess-the-song quiz type."""
36
37 EASY = "easy"
38 NORMAL = "normal"
39 HARD = "hard"
40
41
42class TimelineBonusMode(StrEnum):
43 """Supported timeline bonus answer modes."""
44
45 OFF = "off"
46 FREE_TEXT = "free_text"
47 MULTIPLE_CHOICE = "multiple_choice"
48
49
50class TimelineBonusType(StrEnum):
51 """Supported timeline bonus identities."""
52
53 ARTIST = "artist"
54 TITLE = "title"
55
56
57class MusicQuizVenuePlayerOption(TypedDict):
58 """A venue player available for Music Quiz playback."""
59
60 player_id: str
61 name: str
62
63
64class MusicQuizPlaybackOptions(TypedDict):
65 """Host-visible options for configuring Music Quiz playback."""
66
67 default_playback_mode: str
68 default_venue_player_id: str | None
69 venue_available: bool
70 remote_available: bool
71 venue_players: list[MusicQuizVenuePlayerOption]
72
73
74class MusicQuizPlaybackSummary(TypedDict):
75 """Host-only summary of a game's playback selection."""
76
77 mode: str
78 venue_player_id: str | None
79 venue_player_name: str | None
80
81
82@dataclass
83class MusicQuizConfig(DataClassDictMixin):
84 """Configuration for a Music Quiz game."""
85
86 round_count: int = 5
87 suggestion_count: int = 4
88 answer_duration: int = 30
89 source_uris: list[str] = field(default_factory=list)
90 include_similar_music: bool = False
91 name: str | None = None
92 playback_mode: SharedPlaybackMode = SharedPlaybackMode.VENUE
93 venue_player_id: str | None = None
94 venue_player_name: str | None = None
95 # difficulty is guess-the-song specific; AI distractors also apply to timeline bonuses
96 difficulty: str = MusicQuizDifficulty.NORMAL.value
97 use_ai_distractors: bool = False
98 # the AI engine uid selected in the provider config, or None when no engine is available
99 ai_engine: str | None = None
100 # trivia specific; other quiz types ignore this
101 language: str = DEFAULT_TRIVIA_LANGUAGE
102 play_reveal_audio: bool = True
103 # timeline specific; other answer types ignore these
104 artist_bonus_mode: TimelineBonusMode = TimelineBonusMode.OFF
105 title_bonus_mode: TimelineBonusMode = TimelineBonusMode.OFF
106
107
108@dataclass
109class MusicQuizSource(DataClassDictMixin):
110 """A music source selected for a Music Quiz game."""
111
112 uri: str
113 name: str
114 media_type: str | None = None
115
116
117@dataclass
118class MusicQuizPlayer(DataClassDictMixin):
119 """A player participating in a Music Quiz game."""
120
121 # the player_id doubles as the player's private credential: it is only
122 # ever returned to the guest that joined and must never appear in
123 # broadcast payloads (those key players by their unique display name)
124 player_id: str
125 name: str
126 joined_at: float
127 active_from_round: int
128 score: int = 0
129 ready: bool = False
130 last_seen: float = field(
131 default=0,
132 compare=False,
133 repr=False,
134 metadata=field_options(serialize="omit"),
135 )
136
137
138@dataclass
139class QuizRoundAnswerState(DataClassDictMixin):
140 """Answer state persisted for a Music Quiz round."""
141
142 answer_type: MusicQuizAnswerType
143
144 class Config(BaseConfig):
145 """Mashumaro configuration."""
146
147 discriminator = Discriminator(field="answer_type", include_subtypes=True)
148 forbid_extra_keys = True
149
150
151@dataclass
152class MultipleChoiceSuggestion(DataClassDictMixin):
153 """A possible answer for a multiple-choice round."""
154
155 suggestion_id: str
156 label: str
157 uri: str | None = None
158 is_correct: bool = False
159
160 class Config(BaseConfig):
161 """Mashumaro configuration."""
162
163 forbid_extra_keys = True
164
165
166@dataclass
167class MultipleChoiceAnswer(DataClassDictMixin):
168 """A locked player answer for a multiple-choice round."""
169
170 player_id: str
171 suggestion_id: str
172 answered_at: float
173 is_correct: bool
174 points: int = 0
175
176 class Config(BaseConfig):
177 """Mashumaro configuration."""
178
179 forbid_extra_keys = True
180
181
182@dataclass
183class MultipleChoiceRoundState(QuizRoundAnswerState):
184 """Persisted state for a multiple-choice round."""
185
186 answer_type: Literal[MusicQuizAnswerType.MULTIPLE_CHOICE] = field(
187 default=MusicQuizAnswerType.MULTIPLE_CHOICE,
188 init=False,
189 )
190 suggestions: list[MultipleChoiceSuggestion]
191 answers: dict[str, MultipleChoiceAnswer] = field(default_factory=dict)
192
193
194@dataclass
195class TimelineEntry(DataClassDictMixin):
196 """A revealed entry on a chronological music timeline."""
197
198 entry_id: str
199 release_year: int
200 title: str
201 artist: str
202 track_uri: str
203 image_url: str | None
204 is_anchor: bool = False
205
206 class Config(BaseConfig):
207 """Mashumaro configuration."""
208
209 forbid_extra_keys = True
210
211
212@dataclass
213class TimelinePlacementAnswer(DataClassDictMixin):
214 """A player's locked placement against a timeline snapshot."""
215
216 previous_entry_id: str | None
217 next_entry_id: str | None
218 answered_at: float
219
220 class Config(BaseConfig):
221 """Mashumaro configuration."""
222
223 forbid_extra_keys = True
224
225
226@dataclass
227class TimelineCandidate(DataClassDictMixin):
228 """Protected candidate and accepted truths for a timeline round."""
229
230 entry: TimelineEntry
231 artist_answers: list[str]
232 title_answers: list[str]
233
234 class Config(BaseConfig):
235 """Mashumaro configuration."""
236
237 forbid_extra_keys = True
238
239
240@dataclass
241class TimelineBonusOption(DataClassDictMixin):
242 """A possible answer for a multiple-choice timeline bonus."""
243
244 option_id: str
245 label: str
246 is_correct: bool = False
247
248 class Config(BaseConfig):
249 """Mashumaro configuration."""
250
251 forbid_extra_keys = True
252
253
254@dataclass
255class TimelineBonusDefinition(DataClassDictMixin):
256 """Protected definition of one enabled timeline bonus."""
257
258 bonus_type: TimelineBonusType
259 mode: TimelineBonusMode
260
261 class Config(BaseConfig):
262 """Mashumaro configuration."""
263
264 discriminator = Discriminator(field="mode", include_subtypes=True)
265 forbid_extra_keys = True
266
267
268@dataclass
269class TimelineFreeTextBonusDefinition(TimelineBonusDefinition):
270 """Definition of a free-text timeline bonus."""
271
272 mode: Literal[TimelineBonusMode.FREE_TEXT] = field(
273 default=TimelineBonusMode.FREE_TEXT,
274 init=False,
275 )
276
277
278@dataclass
279class TimelineMultipleChoiceBonusDefinition(TimelineBonusDefinition):
280 """Definition of a multiple-choice timeline bonus."""
281
282 mode: Literal[TimelineBonusMode.MULTIPLE_CHOICE] = field(
283 default=TimelineBonusMode.MULTIPLE_CHOICE,
284 init=False,
285 )
286 options: list[TimelineBonusOption]
287
288
289@dataclass
290class TimelineBonusAnswer(DataClassDictMixin):
291 """A player's persisted answer to one timeline bonus."""
292
293 bonus_type: TimelineBonusType
294 action: str
295 submitted_at: float
296
297 class Config(BaseConfig):
298 """Mashumaro configuration."""
299
300 discriminator = Discriminator(field="action", include_subtypes=True)
301 forbid_extra_keys = True
302
303
304@dataclass
305class TimelineTextBonusAnswer(TimelineBonusAnswer):
306 """A persisted free-text timeline bonus answer."""
307
308 action: Literal["bonus_text"] = field(default="bonus_text", init=False)
309 value: str
310
311
312@dataclass
313class TimelineChoiceBonusAnswer(TimelineBonusAnswer):
314 """A persisted multiple-choice timeline bonus answer."""
315
316 action: Literal["bonus_choice"] = field(default="bonus_choice", init=False)
317 option_id: str
318
319
320@dataclass
321class TimelinePlacementResult(DataClassDictMixin):
322 """Result of a player's timeline placement."""
323
324 previous_entry_id: str | None
325 next_entry_id: str | None
326 is_correct: bool
327 points: int
328
329 class Config(BaseConfig):
330 """Mashumaro configuration."""
331
332 forbid_extra_keys = True
333
334
335@dataclass
336class TimelineBonusResult(DataClassDictMixin):
337 """Result of a player's submitted timeline bonus."""
338
339 bonus_type: TimelineBonusType
340 is_correct: bool
341 points: int
342
343 class Config(BaseConfig):
344 """Mashumaro configuration."""
345
346 forbid_extra_keys = True
347
348
349@dataclass
350class TimelineAnswerResult(DataClassDictMixin):
351 """Revealed result of a player's timeline answer."""
352
353 placement: TimelinePlacementResult
354 bonuses: list[TimelineBonusResult] = field(default_factory=list)
355
356 class Config(BaseConfig):
357 """Mashumaro configuration."""
358
359 forbid_extra_keys = True
360
361
362@dataclass
363class TimelineRoundState(QuizRoundAnswerState):
364 """Persisted state for a timeline round."""
365
366 answer_type: Literal[MusicQuizAnswerType.TIMELINE] = field(
367 default=MusicQuizAnswerType.TIMELINE,
368 init=False,
369 )
370 placement_snapshot: list[TimelineEntry]
371 candidate: TimelineCandidate
372 bonus_definitions: list[TimelineBonusDefinition] = field(default_factory=list)
373 placements: dict[str, TimelinePlacementAnswer] = field(default_factory=dict)
374 bonus_answers: dict[str, list[TimelineBonusAnswer]] = field(default_factory=dict)
375 finished_at: dict[str, float] = field(default_factory=dict)
376 results: dict[str, TimelineAnswerResult] = field(default_factory=dict)
377 revealed: bool = False
378
379
380@dataclass
381class MusicQuizRound(DataClassDictMixin):
382 """A single Music Quiz round."""
383
384 round_index: int
385 answer_label: str
386 answer_state: QuizRoundAnswerState
387 # a round may play its track while answering or after reveal, and/or pose
388 # a text question; fully text-only rounds leave track_uri unset
389 track_uri: str | None = None
390 question: str | None = None
391 image_url: str | None = None
392 duration: float | None = None
393 started_at: float | None = None
394 # when the round's track became audible; only set for rounds that play a
395 # track while answering
396 audio_started_at: float | None = None
397 ended_at: float | None = None
398 auto_advance_at: float | None = None
399
400 class Config(BaseConfig):
401 """Mashumaro configuration."""
402
403 forbid_extra_keys = True
404
405
406@dataclass
407class MusicQuizGame(DataClassDictMixin):
408 """A Music Quiz game."""
409
410 config: MusicQuizConfig
411 quiz_type: str
412 answer_type: MusicQuizAnswerType
413 phase: MusicQuizPhase = MusicQuizPhase.LOBBY
414 created_at: float = 0
415 auto_start_at: float | None = None
416 # set while a reset loads the sources and first round of the next run
417 preparing: bool = False
418 players: dict[str, MusicQuizPlayer] = field(default_factory=dict)
419 rounds: list[MusicQuizRound] = field(default_factory=list)
420 sources: list[MusicQuizSource] = field(default_factory=list)
421 current_round_index: int | None = None
422