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