/
/
/
1"""
2Party Plugin Provider for Music Assistant.
3
4Provides guest access with a shareable URL, allowing guests
5to add songs to the queue with configurable rate limiting.
6"""
7
8from __future__ import annotations
9
10import asyncio
11from collections.abc import Callable
12from dataclasses import dataclass
13from typing import TYPE_CHECKING, Any, cast
14
15from mashumaro import DataClassDictMixin
16from music_assistant_models.auth import Scope
17from music_assistant_models.config_entries import (
18 ConfigEntry,
19 ConfigValueOption,
20 ProviderConfig,
21)
22from music_assistant_models.enums import ConfigEntryType, MediaType, PlaybackState, ProviderFeature
23from music_assistant_models.errors import InvalidDataError, SetupFailedError
24
25from music_assistant.controllers.player_queues.helpers import build_queue_item
26from music_assistant.helpers import guest_access
27from music_assistant.helpers.config_entries import PLAYBACK_TARGET_TYPES
28from music_assistant.helpers.shared_playback import SharedPlaybackMode, SharedPlaybackSession
29from music_assistant.models.plugin import PluginProvider
30
31if TYPE_CHECKING:
32 from music_assistant_models.provider import ProviderManifest
33 from music_assistant_models.queue_item import QueueItem
34
35 from music_assistant.mass import MusicAssistant
36 from music_assistant.models import ProviderInstanceType
37
38# Configuration keys
39CONF_ENABLE_GUEST_ACCESS = "enable_guest_access"
40CONF_PARTY_MODE = "mode"
41CONF_PARTY_PLAYER = "player"
42CONF_PARTY_PLAYER_AUTO = "__auto__"
43CONF_ENABLE_RATE_LIMITING = "enable_rate_limiting"
44# Boost song feature
45CONF_ENABLE_BOOST = "enable_boost"
46CONF_PARTY_BOOST_LIMIT = "boost_limit"
47CONF_PARTY_BOOST_REFILL_MINUTES = "boost_refill_minutes"
48# Add song to Queue feature
49CONF_ENABLE_ADD_QUEUE = "enable_add_queue"
50CONF_PARTY_ADD_QUEUE_LIMIT = "add_queue_limit"
51CONF_PARTY_ADD_QUEUE_REFILL_MINUTES = "add_queue_refill_minutes"
52# Skip Song feature
53CONF_ENABLE_SKIP_SONG = "enable_skip_song"
54CONF_PARTY_SKIP_SONG_LIMIT = "skip_song_limit"
55CONF_PARTY_SKIP_SONG_REFILL_MINUTES = "skip_song_refill_minutes"
56# Badge color configuration
57CONF_REQUEST_BADGE_COLOR = "request_badge_color"
58CONF_BOOST_BADGE_COLOR = "boost_badge_color"
59# Lyrics / Karaoke
60CONF_PARTY_KARAOKE_MODE = "karaoke_mode"
61CONF_PARTY_HIGHLIGHT_AHEAD = "highlight_ahead"
62# Anti burn-in
63CONF_ANTI_BURN_IN = "anti_burn_in"
64# Custom party settings
65CONF_PARTY_NAME = "party_name"
66CONF_PARTY_QR_TEXT = "qr_text"
67CONF_PARTY_DURATION = "party_duration"
68CONF_HIDE_BACK_BUTTON = "hide_back_button"
69CONF_SHOW_PROGRESS_BAR = "show_progress_bar"
70CONF_PREVENT_DUPLICATE_TRACKS = "prevent_duplicate_tracks"
71
72# Color options for badges (name, hex value)
73# Green and Orange are listed first as they are the defaults
74BADGE_COLOR_OPTIONS = [
75 ("Green", "#2D6A4F"),
76 ("Orange", "#B55522"),
77 ("Magenta", "#E91E63"),
78 ("Pink", "#F06292"),
79 ("Purple", "#9C27B0"),
80 ("Deep Purple", "#673AB7"),
81 ("Indigo", "#3F51B5"),
82 ("Cyan", "#00BCD4"),
83 ("Teal", "#009688"),
84 ("Green", "#4CAF50"),
85 ("Lime", "#8BC34A"),
86 ("Deep Orange", "#E64A19"),
87 ("Amber", "#FFC107"),
88 ("Yellow", "#FFEB3B"),
89]
90
91# Guest user configuration
92PARTY_GUEST_USER = "party_guest"
93PARTY_GUEST_DISPLAY_NAME = "Party Guest"
94
95# Extra attribute keys for tracking guest items in the queue
96ATTR_PARTY_GUEST = "party_guest"
97ATTR_PARTY_BOOSTED = "party_boosted"
98
99SUPPORTED_FEATURES: set[ProviderFeature] = set()
100
101
102@dataclass
103class PartyConfig(DataClassDictMixin):
104 """Configuration data returned to the party guest frontend."""
105
106 # Feature toggles
107 enable_rate_limiting: bool
108 enable_add_queue: bool
109 enable_boost: bool
110 enable_skip_song: bool
111 # Add to Queue rate limiting
112 add_queue_limit: int
113 add_queue_refill_minutes: int
114 # Boost rate limiting
115 boost_limit: int
116 boost_refill_minutes: int
117 # Skip Song rate limiting
118 skip_song_limit: int
119 skip_song_refill_minutes: int
120 # UI settings
121 karaoke_mode: bool
122 highlight_ahead: bool
123 # Badge colors (hex values)
124 request_badge_color: str
125 boost_badge_color: str
126 # Anti burn-in
127 anti_burn_in: bool
128 # Custom party settings
129 party_name: str | None
130 qr_text: str | None
131 hide_back_button: bool
132 show_progress_bar: bool
133 prevent_duplicate_tracks: bool
134 # Shared playback mode: "venue" (a real player plays out loud) or "remote"
135 # (silent disco; every guest device is its own receiver). Drives the listen-in default.
136 mode: str
137
138
139async def setup(
140 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
141) -> ProviderInstanceType:
142 """Initialize provider(instance) with given configuration."""
143 return PartyPlugin(mass, manifest, config, SUPPORTED_FEATURES)
144
145
146class PartyPlugin(PluginProvider):
147 """Party plugin provider for Music Assistant."""
148
149 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
150 """Return Config entries to configure this provider."""
151 guest_access_enabled = bool(self.get_config_value(CONF_ENABLE_GUEST_ACCESS, False))
152
153 return (
154 ConfigEntry(
155 key=CONF_PARTY_MODE,
156 type=ConfigEntryType.STRING,
157 required=True,
158 default_value=SharedPlaybackMode.VENUE.value,
159 options=[
160 ConfigValueOption(SharedPlaybackMode.VENUE.value),
161 ConfigValueOption(SharedPlaybackMode.REMOTE.value),
162 ],
163 ),
164 ConfigEntry(
165 key=CONF_PARTY_PLAYER,
166 type=ConfigEntryType.STRING,
167 required=False,
168 default_value=CONF_PARTY_PLAYER_AUTO,
169 depends_on=CONF_PARTY_MODE,
170 depends_on_value=SharedPlaybackMode.VENUE.value,
171 options=[
172 ConfigValueOption(CONF_PARTY_PLAYER_AUTO),
173 *[
174 ConfigValueOption(player.player_id, title=player.display_name)
175 for player in sorted(
176 self.mass.players.all_players(False, False),
177 key=lambda p: p.display_name.lower(),
178 )
179 if player.type in PLAYBACK_TARGET_TYPES
180 ],
181 ],
182 ),
183 ConfigEntry(
184 key=CONF_PARTY_NAME,
185 type=ConfigEntryType.STRING,
186 default_value="",
187 required=False,
188 ),
189 ConfigEntry(
190 key=CONF_PARTY_DURATION,
191 type=ConfigEntryType.INTEGER,
192 default_value=guest_access.DEFAULT_JOIN_CODE_EXPIRY_HOURS,
193 range=(1, 168),
194 advanced=True,
195 ),
196 ConfigEntry(
197 key=CONF_ENABLE_GUEST_ACCESS,
198 type=ConfigEntryType.BOOLEAN,
199 default_value=False,
200 immediate_apply=True,
201 ),
202 # Guest access disabled state
203 ConfigEntry(
204 key="guest_disabled_note",
205 type=ConfigEntryType.LABEL,
206 required=False,
207 hidden=guest_access_enabled,
208 ),
209 # Guest access enabled state
210 ConfigEntry(
211 key="guest_enabled_note",
212 type=ConfigEntryType.ALERT,
213 required=False,
214 hidden=not guest_access_enabled,
215 ),
216 ConfigEntry(
217 key=CONF_PARTY_QR_TEXT,
218 type=ConfigEntryType.STRING,
219 default_value="",
220 required=False,
221 depends_on=CONF_ENABLE_GUEST_ACCESS,
222 ),
223 ConfigEntry(
224 key=CONF_HIDE_BACK_BUTTON,
225 type=ConfigEntryType.BOOLEAN,
226 default_value=False,
227 advanced=True,
228 ),
229 ConfigEntry(
230 key=CONF_SHOW_PROGRESS_BAR,
231 type=ConfigEntryType.BOOLEAN,
232 default_value=False,
233 advanced=True,
234 ),
235 ConfigEntry(
236 key=CONF_PARTY_KARAOKE_MODE,
237 type=ConfigEntryType.BOOLEAN,
238 default_value=False,
239 category="karaoke",
240 ),
241 ConfigEntry(
242 key=CONF_PARTY_HIGHLIGHT_AHEAD,
243 type=ConfigEntryType.BOOLEAN,
244 default_value=True,
245 depends_on=CONF_PARTY_KARAOKE_MODE,
246 category="karaoke",
247 advanced=True,
248 ),
249 ConfigEntry(
250 key=CONF_ANTI_BURN_IN,
251 type=ConfigEntryType.BOOLEAN,
252 default_value=True,
253 depends_on=CONF_ENABLE_GUEST_ACCESS,
254 advanced=True,
255 ),
256 ConfigEntry(
257 key=CONF_ENABLE_RATE_LIMITING,
258 type=ConfigEntryType.BOOLEAN,
259 default_value=True,
260 depends_on=CONF_ENABLE_GUEST_ACCESS,
261 advanced=True,
262 category="guest_features",
263 ),
264 # Add to Queue feature
265 ConfigEntry(
266 key=CONF_ENABLE_ADD_QUEUE,
267 type=ConfigEntryType.BOOLEAN,
268 default_value=True,
269 depends_on=CONF_ENABLE_GUEST_ACCESS,
270 advanced=True,
271 category="guest_features",
272 ),
273 ConfigEntry(
274 key=CONF_PREVENT_DUPLICATE_TRACKS,
275 type=ConfigEntryType.BOOLEAN,
276 default_value=True,
277 depends_on=CONF_ENABLE_ADD_QUEUE,
278 advanced=True,
279 category="guest_features",
280 ),
281 ConfigEntry(
282 key=CONF_PARTY_ADD_QUEUE_LIMIT,
283 type=ConfigEntryType.INTEGER,
284 default_value=10,
285 depends_on=CONF_ENABLE_ADD_QUEUE,
286 range=(1, 50),
287 advanced=True,
288 category="guest_features",
289 ),
290 ConfigEntry(
291 key=CONF_PARTY_ADD_QUEUE_REFILL_MINUTES,
292 type=ConfigEntryType.INTEGER,
293 default_value=2,
294 depends_on=CONF_ENABLE_ADD_QUEUE,
295 range=(1, 60),
296 advanced=True,
297 category="guest_features",
298 ),
299 # Boost feature (priority queue jumping)
300 ConfigEntry(
301 key=CONF_ENABLE_BOOST,
302 type=ConfigEntryType.BOOLEAN,
303 default_value=True,
304 depends_on=CONF_ENABLE_GUEST_ACCESS,
305 advanced=True,
306 category="guest_features",
307 ),
308 ConfigEntry(
309 key=CONF_PARTY_BOOST_LIMIT,
310 type=ConfigEntryType.INTEGER,
311 default_value=1,
312 depends_on=CONF_ENABLE_BOOST,
313 range=(1, 10),
314 advanced=True,
315 category="guest_features",
316 ),
317 ConfigEntry(
318 key=CONF_PARTY_BOOST_REFILL_MINUTES,
319 type=ConfigEntryType.INTEGER,
320 default_value=20,
321 depends_on=CONF_ENABLE_BOOST,
322 range=(5, 120),
323 advanced=True,
324 category="guest_features",
325 ),
326 # Skip Song feature
327 ConfigEntry(
328 key=CONF_ENABLE_SKIP_SONG,
329 type=ConfigEntryType.BOOLEAN,
330 default_value=False,
331 depends_on=CONF_ENABLE_GUEST_ACCESS,
332 advanced=True,
333 category="guest_features",
334 ),
335 ConfigEntry(
336 key=CONF_PARTY_SKIP_SONG_LIMIT,
337 type=ConfigEntryType.INTEGER,
338 default_value=1,
339 depends_on=CONF_ENABLE_SKIP_SONG,
340 range=(1, 5),
341 advanced=True,
342 category="guest_features",
343 ),
344 ConfigEntry(
345 key=CONF_PARTY_SKIP_SONG_REFILL_MINUTES,
346 type=ConfigEntryType.INTEGER,
347 default_value=60,
348 depends_on=CONF_ENABLE_SKIP_SONG,
349 range=(15, 180),
350 advanced=True,
351 category="guest_features",
352 ),
353 # Badge color configuration
354 ConfigEntry(
355 key=CONF_REQUEST_BADGE_COLOR,
356 type=ConfigEntryType.STRING,
357 default_value="#2D6A4F", # Green
358 depends_on=CONF_ENABLE_GUEST_ACCESS,
359 options=[
360 ConfigValueOption(value, title=name) for name, value in BADGE_COLOR_OPTIONS
361 ],
362 advanced=True,
363 ),
364 ConfigEntry(
365 key=CONF_BOOST_BADGE_COLOR,
366 type=ConfigEntryType.STRING,
367 default_value="#B55522", # Orange
368 depends_on=CONF_ENABLE_GUEST_ACCESS,
369 options=[
370 ConfigValueOption(value, title=name) for name, value in BADGE_COLOR_OPTIONS
371 ],
372 advanced=True,
373 ),
374 )
375
376 def __init__(
377 self,
378 mass: MusicAssistant,
379 manifest: ProviderManifest,
380 config: ProviderConfig,
381 supported_features: set[ProviderFeature],
382 ) -> None:
383 """Initialize the Party plugin."""
384 super().__init__(mass, manifest, config, supported_features)
385 self._unregister_handles: list[Callable[[], None]] = []
386 self._queue_lock = asyncio.Lock()
387 self._session: SharedPlaybackSession | None = None
388 self._session_lock = asyncio.Lock()
389
390 async def loaded_in_mass(self) -> None:
391 """Call after the provider has been loaded."""
392 # Register API commands and store unregister handles
393 # PROVIDERS_READ (held by guests) lets a guest fetch the join URL to display/share
394 # the QR; get_party_url returns None when guest access is disabled, so nothing leaks.
395 self._unregister_handles.append(
396 self.mass.register_api_command(
397 "party/url", self.get_party_url, required_scope=Scope.PROVIDERS_READ
398 )
399 )
400 self._unregister_handles.append(
401 self.mass.register_api_command(
402 "party/player", self.get_party_player, required_scope=Scope.PLAYERS_READ
403 )
404 )
405 self._unregister_handles.append(
406 self.mass.register_api_command(
407 "party/config", self.get_party_config, required_scope=Scope.PROVIDERS_READ
408 )
409 )
410 # Guest action commands - these are called by the guest frontend
411 self._unregister_handles.append(
412 self.mass.register_api_command(
413 "party/add_to_queue", self.add_to_queue, required_scope=Scope.QUEUES_CONTROL
414 )
415 )
416 self._unregister_handles.append(
417 self.mass.register_api_command(
418 "party/boost_queue_item", self.boost_queue_item, required_scope=Scope.QUEUES_CONTROL
419 )
420 )
421 self._unregister_handles.append(
422 self.mass.register_api_command(
423 "party/skip", self.skip_current, required_scope=Scope.QUEUES_CONTROL
424 )
425 )
426 self._unregister_handles.append(
427 self.mass.register_api_command(
428 "party/listen_in", self.listen_in, required_scope=Scope.PLAYERS_CONTROL
429 )
430 )
431 self._unregister_handles.append(
432 self.mass.register_api_command(
433 "party/stop_listen_in",
434 self.stop_listen_in,
435 required_scope=Scope.PLAYERS_CONTROL,
436 )
437 )
438 self._unregister_handles.append(
439 self.mass.register_api_command(
440 "party/can_listen_in", self.can_listen_in, required_scope=Scope.PLAYERS_CONTROL
441 )
442 )
443
444 async def unload(self, is_removed: bool = False) -> None:
445 """
446 Call when the provider is being unloaded.
447
448 :param is_removed: Whether the provider is being removed (vs just reloaded).
449 """
450 self.logger.debug("Party unload called, is_removed=%s", is_removed)
451
452 # Unregister all API commands
453 for unregister in self._unregister_handles:
454 unregister()
455 self._unregister_handles.clear()
456
457 # Tear down the shared playback session (detaches guest listeners and,
458 # in remote mode, removes the virtual player)
459 async with self._session_lock:
460 if self._session is not None:
461 await self._session.close()
462 self._session = None
463
464 # Revoke all guest tokens when:
465 # 1. The plugin is being removed entirely (is_removed=True)
466 # 2. Guest access is disabled in config (provider reload with disabled setting)
467 # This ensures guests are immediately disconnected when access is revoked
468 # Note: We read the LIVE stored value, which also covers reloads that are triggered
469 # outside of a config save. The default must match the config entry's default_value:
470 # only values that differ from their default are persisted, so switching guest access
471 # off drops the key entirely.
472 guest_access_enabled = self.mass.config.get_raw_provider_config_value(
473 self.instance_id, CONF_ENABLE_GUEST_ACCESS, default=False
474 )
475 if is_removed or not guest_access_enabled:
476 self.logger.debug("Revoking guest tokens...")
477 await self._revoke_guest_tokens()
478
479 await super().unload(is_removed)
480
481 # ==================== Configuration API Commands ====================
482
483 async def get_party_url(self) -> str | None:
484 """
485 Get the guest access URL for party.
486
487 When remote access is enabled, returns a URL that works from anywhere via WebRTC.
488 Otherwise, returns a local URL that only works on the same network.
489
490 :returns: The guest join URL, or None if guest access is disabled.
491 """
492 if not self.config.get_value(CONF_ENABLE_GUEST_ACCESS):
493 return None
494
495 guest_user = await guest_access.get_or_create_guest_user(
496 self.mass, PARTY_GUEST_USER, PARTY_GUEST_DISPLAY_NAME
497 )
498 code = await guest_access.get_or_create_join_code(
499 self.mass,
500 guest_user,
501 device_name="Party Guest",
502 expires_in_hours=cast("int", self.config.get_value(CONF_PARTY_DURATION)),
503 )
504 return guest_access.build_join_url(self.mass, code)
505
506 async def get_party_player(self) -> str | None:
507 """
508 Get the configured party player/queue ID.
509
510 In remote mode, returns the queue of the session's virtual player.
511 When configured to auto, returns the first active playing queue,
512 falling back to any paused queue, then any available queue.
513
514 :returns: The queue ID for party, or None if no player available.
515 """
516 if not self.config.get_value(CONF_ENABLE_GUEST_ACCESS):
517 return None
518
519 if self.config.get_value(CONF_PARTY_MODE) == SharedPlaybackMode.REMOTE.value:
520 session = await self._get_session()
521 return session.queue_id if session else None
522
523 player_id = self.config.get_value(CONF_PARTY_PLAYER)
524 if player_id and str(player_id) != CONF_PARTY_PLAYER_AUTO:
525 return str(player_id)
526
527 # Auto-select: prefer playing queue, then paused, then any active queue
528 best_queue: str | None = None
529 best_priority = -1
530 for queue in self.mass.player_queues:
531 if not queue.active:
532 continue
533 if queue.state == PlaybackState.PLAYING:
534 return queue.queue_id
535 if queue.state == PlaybackState.PAUSED and best_priority < 1:
536 best_queue = queue.queue_id
537 best_priority = 1
538 elif best_priority < 0:
539 best_queue = queue.queue_id
540 best_priority = 0
541 return best_queue
542
543 async def get_party_config(self) -> PartyConfig:
544 """
545 Get the party configuration for guest rate limiting.
546
547 :returns: PartyConfig with feature toggles, token limits, refill rates, and colors.
548 """
549 return PartyConfig(
550 enable_rate_limiting=cast("bool", self.config.get_value(CONF_ENABLE_RATE_LIMITING)),
551 enable_add_queue=cast("bool", self.config.get_value(CONF_ENABLE_ADD_QUEUE)),
552 add_queue_limit=cast("int", self.config.get_value(CONF_PARTY_ADD_QUEUE_LIMIT)),
553 add_queue_refill_minutes=cast(
554 "int", self.config.get_value(CONF_PARTY_ADD_QUEUE_REFILL_MINUTES)
555 ),
556 enable_boost=cast("bool", self.config.get_value(CONF_ENABLE_BOOST)),
557 boost_limit=cast("int", self.config.get_value(CONF_PARTY_BOOST_LIMIT)),
558 boost_refill_minutes=cast(
559 "int", self.config.get_value(CONF_PARTY_BOOST_REFILL_MINUTES)
560 ),
561 enable_skip_song=cast("bool", self.config.get_value(CONF_ENABLE_SKIP_SONG)),
562 skip_song_limit=cast("int", self.config.get_value(CONF_PARTY_SKIP_SONG_LIMIT)),
563 skip_song_refill_minutes=cast(
564 "int", self.config.get_value(CONF_PARTY_SKIP_SONG_REFILL_MINUTES)
565 ),
566 karaoke_mode=cast("bool", self.config.get_value(CONF_PARTY_KARAOKE_MODE)),
567 highlight_ahead=cast("bool", self.config.get_value(CONF_PARTY_HIGHLIGHT_AHEAD)),
568 request_badge_color=cast("str", self.config.get_value(CONF_REQUEST_BADGE_COLOR)),
569 boost_badge_color=cast("str", self.config.get_value(CONF_BOOST_BADGE_COLOR)),
570 anti_burn_in=cast("bool", self.config.get_value(CONF_ANTI_BURN_IN)),
571 party_name=cast("str | None", self.config.get_value(CONF_PARTY_NAME)),
572 qr_text=cast("str | None", self.config.get_value(CONF_PARTY_QR_TEXT)),
573 hide_back_button=cast("bool", self.config.get_value(CONF_HIDE_BACK_BUTTON)),
574 show_progress_bar=cast("bool", self.config.get_value(CONF_SHOW_PROGRESS_BAR)),
575 prevent_duplicate_tracks=cast(
576 "bool", self.config.get_value(CONF_PREVENT_DUPLICATE_TRACKS)
577 ),
578 mode=cast("str", self.config.get_value(CONF_PARTY_MODE)),
579 )
580
581 # ==================== Guest Action API Commands ====================
582
583 async def add_to_queue(
584 self,
585 uri: str,
586 boost: bool = False,
587 ) -> dict[str, Any]:
588 """
589 Add a media item to the party queue.
590
591 This is the primary API for guests to add songs. The provider handles all
592 queue logic including priority positioning for guest items.
593
594 :param uri: The URI of the media item to add (e.g., "spotify://track/xxx").
595 :param boost: If True, insert at the front of the guest section (play next).
596 :returns: Result dict with success status and queue position info.
597 """
598 # Check if guest access is enabled
599 if not self.config.get_value(CONF_ENABLE_GUEST_ACCESS):
600 raise InvalidDataError("Party guest access is disabled")
601
602 # Check feature toggles
603 if boost and not self.config.get_value(CONF_ENABLE_BOOST):
604 raise InvalidDataError("Boost feature is disabled")
605 if not self.config.get_value(CONF_ENABLE_ADD_QUEUE):
606 raise InvalidDataError("Add to queue feature is disabled")
607
608 # Get the party queue
609 queue_id = await self.get_party_player()
610 if not queue_id:
611 raise InvalidDataError("Could not get player queue")
612
613 queue = self.mass.player_queues.get(queue_id)
614 if not queue:
615 raise InvalidDataError(f"Queue not found: {queue_id}")
616
617 # Handle different scenarios based on queue state and boost mode
618 started_playback = False
619
620 if queue.state == PlaybackState.PLAYING:
621 # Queue is actively playing — insert into the priority section
622 extra_attrs: dict[str, Any] = {ATTR_PARTY_GUEST: True}
623 if boost:
624 extra_attrs[ATTR_PARTY_BOOSTED] = True
625 await self._add_to_priority_section(queue_id, uri, extra_attrs)
626 else:
627 # Queue is not playing — resolve the item, insert, and start playback.
628 # Hold the lock so concurrent guests don't both start playback.
629 async with self._queue_lock:
630 if self.config.get_value(
631 CONF_PREVENT_DUPLICATE_TRACKS
632 ) and self._queue_contains_uri(self.mass.player_queues.items(queue_id), uri):
633 raise InvalidDataError("This track is already in the queue")
634 media_item = await self.mass.music.get_item_by_uri(uri)
635 if not media_item or media_item.media_type not in (
636 MediaType.TRACK,
637 MediaType.RADIO,
638 ):
639 raise InvalidDataError(f"Cannot add {uri} to queue - not a playable item")
640 queue_item = build_queue_item(queue_id, media_item) # type: ignore[arg-type]
641 queue_item.extra_attributes[ATTR_PARTY_GUEST] = True
642 if boost:
643 queue_item.extra_attributes[ATTR_PARTY_BOOSTED] = True
644 # Insert after current position if queue has one, otherwise at the start
645 insert_index = (queue.current_index + 1) if queue.current_index is not None else 0
646 await self.mass.player_queues.load(
647 queue_id=queue_id,
648 queue_items=[queue_item],
649 insert_at_index=insert_index,
650 keep_remaining=True,
651 keep_played=True,
652 shuffle=False,
653 )
654 await self.mass.player_queues.play_index(queue_id, insert_index)
655 started_playback = True
656
657 self.logger.info(
658 "Guest added to queue: %s (boost=%s, started_playback=%s)",
659 uri,
660 boost,
661 started_playback,
662 )
663
664 return {
665 "success": True,
666 "queue_id": queue_id,
667 "boosted": boost,
668 "started_playback": started_playback,
669 }
670
671 async def boost_queue_item(self, queue_item_id: str) -> dict[str, Any]:
672 """
673 Boost an existing queue item by moving it to the boosted section.
674
675 Finds the item in the queue, marks it as boosted, and moves it to the
676 end of the boosted priority section (right after the currently playing track).
677
678 :param queue_item_id: The queue_item_id of the item to boost.
679 :returns: Result dict with success status.
680 """
681 if not self.config.get_value(CONF_ENABLE_GUEST_ACCESS):
682 raise InvalidDataError("Party guest access is disabled")
683 if not self.config.get_value(CONF_ENABLE_BOOST):
684 raise InvalidDataError("Boost feature is disabled")
685
686 queue_id = await self.get_party_player()
687 if not queue_id:
688 raise InvalidDataError("Could not get player queue")
689
690 queue = self.mass.player_queues.get(queue_id)
691 if not queue:
692 raise InvalidDataError(f"Queue not found: {queue_id}")
693
694 started_playback = False
695
696 async with self._queue_lock:
697 queue_items = self.mass.player_queues.items(queue_id)
698
699 # Find the item by queue_item_id
700 item_index = None
701 for i, item in enumerate(queue_items):
702 if item.queue_item_id == queue_item_id:
703 item_index = i
704 break
705
706 if item_index is None:
707 raise InvalidDataError(f"Queue item {queue_item_id} not found")
708
709 if queue_items[item_index].extra_attributes.get(ATTR_PARTY_BOOSTED):
710 raise InvalidDataError("This item is already boosted")
711
712 if queue.state == PlaybackState.PLAYING:
713 # Use index_in_buffer to avoid moving already-buffered items
714 current_index = (
715 queue.index_in_buffer
716 if queue.index_in_buffer is not None
717 else (queue.current_index if queue.current_index is not None else 0)
718 )
719
720 if item_index <= current_index:
721 raise InvalidDataError(
722 "Cannot boost an already played or currently playing item"
723 )
724
725 # Find the end of the boosted section and move item there
726 insert_index = self._find_section_end(
727 queue_items, current_index, ATTR_PARTY_BOOSTED
728 )
729
730 queue_items_copy = queue_items.copy()
731 moved_item = queue_items_copy.pop(item_index)
732 moved_item.extra_attributes[ATTR_PARTY_GUEST] = True
733 moved_item.extra_attributes[ATTR_PARTY_BOOSTED] = True
734
735 # Adjust insert index if the item was before the insert position
736 if item_index < insert_index:
737 insert_index -= 1
738
739 queue_items_copy.insert(insert_index, moved_item)
740 self.mass.player_queues.update_items(queue_id, queue_items_copy)
741 else:
742 # Queue is not playing — move item to the play position and start playback
743 current_index = queue.current_index or 0
744 play_index = current_index + 1 if queue.current_index is not None else 0
745
746 queue_items_copy = queue_items.copy()
747 moved_item = queue_items_copy.pop(item_index)
748 moved_item.extra_attributes[ATTR_PARTY_GUEST] = True
749 moved_item.extra_attributes[ATTR_PARTY_BOOSTED] = True
750
751 # Adjust play_index if the item was before it
752 if item_index < play_index:
753 play_index -= 1
754
755 queue_items_copy.insert(play_index, moved_item)
756 self.mass.player_queues.update_items(queue_id, queue_items_copy)
757 await self.mass.player_queues.play_index(queue_id, play_index)
758 started_playback = True
759
760 self.logger.info(
761 "Guest boosted queue item: %s (started_playback=%s)",
762 queue_item_id,
763 started_playback,
764 )
765
766 return {
767 "success": True,
768 "queue_id": queue_id,
769 "started_playback": started_playback,
770 }
771
772 async def _add_to_priority_section(
773 self, queue_id: str, uri: str, extra_attributes: dict[str, Any]
774 ) -> None:
775 """
776 Add a media item to the end of a priority section in the queue.
777
778 Resolves the media item, creates a QueueItem with the given extra attributes,
779 finds the correct insert position, and loads it into the queue.
780
781 :param queue_id: The queue ID to add to.
782 :param uri: The URI of the media item to add.
783 :param extra_attributes: Attributes to set on the queue item (e.g., guest/boosted flags).
784 """
785 # Resolve the media item from URI
786 media_item = await self.mass.music.get_item_by_uri(uri)
787 if not media_item or media_item.media_type not in (
788 MediaType.TRACK,
789 MediaType.RADIO,
790 ):
791 raise InvalidDataError(f"Cannot add {uri} to queue - not a playable item")
792
793 # Create a QueueItem from the media item
794 queue_item = build_queue_item(queue_id, media_item) # type: ignore[arg-type]
795 queue_item.extra_attributes.update(extra_attributes)
796
797 # Determine the attribute to scan for when finding the section boundary.
798 # Use the most specific attribute (e.g., ATTR_PARTY_BOOSTED takes
799 # priority over ATTR_PARTY_GUEST) so boosted items form their own
800 # sub-section at the front of the guest section.
801 if ATTR_PARTY_BOOSTED in extra_attributes:
802 section_attribute = ATTR_PARTY_BOOSTED
803 else:
804 section_attribute = ATTR_PARTY_GUEST
805
806 # Hold the lock while reading queue state, calculating the insert index,
807 # and loading the item so concurrent guest requests don't interleave.
808 async with self._queue_lock:
809 queue = self.mass.player_queues.get(queue_id)
810 queue_items = self.mass.player_queues.items(queue_id)
811 if self.config.get_value(CONF_PREVENT_DUPLICATE_TRACKS) and self._queue_contains_uri(
812 queue_items, uri
813 ):
814 raise InvalidDataError("This track is already in the queue")
815
816 # Use index_in_buffer when playing to avoid inserting before an already-buffered
817 # track, which would cause the newly added song to be skipped
818 if queue and queue.state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
819 current_index = (
820 queue.index_in_buffer
821 if queue.index_in_buffer is not None
822 else (queue.current_index if queue.current_index is not None else 0)
823 )
824 else:
825 current_index = queue.current_index or 0 if queue else 0
826
827 insert_index = self._find_section_end(queue_items, current_index, section_attribute)
828
829 await self.mass.player_queues.load(
830 queue_id=queue_id,
831 queue_items=[queue_item],
832 insert_at_index=insert_index,
833 keep_remaining=True,
834 keep_played=True,
835 shuffle=False,
836 )
837
838 @staticmethod
839 def _queue_contains_uri(queue_items: list[QueueItem], uri: str) -> bool:
840 """Return whether the queue already contains the given item URI."""
841 return any(queue_item.uri == uri for queue_item in queue_items)
842
843 @staticmethod
844 def _find_section_end(queue_items: list[QueueItem], current_index: int, attribute: str) -> int:
845 """
846 Find the index where a priority section ends.
847
848 Scans from current_index + 1 forward to find where consecutive items
849 with the given attribute end. Returns the position where a new item
850 should be inserted.
851
852 :param queue_items: List of queue items.
853 :param current_index: The currently playing index.
854 :param attribute: The extra_attributes key that defines the section.
855 :returns: The index where new items should be inserted.
856 """
857 search_start = current_index + 1
858
859 if not queue_items or search_start >= len(queue_items):
860 return len(queue_items)
861
862 section_end = search_start
863 for i in range(search_start, len(queue_items)):
864 if queue_items[i].extra_attributes.get(attribute):
865 section_end = i + 1
866 else:
867 break
868
869 return section_end
870
871 async def skip_current(self) -> dict[str, Any]:
872 """
873 Skip the currently playing track.
874
875 :returns: Result dict with success status.
876 """
877 # Check if guest access and skip are enabled
878 if not self.config.get_value(CONF_ENABLE_GUEST_ACCESS):
879 raise InvalidDataError("Party guest access is disabled")
880 if not self.config.get_value(CONF_ENABLE_SKIP_SONG):
881 raise InvalidDataError("Skip song feature is disabled")
882
883 # Get the party queue
884 queue_id = await self.get_party_player()
885 if not queue_id:
886 raise InvalidDataError("Could not get player queue")
887
888 queue = self.mass.player_queues.get(queue_id)
889 if not queue:
890 raise InvalidDataError(f"Queue not found: {queue_id}")
891
892 if queue.state != PlaybackState.PLAYING:
893 raise InvalidDataError("Nothing is currently playing")
894
895 # Skip to next track
896 await self.mass.player_queues.next(queue_id)
897
898 self.logger.info("Guest skipped current track on queue: %s", queue_id)
899
900 return {
901 "success": True,
902 "queue_id": queue_id,
903 }
904
905 async def listen_in(self, web_player_id: str) -> dict[str, Any]:
906 """
907 Attach a guest's web player to the party audio.
908
909 :param web_player_id: The player_id of the guest's web player.
910 :returns: Result dict with success status and the party queue ID.
911 """
912 if not self.config.get_value(CONF_ENABLE_GUEST_ACCESS):
913 raise InvalidDataError("Party guest access is disabled")
914
915 # hold the session lock across resolving and joining the session so a
916 # guest can never be attached to a session that is being torn down
917 async with self._session_lock:
918 session = await self._get_or_create_session_locked()
919 if not session:
920 raise InvalidDataError("Listen-in is not available for this party")
921 await session.add_guest_listener(web_player_id)
922 queue_id = session.queue_id
923
924 self.logger.info("Guest player %s is now listening in", web_player_id)
925 return {
926 "success": True,
927 "queue_id": queue_id,
928 }
929
930 async def stop_listen_in(self, web_player_id: str) -> dict[str, Any]:
931 """
932 Detach a guest's web player from the party audio.
933
934 :param web_player_id: The player_id of the guest's web player.
935 :returns: Result dict with success status.
936 """
937 async with self._session_lock:
938 if self._session is not None:
939 await self._session.remove_guest_listener(web_player_id)
940
941 self.logger.info("Guest player %s stopped listening in", web_player_id)
942 return {"success": True}
943
944 async def can_listen_in(self, web_player_id: str) -> bool:
945 """
946 Return whether the given guest web player can listen in on the party audio.
947
948 :param web_player_id: The player_id of the guest's web player.
949 """
950 if not self.config.get_value(CONF_ENABLE_GUEST_ACCESS):
951 return False
952
953 async with self._session_lock:
954 session = await self._get_or_create_session_locked()
955 return session is not None and session.can_listen_in(web_player_id)
956
957 # ==================== Helper Methods ====================
958
959 async def _get_session(self) -> SharedPlaybackSession | None:
960 """
961 Get the shared playback session for this party, creating it if needed.
962
963 In remote mode the session is backed by a Sendspin virtual player; the
964 session is (re)created here when the virtual player does not exist
965 (e.g. after a Sendspin provider reload). In venue mode a session only
966 exists when a fixed player is configured; with auto player selection
967 there is no session (and thus no listen-in support).
968
969 :returns: The session, or None when no session is available.
970 """
971 async with self._session_lock:
972 return await self._get_or_create_session_locked()
973
974 async def _get_or_create_session_locked(self) -> SharedPlaybackSession | None:
975 """
976 Get or (re)create the shared playback session.
977
978 The caller must hold ``_session_lock``; guest listen-in and session
979 teardown share the lock so a session is never created or joined while it
980 is being closed.
981
982 :returns: The session, or None when no session is available.
983 """
984 # drop a stale session whose player no longer exists
985 if self._session is not None and (
986 self.mass.players.get_player(self._session.player_id) is None
987 ):
988 await self._session.close()
989 self._session = None
990
991 if self._session is not None:
992 return self._session
993
994 if self.config.get_value(CONF_PARTY_MODE) == SharedPlaybackMode.REMOTE.value:
995 party_name = cast("str | None", self.config.get_value(CONF_PARTY_NAME))
996 try:
997 self._session = await SharedPlaybackSession.create_remote(
998 self.mass,
999 owner_instance_id=self.instance_id,
1000 display_name=party_name or "Party",
1001 session_id=self.instance_id,
1002 )
1003 except SetupFailedError as err:
1004 self.logger.warning("Unable to create remote party session: %s", err)
1005 else:
1006 player_id = self.config.get_value(CONF_PARTY_PLAYER)
1007 if player_id and str(player_id) != CONF_PARTY_PLAYER_AUTO:
1008 try:
1009 self._session = await SharedPlaybackSession.create_venue(
1010 self.mass, str(player_id)
1011 )
1012 except SetupFailedError as err:
1013 self.logger.warning("Unable to create venue party session: %s", err)
1014 return self._session
1015
1016 async def _revoke_guest_tokens(self) -> None:
1017 """
1018 Revoke all guest access tokens and codes for party.
1019
1020 This is called when guest access is disabled or the plugin is removed.
1021 We disconnect WebSocket connections to force the frontend to redirect to login,
1022 revoke tokens so they can't reconnect, and invalidate pending join codes.
1023 """
1024 codes_revoked, tokens_revoked = await guest_access.revoke_guest_access(
1025 self.mass, PARTY_GUEST_USER
1026 )
1027 if codes_revoked > 0:
1028 self.logger.info("Revoked %d pending join codes", codes_revoked)
1029 if tokens_revoked > 0:
1030 self.logger.info(
1031 "Revoked %d guest access tokens for user '%s'",
1032 tokens_revoked,
1033 PARTY_GUEST_USER,
1034 )
1035