/
/
/
1"""
2MusicAssistant PlayerController.
3
4Handles all logic to control supported players,
5which are provided by Player Providers.
6
7Note that the PlayerController has a concept of a 'player' and a 'playerstate'.
8The Player is the actual object that is provided by the provider,
9which incorporates the (unaltered) state of the player (e.g. volume, state, etc)
10and functions for controlling the player (e.g. play, pause, etc).
11
12The playerstate is the (final) state of the player, including any user customizations
13and transformations that are applied to the player.
14The playerstate is the object that is exposed to the outside world (via the API).
15"""
16
17from __future__ import annotations
18
19import asyncio
20import time
21from contextlib import suppress
22from contextvars import ContextVar
23from typing import TYPE_CHECKING, Any, cast
24
25from music_assistant_models.auth import UserRole
26from music_assistant_models.constants import (
27 PLAYER_CONTROL_FAKE,
28 PLAYER_CONTROL_NATIVE,
29 PLAYER_CONTROL_NONE,
30)
31from music_assistant_models.enums import (
32 EventType,
33 MediaType,
34 PlaybackState,
35 PlayerFeature,
36 PlayerType,
37 ProviderFeature,
38 ProviderType,
39)
40from music_assistant_models.errors import (
41 AlreadyRegisteredError,
42 InsufficientPermissions,
43 MusicAssistantError,
44 PlayerCommandFailed,
45 PlayerUnavailableError,
46 ProviderUnavailableError,
47 UnsupportedFeaturedException,
48)
49from music_assistant_models.player import PlayerOptionValueType # noqa: TC002
50from music_assistant_models.player_control import PlayerControl # noqa: TC002
51
52from music_assistant.constants import (
53 ANNOUNCE_ALERT_FILE,
54 ATTR_ACTIVE_SOURCE,
55 ATTR_ANNOUNCEMENT_IN_PROGRESS,
56 ATTR_AVAILABLE,
57 ATTR_ELAPSED_TIME,
58 ATTR_ENABLED,
59 ATTR_FAKE_MUTE,
60 ATTR_FAKE_POWER,
61 ATTR_FAKE_VOLUME,
62 ATTR_GROUP_MEMBERS,
63 ATTR_LAST_POLL,
64 ATTR_MUTE_LOCK,
65 ATTR_PREVIOUS_VOLUME,
66 CONF_AUTO_PLAY,
67 CONF_ENTRY_ANNOUNCE_VOLUME,
68 CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
69 CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
70 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
71 CONF_ENTRY_TTS_PRE_ANNOUNCE,
72 CONF_ENTRY_ZEROCONF_INTERFACES,
73 CONF_PLAYER_DSP,
74 CONF_PLAYERS,
75 CONF_PRE_ANNOUNCE_CHIME_URL,
76)
77from music_assistant.controllers.webserver.helpers.auth_middleware import (
78 get_current_user,
79 get_sendspin_player_id,
80)
81from music_assistant.helpers.api import api_command
82from music_assistant.helpers.tags import async_parse_tags
83from music_assistant.helpers.throttle_retry import Throttler
84from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url
85from music_assistant.models.core_controller import CoreController
86from music_assistant.models.player import Player, PlayerMedia, PlayerState
87from music_assistant.models.player_provider import PlayerProvider
88from music_assistant.models.plugin import PluginProvider, PluginSource
89
90from .helpers import AnnounceData, handle_player_command
91from .protocol_linking import ProtocolLinkingMixin
92
93if TYPE_CHECKING:
94 from collections.abc import Iterator
95
96 from music_assistant_models.config_entries import (
97 ConfigEntry,
98 ConfigValueType,
99 CoreConfig,
100 PlayerConfig,
101 )
102 from music_assistant_models.player_queue import PlayerQueue
103
104 from music_assistant import MusicAssistant
105
106CACHE_CATEGORY_PLAYER_POWER = 1
107
108# Context variable to prevent circular calls between players and player_queues controllers
109IN_QUEUE_COMMAND: ContextVar[bool] = ContextVar("IN_QUEUE_COMMAND", default=False)
110
111
112class PlayerController(ProtocolLinkingMixin, CoreController):
113 """Controller holding all logic to control registered players."""
114
115 domain: str = "players"
116
117 def __init__(self, mass: MusicAssistant) -> None:
118 """Initialize core controller."""
119 super().__init__(mass)
120 self._players: dict[str, Player] = {}
121 self._controls: dict[str, PlayerControl] = {}
122 self.manifest.name = "Player Controller"
123 self.manifest.description = (
124 "Music Assistant's core controller which manages all players from all providers."
125 )
126 self.manifest.icon = "speaker-multiple"
127 self._poll_task: asyncio.Task[None] | None = None
128 self._player_throttlers: dict[str, Throttler] = {}
129 self._player_command_locks: dict[str, asyncio.Lock] = {}
130 # Lock to prevent race conditions during player registration
131 self._register_lock = asyncio.Lock()
132 # Track pending protocol player evaluations (delayed to allow all protocols to register)
133 self._pending_protocol_evaluations: dict[str, asyncio.TimerHandle] = {}
134
135 async def get_config_entries(
136 self,
137 action: str | None = None,
138 values: dict[str, ConfigValueType] | None = None,
139 ) -> tuple[ConfigEntry, ...]:
140 """Return Config Entries for the Player Controller."""
141 return (CONF_ENTRY_ZEROCONF_INTERFACES,)
142
143 async def setup(self, config: CoreConfig) -> None:
144 """Async initialize of module."""
145 self._poll_task = self.mass.create_task(self._poll_players())
146
147 async def close(self) -> None:
148 """Cleanup on exit."""
149 if self._poll_task and not self._poll_task.done():
150 self._poll_task.cancel()
151 # Cancel all pending protocol evaluations
152 for handle in self._pending_protocol_evaluations.values():
153 handle.cancel()
154 self._pending_protocol_evaluations.clear()
155
156 async def on_provider_loaded(self, provider: PlayerProvider) -> None:
157 """Handle logic when a provider is loaded."""
158
159 async def on_provider_unload(self, provider: PlayerProvider) -> None:
160 """Handle logic when a provider is (about to get) unloaded."""
161
162 @property
163 def providers(self) -> list[PlayerProvider]:
164 """Return all loaded/running MusicProviders."""
165 return cast("list[PlayerProvider]", self.mass.get_providers(ProviderType.PLAYER))
166
167 def all_players(
168 self,
169 return_unavailable: bool = True,
170 return_disabled: bool = False,
171 provider_filter: str | None = None,
172 return_protocol_players: bool = False,
173 ) -> list[Player]:
174 """
175 Return all registered players.
176
177 Note that this applies user filters for players (for non admin users).
178
179 :param return_unavailable [bool]: Include unavailable players.
180 :param return_disabled [bool]: Include disabled players.
181 :param provider_filter [str]: Optional filter by provider lookup key.
182 :param return_protocol_players [bool]: Include protocol players (hidden by default).
183
184 :return: List of Player objects.
185 """
186 current_user = get_current_user()
187 user_filter = (
188 current_user.player_filter
189 if current_user and current_user.role != UserRole.ADMIN
190 else None
191 )
192 current_sendspin_player = get_sendspin_player_id()
193 return [
194 player
195 for player in list(self._players.values())
196 if (player.state.available or return_unavailable)
197 and (player.state.enabled or return_disabled)
198 and (provider_filter is None or player.provider.instance_id == provider_filter)
199 and (
200 not user_filter
201 or player.player_id in user_filter
202 or player.player_id == current_sendspin_player
203 )
204 and (return_protocol_players or player.state.type != PlayerType.PROTOCOL)
205 ]
206
207 @api_command("players/all")
208 def all_player_states(
209 self,
210 return_unavailable: bool = True,
211 return_disabled: bool = False,
212 provider_filter: str | None = None,
213 return_protocol_players: bool = False,
214 ) -> list[PlayerState]:
215 """
216 Return PlayerState for all registered players.
217
218 :param return_unavailable [bool]: Include unavailable players.
219 :param return_disabled [bool]: Include disabled players.
220 :param provider_filter [str]: Optional filter by provider lookup key.
221 :param return_protocol_players [bool]: Include protocol players (hidden by default).
222
223 :return: List of PlayerState objects.
224 """
225 return [
226 player.state
227 for player in self.all_players(
228 return_unavailable=return_unavailable,
229 return_disabled=return_disabled,
230 provider_filter=provider_filter,
231 return_protocol_players=return_protocol_players,
232 )
233 ]
234
235 def get_player(
236 self,
237 player_id: str,
238 raise_unavailable: bool = False,
239 ) -> Player | None:
240 """
241 Return Player by player_id.
242
243 :param player_id [str]: ID of the player.
244 :param raise_unavailable [bool]: Raise if player is unavailable.
245
246 :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
247 :return: Player object or None.
248 """
249 if player := self._players.get(player_id):
250 if (not player.state.available or not player.state.enabled) and raise_unavailable:
251 msg = f"Player {player_id} is not available"
252 raise PlayerUnavailableError(msg)
253 return player
254 if raise_unavailable:
255 msg = f"Player {player_id} is not available"
256 raise PlayerUnavailableError(msg)
257 return None
258
259 @api_command("players/get")
260 def get_player_state(
261 self,
262 player_id: str,
263 raise_unavailable: bool = False,
264 ) -> PlayerState | None:
265 """
266 Return PlayerState by player_id.
267
268 :param player_id [str]: ID of the player.
269 :param raise_unavailable [bool]: Raise if player is unavailable.
270
271 :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
272 :return: Player object or None.
273 """
274 current_user = get_current_user()
275 user_filter = (
276 current_user.player_filter
277 if current_user and current_user.role != UserRole.ADMIN
278 else None
279 )
280 current_sendspin_player = get_sendspin_player_id()
281 if (
282 current_user
283 and user_filter
284 and player_id not in user_filter
285 and player_id != current_sendspin_player
286 ):
287 msg = f"{current_user.username} does not have access to player {player_id}"
288 raise InsufficientPermissions(msg)
289 if player := self.get_player(player_id, raise_unavailable):
290 return player.state
291 return None
292
293 def get_player_by_name(self, name: str) -> Player | None:
294 """
295 Return Player by name.
296
297 Performs case-insensitive matching against the player's state name
298 (the final name visible in clients and API).
299 If multiple players match, logs a warning and returns the first match.
300
301 :param name: Name of the player.
302 :return: Player object or None.
303 """
304 name_normalized = name.strip().lower()
305 matches: list[Player] = []
306
307 for player in list(self._players.values()):
308 if player.state.name.strip().lower() == name_normalized:
309 matches.append(player)
310
311 if not matches:
312 return None
313
314 if len(matches) > 1:
315 player_ids = [p.player_id for p in matches]
316 self.logger.warning(
317 "players/get_by_name: Multiple players found with name '%s': %s - "
318 "returning first match (%s). "
319 "Consider using the players/get API with player_id instead "
320 "for unambiguous lookups.",
321 name,
322 player_ids,
323 matches[0].player_id,
324 )
325
326 return matches[0]
327
328 @api_command("players/get_by_name")
329 def get_player_state_by_name(self, name: str) -> PlayerState | None:
330 """
331 Return PlayerState by name.
332
333 :param name: Name of the player.
334 :return: PlayerState object or None.
335 """
336 current_user = get_current_user()
337 user_filter = (
338 current_user.player_filter
339 if current_user and current_user.role != UserRole.ADMIN
340 else None
341 )
342 current_sendspin_player = get_sendspin_player_id()
343 if player := self.get_player_by_name(name):
344 if (
345 current_user
346 and user_filter
347 and player.player_id not in user_filter
348 and player.player_id != current_sendspin_player
349 ):
350 msg = f"{current_user.username} does not have access to player {player.player_id}"
351 raise InsufficientPermissions(msg)
352 return player.state
353 return None
354
355 @api_command("players/player_controls")
356 def player_controls(
357 self,
358 ) -> list[PlayerControl]:
359 """Return all registered playercontrols."""
360 return list(self._controls.values())
361
362 @api_command("players/player_control")
363 def get_player_control(
364 self,
365 control_id: str,
366 ) -> PlayerControl | None:
367 """
368 Return PlayerControl by control_id.
369
370 :param control_id: ID of the player control.
371 :return: PlayerControl object or None.
372 """
373 if control := self._controls.get(control_id):
374 return control
375 return None
376
377 @api_command("players/plugin_sources")
378 def get_plugin_sources(self) -> list[PluginSource]:
379 """Return all available plugin sources."""
380 return [
381 plugin_prov.get_source()
382 for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN)
383 if isinstance(plugin_prov, PluginProvider)
384 and ProviderFeature.AUDIO_SOURCE in plugin_prov.supported_features
385 ]
386
387 @api_command("players/plugin_source")
388 def get_plugin_source(
389 self,
390 source_id: str,
391 ) -> PluginSource | None:
392 """
393 Return PluginSource by source_id.
394
395 :param source_id: ID of the plugin source.
396 :return: PluginSource object or None.
397 """
398 for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN):
399 assert isinstance(plugin_prov, PluginProvider) # for type checking
400 if ProviderFeature.AUDIO_SOURCE not in plugin_prov.supported_features:
401 continue
402 if (source := plugin_prov.get_source()) and source.id == source_id:
403 return source
404 return None
405
406 # Player commands
407
408 @api_command("players/cmd/stop")
409 @handle_player_command
410 async def cmd_stop(self, player_id: str) -> None:
411 """Send STOP command to given player.
412
413 - player_id: player_id of the player to handle the command.
414 """
415 player = self._get_player_with_redirect(player_id)
416 # Redirect to queue controller if it is active (skip if already in queue command context)
417 if not IN_QUEUE_COMMAND.get() and (active_queue := self.get_active_queue(player)):
418 await self.mass.player_queues.stop(active_queue.queue_id)
419 return
420 # Delegate to internal handler for actual implementation
421 await self._handle_cmd_stop(player.player_id)
422
423 @api_command("players/cmd/play")
424 @handle_player_command
425 async def cmd_play(self, player_id: str) -> None:
426 """Send PLAY (unpause) command to given player.
427
428 - player_id: player_id of the player to handle the command.
429 """
430 player = self._get_player_with_redirect(player_id)
431 if player.state.playback_state == PlaybackState.PLAYING:
432 self.logger.info(
433 "Ignore PLAY request to player %s: player is already playing", player.state.name
434 )
435 return
436 # player is not paused: check for queue redirect, then delegate to internal handler
437 if player.state.playback_state != PlaybackState.PAUSED:
438 source = player.state.active_source
439 if active_queue := self.mass.player_queues.get(source or player_id):
440 await self.mass.player_queues.resume(active_queue.queue_id)
441 return
442
443 # Delegate to internal handler for actual implementation
444 await self._handle_cmd_play(player.player_id)
445
446 @api_command("players/cmd/pause")
447 @handle_player_command
448 async def cmd_pause(self, player_id: str) -> None:
449 """Send PAUSE command to given player.
450
451 - player_id: player_id of the player to handle the command.
452 """
453 player = self._get_player_with_redirect(player_id)
454 # Redirect to queue controller if it is active (skip if already in queue command context)
455 if not IN_QUEUE_COMMAND.get() and (active_queue := self.get_active_queue(player)):
456 await self.mass.player_queues.pause(active_queue.queue_id)
457 return
458 # Delegate to internal handler for actual implementation
459 await self._handle_cmd_pause(player.player_id)
460
461 @api_command("players/cmd/play_pause")
462 async def cmd_play_pause(self, player_id: str) -> None:
463 """Toggle play/pause on given player.
464
465 - player_id: player_id of the player to handle the command.
466 """
467 player = self._get_player_with_redirect(player_id)
468 if player.state.playback_state == PlaybackState.PLAYING:
469 await self.cmd_pause(player.player_id)
470 else:
471 await self.cmd_play(player.player_id)
472
473 @api_command("players/cmd/resume")
474 @handle_player_command
475 async def cmd_resume(
476 self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
477 ) -> None:
478 """Send RESUME command to given player.
479
480 Resume (or restart) playback on the player.
481
482 :param player_id: player_id of the player to handle the command.
483 :param source: Optional source to resume.
484 :param media: Optional media to resume.
485 """
486 await self._handle_cmd_resume(player_id, source, media)
487
488 @api_command("players/cmd/seek")
489 @handle_player_command
490 async def cmd_seek(self, player_id: str, position: int) -> None:
491 """Handle SEEK command for given player.
492
493 - player_id: player_id of the player to handle the command.
494 - position: position in seconds to seek to in the current playing item.
495 """
496 player = self._get_player_with_redirect(player_id)
497 # Check if a plugin source is active with a seek callback
498 if plugin_source := self._get_active_plugin_source(player):
499 if plugin_source.can_seek and plugin_source.on_seek:
500 await plugin_source.on_seek(position)
501 return
502 # Redirect to queue controller if it is active
503 if not IN_QUEUE_COMMAND.get() and (active_queue := self.get_active_queue(player)):
504 await self.mass.player_queues.seek(active_queue.queue_id, position)
505 return
506 # handle command on player/source directly
507 active_source = next((x for x in player.source_list if x.id == player.active_source), None)
508 if active_source and not active_source.can_seek:
509 msg = (
510 f"The active source ({active_source.name}) on player "
511 f"{player.display_name} does not support seeking"
512 )
513 raise PlayerCommandFailed(msg)
514 if PlayerFeature.SEEK not in player.supported_features:
515 msg = f"Player {player.display_name} does not support seeking"
516 raise UnsupportedFeaturedException(msg)
517 # handle command on player directly
518 await player.seek(position)
519
520 @api_command("players/cmd/next")
521 @handle_player_command
522 async def cmd_next_track(self, player_id: str) -> None:
523 """Handle NEXT TRACK command for given player."""
524 player = self._get_player_with_redirect(player_id)
525 active_source_id = player.state.active_source or player.player_id
526 # Check if a plugin source is active with a next callback
527 if plugin_source := self._get_active_plugin_source(player):
528 if plugin_source.can_next_previous and plugin_source.on_next:
529 await plugin_source.on_next()
530 return
531 # Redirect to queue controller if it is active
532 if active_queue := self.get_active_queue(player):
533 await self.mass.player_queues.next(active_queue.queue_id)
534 return
535 if PlayerFeature.NEXT_PREVIOUS in player.state.supported_features:
536 # player has some other source active and native next/previous support
537 active_source = next(
538 (x for x in player.state.source_list if x.id == active_source_id), None
539 )
540 if active_source and active_source.can_next_previous:
541 await player.next_track()
542 return
543 msg = "This action is (currently) unavailable for this source."
544 raise PlayerCommandFailed(msg)
545 # Player does not support next/previous feature
546 msg = f"Player {player.state.name} does not support skipping to the next track."
547 raise UnsupportedFeaturedException(msg)
548
549 @api_command("players/cmd/previous")
550 @handle_player_command
551 async def cmd_previous_track(self, player_id: str) -> None:
552 """Handle PREVIOUS TRACK command for given player."""
553 player = self._get_player_with_redirect(player_id)
554 active_source_id = player.state.active_source or player.player_id
555 # Check if a plugin source is active with a previous callback
556 if plugin_source := self._get_active_plugin_source(player):
557 if plugin_source.can_next_previous and plugin_source.on_previous:
558 await plugin_source.on_previous()
559 return
560 # Redirect to queue controller if it is active
561 if active_queue := self.get_active_queue(player):
562 await self.mass.player_queues.previous(active_queue.queue_id)
563 return
564 if PlayerFeature.NEXT_PREVIOUS in player.state.supported_features:
565 # player has some other source active and native next/previous support
566 active_source = next(
567 (x for x in player.state.source_list if x.id == active_source_id), None
568 )
569 if active_source and active_source.can_next_previous:
570 await player.previous_track()
571 return
572 msg = "This action is (currently) unavailable for this source."
573 raise PlayerCommandFailed(msg)
574 # Player does not support next/previous feature
575 msg = f"Player {player.state.name} does not support skipping to the previous track."
576 raise UnsupportedFeaturedException(msg)
577
578 @api_command("players/cmd/power")
579 @handle_player_command
580 async def cmd_power(self, player_id: str, powered: bool) -> None:
581 """Send POWER command to given player.
582
583 :param player_id: player_id of the player to handle the command.
584 :param powered: bool if player should be powered on or off.
585 """
586 await self._handle_cmd_power(player_id, powered)
587
588 @api_command("players/cmd/volume_set")
589 @handle_player_command
590 async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
591 """Send VOLUME_SET command to given player.
592
593 :param player_id: player_id of the player to handle the command.
594 :param volume_level: volume level (0..100) to set on the player.
595 """
596 await self._handle_cmd_volume_set(player_id, volume_level)
597
598 @api_command("players/cmd/volume_up")
599 @handle_player_command
600 async def cmd_volume_up(self, player_id: str) -> None:
601 """Send VOLUME_UP command to given player.
602
603 - player_id: player_id of the player to handle the command.
604 """
605 if not (player := self.get_player(player_id)):
606 return
607 current_volume = player.state.volume_level or 0
608 if current_volume < 5 or current_volume > 95:
609 step_size = 1
610 elif current_volume < 20 or current_volume > 80:
611 step_size = 2
612 else:
613 step_size = 5
614 new_volume = min(100, current_volume + step_size)
615 await self.cmd_volume_set(player_id, new_volume)
616
617 @api_command("players/cmd/volume_down")
618 @handle_player_command
619 async def cmd_volume_down(self, player_id: str) -> None:
620 """Send VOLUME_DOWN command to given player.
621
622 - player_id: player_id of the player to handle the command.
623 """
624 if not (player := self.get_player(player_id)):
625 return
626 current_volume = player.state.volume_level or 0
627 if current_volume < 5 or current_volume > 95:
628 step_size = 1
629 elif current_volume < 20 or current_volume > 80:
630 step_size = 2
631 else:
632 step_size = 5
633 new_volume = max(0, current_volume - step_size)
634 await self.cmd_volume_set(player_id, new_volume)
635
636 @api_command("players/cmd/group_volume")
637 @handle_player_command
638 async def cmd_group_volume(
639 self,
640 player_id: str,
641 volume_level: int,
642 ) -> None:
643 """
644 Handle adjusting the overall/group volume to a playergroup (or synced players).
645
646 Will set a new (overall) volume level to a group player or syncgroup.
647
648 :param player_id: Player ID of group player or syncleader to handle the command.
649 :param volume_level: Volume level (0..100) to set to the group.
650 """
651 player = self.get_player(player_id, True)
652 assert player is not None # for type checker
653 if player.state.type == PlayerType.GROUP or player.state.group_members:
654 # dedicated group player or sync leader
655 await self.set_group_volume(player, volume_level)
656 return
657 if player.state.synced_to and (sync_leader := self.get_player(player.state.synced_to)):
658 # redirect to sync leader
659 await self.set_group_volume(sync_leader, volume_level)
660 return
661 # treat as normal player volume change
662 await self.cmd_volume_set(player_id, volume_level)
663
664 @api_command("players/cmd/group_volume_up")
665 @handle_player_command
666 async def cmd_group_volume_up(self, player_id: str) -> None:
667 """Send VOLUME_UP command to given playergroup.
668
669 - player_id: player_id of the player to handle the command.
670 """
671 group_player_state = self.get_player_state(player_id, True)
672 assert group_player_state
673 cur_volume = group_player_state.group_volume
674 if cur_volume < 5 or cur_volume > 95:
675 step_size = 1
676 elif cur_volume < 20 or cur_volume > 80:
677 step_size = 2
678 else:
679 step_size = 5
680 new_volume = min(100, cur_volume + step_size)
681 await self.cmd_group_volume(player_id, new_volume)
682
683 @api_command("players/cmd/group_volume_down")
684 @handle_player_command
685 async def cmd_group_volume_down(self, player_id: str) -> None:
686 """Send VOLUME_DOWN command to given playergroup.
687
688 - player_id: player_id of the player to handle the command.
689 """
690 group_player_state = self.get_player_state(player_id, True)
691 assert group_player_state
692 cur_volume = group_player_state.group_volume
693 if cur_volume < 5 or cur_volume > 95:
694 step_size = 1
695 elif cur_volume < 20 or cur_volume > 80:
696 step_size = 2
697 else:
698 step_size = 5
699 new_volume = max(0, cur_volume - step_size)
700 await self.cmd_group_volume(player_id, new_volume)
701
702 @api_command("players/cmd/group_volume_mute")
703 @handle_player_command
704 async def cmd_group_volume_mute(self, player_id: str, muted: bool) -> None:
705 """Send VOLUME_MUTE command to all players in a group.
706
707 - player_id: player_id of the group player or sync leader.
708 - muted: bool if group should be muted.
709 """
710 player = self.get_player(player_id, True)
711 assert player is not None # for type checker
712 if player.state.type == PlayerType.GROUP or player.state.group_members:
713 # dedicated group player or sync leader
714 coros = []
715 for child_player in self.iter_group_members(
716 player, only_powered=True, exclude_self=False
717 ):
718 coros.append(self.cmd_volume_mute(child_player.player_id, muted))
719 await asyncio.gather(*coros)
720
721 @api_command("players/cmd/volume_mute")
722 @handle_player_command
723 async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
724 """Send VOLUME_MUTE command to given player.
725
726 - player_id: player_id of the player to handle the command.
727 - muted: bool if player should be muted.
728 """
729 player = self.get_player(player_id, True)
730 assert player
731
732 # Set/clear mute lock for players in a group
733 # This prevents auto-unmute when group volume changes
734 is_in_group = bool(player.state.synced_to or player.state.group_members)
735 if muted and is_in_group:
736 player.extra_data[ATTR_MUTE_LOCK] = True
737 elif not muted:
738 player.extra_data.pop(ATTR_MUTE_LOCK, None)
739
740 if player.volume_control == PLAYER_CONTROL_NONE:
741 raise UnsupportedFeaturedException(
742 f"Player {player.state.name} does not support muting"
743 )
744 if player.mute_control == PLAYER_CONTROL_NATIVE:
745 # player supports mute command natively: forward to player
746 await player.volume_mute(muted)
747 return
748 if player.mute_control == PLAYER_CONTROL_FAKE:
749 # user wants to use fake mute control - so we use volume instead
750 self.logger.debug(
751 "Using volume for muting for player %s",
752 player.state.name,
753 )
754 if muted:
755 player.extra_data[ATTR_PREVIOUS_VOLUME] = player.state.volume_level
756 player.extra_data[ATTR_FAKE_MUTE] = True
757 await self._handle_cmd_volume_set(player_id, 0)
758 player.update_state()
759 else:
760 prev_volume = player.extra_data.get(ATTR_PREVIOUS_VOLUME, 1)
761 player.extra_data[ATTR_FAKE_MUTE] = False
762 player.update_state()
763 await self._handle_cmd_volume_set(player_id, prev_volume)
764 return
765
766 # handle external player control
767 if player_control := self._controls.get(player.mute_control):
768 control_name = player_control.name if player_control else player.mute_control
769 self.logger.debug("Redirecting mute command to PlayerControl %s", control_name)
770 if not player_control or not player_control.supports_mute:
771 raise UnsupportedFeaturedException(
772 f"Player control {control_name} is not available"
773 )
774 assert player_control.mute_set is not None
775 await player_control.mute_set(muted)
776 return
777
778 # handle to protocol player as volume_mute control
779 if protocol_player := self.get_player(player.state.volume_control):
780 self.logger.debug(
781 "Redirecting mute command to protocol player %s",
782 protocol_player.provider.manifest.name,
783 )
784 await self.cmd_volume_mute(protocol_player.player_id, muted)
785 return
786
787 @api_command("players/cmd/play_announcement")
788 @handle_player_command(lock=True)
789 async def play_announcement(
790 self,
791 player_id: str,
792 url: str,
793 pre_announce: bool | None = None,
794 volume_level: int | None = None,
795 pre_announce_url: str | None = None,
796 ) -> None:
797 """
798 Handle playback of an announcement (url) on given player.
799
800 :param player_id: Player ID of the player to handle the command.
801 :param url: URL of the announcement to play.
802 :param pre_announce: Optional bool if pre-announce should be used.
803 :param volume_level: Optional volume level to set for the announcement.
804 :param pre_announce_url: Optional custom URL to use for the pre-announce chime.
805 """
806 player = self.get_player(player_id, True)
807 assert player is not None # for type checking
808 if not url.startswith("http"):
809 raise PlayerCommandFailed("Only URLs are supported for announcements")
810 if (
811 pre_announce
812 and pre_announce_url
813 and not validate_announcement_chime_url(pre_announce_url)
814 ):
815 raise PlayerCommandFailed("Invalid pre-announce chime URL specified.")
816 try:
817 # mark announcement_in_progress on player
818 player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True
819 # determine pre-announce from (group)player config
820 if pre_announce is None and "tts" in url:
821 conf_pre_announce = self.mass.config.get_raw_player_config_value(
822 player_id,
823 CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
824 CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value,
825 )
826 pre_announce = cast("bool", conf_pre_announce)
827 if pre_announce_url is None:
828 if conf_pre_announce_url := self.mass.config.get_raw_player_config_value(
829 player_id,
830 CONF_PRE_ANNOUNCE_CHIME_URL,
831 ):
832 # player default custom chime url
833 pre_announce_url = cast("str", conf_pre_announce_url)
834 else:
835 # use global default chime url
836 pre_announce_url = ANNOUNCE_ALERT_FILE
837 # if player type is group with all members supporting announcements,
838 # we forward the request to each individual player
839 if player.state.type == PlayerType.GROUP and (
840 all(
841 PlayerFeature.PLAY_ANNOUNCEMENT in x.state.supported_features
842 for x in self.iter_group_members(player)
843 )
844 ):
845 # forward the request to each individual player
846 async with TaskManager(self.mass) as tg:
847 for group_member in player.state.group_members:
848 tg.create_task(
849 self.play_announcement(
850 group_member,
851 url=url,
852 pre_announce=pre_announce,
853 volume_level=volume_level,
854 pre_announce_url=pre_announce_url,
855 )
856 )
857 return
858 self.logger.info(
859 "Playback announcement to player %s (with pre-announce: %s): %s",
860 player.state.name,
861 pre_announce,
862 url,
863 )
864 # determine if the player has native announcements support
865 # or if any linked protocol has announcement support
866 native_announce_support = False
867 if announce_player := self._get_control_target(
868 player,
869 required_feature=PlayerFeature.PLAY_ANNOUNCEMENT,
870 require_active=False,
871 ):
872 native_announce_support = True
873 else:
874 announce_player = player
875 # create a PlayerMedia object for the announcement so
876 # we can send a regular play-media call downstream
877 announce_data = AnnounceData(
878 announcement_url=url,
879 pre_announce=bool(pre_announce),
880 pre_announce_url=pre_announce_url,
881 )
882 announcement = PlayerMedia(
883 uri=self.mass.streams.get_announcement_url(player_id, announce_data=announce_data),
884 media_type=MediaType.ANNOUNCEMENT,
885 title="Announcement",
886 custom_data=dict(announce_data),
887 )
888 # handle native announce support (player or linked protocol)
889 if native_announce_support:
890 announcement_volume = self.get_announcement_volume(player_id, volume_level)
891 await announce_player.play_announcement(announcement, announcement_volume)
892 return
893 # use fallback/default implementation
894 await self._play_announcement(player, announcement, volume_level)
895 finally:
896 player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False
897
898 @handle_player_command(lock=True)
899 async def play_media(self, player_id: str, media: PlayerMedia) -> None:
900 """Handle PLAY MEDIA on given player.
901
902 - player_id: player_id of the player to handle the command.
903 - media: The Media that needs to be played on the player.
904 """
905 player = self._get_player_with_redirect(player_id)
906 # Delegate to internal handler for actual implementation
907 await self._handle_play_media(player.player_id, media)
908
909 @api_command("players/cmd/select_sound_mode")
910 @handle_player_command
911 async def select_sound_mode(self, player_id: str, sound_mode: str) -> None:
912 """
913 Handle SELECT SOUND MODE command on given player.
914
915 - player_id: player_id of the player to handle the command
916 - sound_mode: The ID of the sound mode that needs to be activated/selected.
917 """
918 player = self.get_player(player_id, True)
919 assert player is not None # for type checking
920
921 if PlayerFeature.SELECT_SOUND_MODE not in player.supported_features:
922 raise UnsupportedFeaturedException(
923 f"Player {player.display_name} does not support sound mode selection"
924 )
925
926 prev_sound_mode = player.active_sound_mode
927 if sound_mode == prev_sound_mode:
928 return
929
930 # basic check if sound mode is valid for player
931 if not any(x for x in player.sound_mode_list if x.id == sound_mode):
932 raise PlayerCommandFailed(
933 f"{sound_mode} is an invalid sound_mode for player {player.display_name}"
934 )
935
936 # forward to player
937 await player.select_sound_mode(sound_mode)
938
939 @api_command("players/cmd/set_option")
940 @handle_player_command
941 async def set_option(
942 self, player_id: str, option_key: str, option_value: PlayerOptionValueType
943 ) -> None:
944 """
945 Handle SET_OPTION command on given player.
946
947 - player_id: player_id of the player to handle the command
948 - option_key: The key of the player option that needs to be activated/selected.
949 - option_value: The new value of the player option.
950 """
951 player = self.get_player(player_id, True)
952 assert player is not None # for type checking
953
954 if PlayerFeature.OPTIONS not in player.supported_features:
955 raise UnsupportedFeaturedException(
956 f"Player {player.display_name} does not support set_option"
957 )
958
959 prev_player_option = next((x for x in player.options if x.key == option_key), None)
960 if not prev_player_option:
961 return
962 if prev_player_option.value == option_value:
963 return
964
965 if prev_player_option.read_only:
966 raise UnsupportedFeaturedException(
967 f"Player {player.display_name} option {option_key} is read-only"
968 )
969
970 # forward to player
971 await player.set_option(option_key=option_key, option_value=option_value)
972
973 @api_command("players/cmd/select_source")
974 @handle_player_command
975 async def select_source(self, player_id: str, source: str | None) -> None:
976 """
977 Handle SELECT SOURCE command on given player.
978
979 - player_id: player_id of the player to handle the command.
980 - source: The ID of the source that needs to be activated/selected.
981 """
982 if source is None:
983 source = player_id # default to MA queue source
984 player = self.get_player(player_id, True)
985 assert player is not None # for type checking
986 # Check if player is currently grouped (reject for public API)
987 if player.state.synced_to or player.state.active_group:
988 raise PlayerCommandFailed(f"Player {player.state.name} is currently grouped")
989 # Delegate to internal handler for actual implementation
990 await self._handle_select_source(player_id, source)
991
992 @handle_player_command(lock=True)
993 async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
994 """
995 Handle enqueuing of a next media item on the player.
996
997 :param player_id: player_id of the player to handle the command.
998 :param media: The Media that needs to be enqueued on the player.
999 :raises UnsupportedFeaturedException: if the player does not support enqueueing.
1000 :raises PlayerUnavailableError: if the player is not available.
1001 """
1002 # Note: No group redirect needed here as enqueue doesn't use _get_player_with_redirect
1003 # Delegate to internal handler for actual implementation
1004 await self._handle_enqueue_next_media(player_id, media)
1005
1006 @api_command("players/cmd/set_members")
1007 async def cmd_set_members(
1008 self,
1009 target_player: str,
1010 player_ids_to_add: list[str] | None = None,
1011 player_ids_to_remove: list[str] | None = None,
1012 ) -> None:
1013 """
1014 Join/unjoin given player(s) to/from target player.
1015
1016 Will add the given player(s) to the target player (sync leader or group player).
1017
1018 :param target_player: player_id of the syncgroup leader or group player.
1019 :param player_ids_to_add: List of player_id's to add to the target player.
1020 :param player_ids_to_remove: List of player_id's to remove from the target player.
1021
1022 :raises UnsupportedFeaturedException: if the target player does not support grouping.
1023 :raises PlayerUnavailableError: if the target player is not available.
1024 """
1025 parent_player: Player | None = self.get_player(target_player, True)
1026 assert parent_player is not None # for type checking
1027 if PlayerFeature.SET_MEMBERS not in parent_player.state.supported_features:
1028 msg = f"Player {parent_player.name} does not support group commands"
1029 raise UnsupportedFeaturedException(msg)
1030
1031 # handle edge case: player already synced to another player
1032 # automatically ungroup it first and wait for state to propagate
1033 await self._auto_ungroup_if_synced(parent_player, "setting members")
1034
1035 lock_key = f"set_members_{target_player}"
1036 if lock_key not in self._player_command_locks:
1037 self._player_command_locks[lock_key] = asyncio.Lock()
1038 async with self._player_command_locks[lock_key]:
1039 await self._handle_set_members(
1040 parent_player, target_player, player_ids_to_add, player_ids_to_remove
1041 )
1042
1043 @api_command("players/cmd/group")
1044 @handle_player_command
1045 async def cmd_group(self, player_id: str, target_player: str) -> None:
1046 """Handle GROUP command for given player.
1047
1048 Join/add the given player(id) to the given (leader) player/sync group.
1049 If the target player itself is already synced to another player, this may fail.
1050 If the player can not be synced with the given target player, this may fail.
1051
1052 NOTE: This is a convenience helper for cmd_set_members.
1053
1054 :param player_id: player_id of the player to handle the command.
1055 :param target_player: player_id of the syncgroup leader or group player.
1056
1057 :raises UnsupportedFeaturedException: if the target player does not support grouping.
1058 :raises PlayerCommandFailed: if the target player is already synced to another player.
1059 :raises PlayerUnavailableError: if the target player is not available.
1060 :raises PlayerCommandFailed: if the player is already grouped to another player.
1061 """
1062 await self.cmd_set_members(target_player, player_ids_to_add=[player_id])
1063
1064 @api_command("players/cmd/group_many")
1065 async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
1066 """
1067 Join given player(s) to target player.
1068
1069 Will add the given player(s) to the target player (sync leader or group player).
1070 This is a (deprecated) alias for cmd_set_members.
1071 """
1072 await self.cmd_set_members(target_player, player_ids_to_add=child_player_ids)
1073
1074 @api_command("players/cmd/ungroup")
1075 @handle_player_command
1076 async def cmd_ungroup(self, player_id: str) -> None:
1077 """
1078 Handle UNGROUP command for given player.
1079
1080 Remove the given player from any (sync)groups it currently is synced to.
1081 If the player is not currently grouped to any other player,
1082 this will silently be ignored.
1083
1084 NOTE: This is a convenience helper for cmd_set_members.
1085 """
1086 if not (player := self.get_player(player_id)):
1087 self.logger.warning("Player %s is not available", player_id)
1088 return
1089
1090 if player.state.active_group:
1091 # the player is part of a (permanent) groupplayer and the user tries to ungroup
1092 await self.cmd_set_members(player.state.active_group, player_ids_to_remove=[player_id])
1093 return
1094
1095 if player.state.synced_to:
1096 # player is a sync member
1097 await self.cmd_set_members(player.state.synced_to, player_ids_to_remove=[player_id])
1098 return
1099
1100 if player.state.group_members:
1101 # player is a sync leader, so we ungroup all members from it
1102 await self.cmd_set_members(
1103 player.player_id, player_ids_to_remove=player.state.group_members
1104 )
1105 return
1106
1107 @api_command("players/cmd/ungroup_many")
1108 async def cmd_ungroup_many(self, player_ids: list[str]) -> None:
1109 """Handle UNGROUP command for all the given players."""
1110 for player_id in list(player_ids):
1111 await self.cmd_ungroup(player_id)
1112
1113 @api_command("players/create_group_player", required_role="admin")
1114 async def create_group_player(
1115 self, provider: str, name: str, members: list[str], dynamic: bool = True
1116 ) -> Player:
1117 """
1118 Create a new (permanent) Group Player.
1119
1120 :param provider: The provider (id) to create the group player for.
1121 :param name: Name of the new group player.
1122 :param members: List of player ids to add to the group.
1123 :param dynamic: Whether the group is dynamic (members can change).
1124 """
1125 if not (provider_instance := self.mass.get_provider(provider)):
1126 raise ProviderUnavailableError(f"Provider {provider} not found")
1127 provider_instance = cast("PlayerProvider", provider_instance)
1128 if ProviderFeature.CREATE_GROUP_PLAYER not in provider_instance.supported_features:
1129 raise UnsupportedFeaturedException(
1130 f"Provider {provider} does not support creating group players"
1131 )
1132 return await provider_instance.create_group_player(name, members, dynamic)
1133
1134 @api_command("players/remove_group_player", required_role="admin")
1135 async def remove_group_player(self, player_id: str) -> None:
1136 """Remove a group player."""
1137 if not (player := self.get_player(player_id)):
1138 # we simply permanently delete the player by wiping its config
1139 self.mass.config.remove(f"players/{player_id}")
1140 return
1141 if player.state.type != PlayerType.GROUP:
1142 raise UnsupportedFeaturedException(f"Player {player.state.name} is not a group player")
1143 player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
1144 await player.provider.remove_group_player(player_id)
1145
1146 @api_command("players/add_currently_playing_to_favorites")
1147 async def add_currently_playing_to_favorites(self, player_id: str) -> None:
1148 """
1149 Add the currently playing item/track on given player to the favorites.
1150
1151 This tries to resolve the currently playing media to an actual media item
1152 and add that to the favorites in the library. Will raise an error if the
1153 player is not currently playing anything or if the currently playing media
1154 can not be resolved to a media item.
1155 """
1156 player = self._get_player_with_redirect(player_id)
1157 # handle mass player queue active
1158 if mass_queue := self.get_active_queue(player):
1159 if not (current_item := mass_queue.current_item) or not current_item.media_item:
1160 raise PlayerCommandFailed("No current item to add to favorites")
1161 # if we're playing a radio station, try to resolve the currently playing track
1162 if current_item.media_item.media_type == MediaType.RADIO:
1163 if not (
1164 (streamdetails := mass_queue.current_item.streamdetails)
1165 and (stream_title := streamdetails.stream_title)
1166 and " - " in stream_title
1167 ):
1168 # no stream title available, so we can't resolve the track
1169 # this can happen if the radio station does not provide metadata
1170 # or there's a commercial break
1171 # Possible future improvement could be to actually detect the song with a
1172 # shazam-like approach.
1173 raise PlayerCommandFailed("No current item to add to favorites")
1174 # send the streamtitle into a global search query
1175 search_artist, search_title_title = stream_title.split(" - ", 1)
1176 # strip off any additional comments in the title (such as from Radio Paradise)
1177 search_title_title = search_title_title.split(" | ")[0].strip()
1178 if track := await self.mass.music.get_track_by_name(
1179 search_title_title, search_artist
1180 ):
1181 # we found a track, so add it to the favorites
1182 await self.mass.music.add_item_to_favorites(track)
1183 return
1184 # we could not resolve the track, so raise an error
1185 raise PlayerCommandFailed("No current item to add to favorites")
1186
1187 # else: any other media item, just add it to the favorites directly
1188 await self.mass.music.add_item_to_favorites(current_item.media_item)
1189 return
1190
1191 # guard for player with no active source
1192 if not player.state.active_source:
1193 raise PlayerCommandFailed("Player has no active source")
1194 # handle other source active using the current_media with uri
1195 if current_media := player.state.current_media:
1196 # prefer the uri of the current media item
1197 if current_media.uri:
1198 with suppress(MusicAssistantError):
1199 await self.mass.music.add_item_to_favorites(current_media.uri)
1200 return
1201 # fallback to search based on artist and title (and album if available)
1202 if current_media.artist and current_media.title:
1203 if track := await self.mass.music.get_track_by_name(
1204 current_media.title,
1205 current_media.artist,
1206 current_media.album,
1207 ):
1208 # we found a track, so add it to the favorites
1209 await self.mass.music.add_item_to_favorites(track)
1210 return
1211 # if we reach here, we could not resolve the currently playing item
1212 raise PlayerCommandFailed("No current item to add to favorites")
1213
1214 async def register(self, player: Player) -> None:
1215 """Register a player on the Player Controller."""
1216 if self.mass.closing:
1217 return
1218
1219 # Use lock to prevent race conditions during concurrent player registrations
1220 async with self._register_lock:
1221 player_id = player.player_id
1222
1223 if player_id in self._players:
1224 msg = f"Player {player_id} is already registered!"
1225 raise AlreadyRegisteredError(msg)
1226
1227 # ignore disabled players
1228 if not player.state.enabled:
1229 return
1230
1231 # register throttler for this player
1232 self._player_throttlers[player_id] = Throttler(1, 0.05)
1233
1234 # restore 'fake' power state from cache if available
1235 cached_value = await self.mass.cache.get(
1236 key=player.player_id,
1237 provider=self.domain,
1238 category=CACHE_CATEGORY_PLAYER_POWER,
1239 default=False,
1240 )
1241 if cached_value is not None:
1242 player.extra_data[ATTR_FAKE_POWER] = cached_value
1243
1244 # finally actually register it
1245 self._players[player_id] = player
1246 # update state without signaling event first (ensure all attributes are set)
1247 player.update_state(signal_event=False)
1248
1249 # ensure we fetch and set the latest/full config for the player
1250 player_config = await self.mass.config.get_player_config(player_id)
1251 player.set_config(player_config)
1252 # call hook after the player is registered and config is set
1253 await player.on_config_updated()
1254
1255 # Handle protocol linking
1256 # First enrich identifiers with real MAC (resolves virtual MACs via ARP)
1257 await self._enrich_player_identifiers(player)
1258 self._evaluate_protocol_links(player)
1259
1260 self.logger.info(
1261 "Player (type %s) registered: %s/%s",
1262 player.state.type.value,
1263 player_id,
1264 player.state.name,
1265 )
1266 # signal event that a player was added
1267
1268 if player.state.type != PlayerType.PROTOCOL:
1269 self.mass.signal_event(
1270 EventType.PLAYER_ADDED, object_id=player.player_id, data=player
1271 )
1272
1273 # register playerqueue for this player
1274 # Skip if this is a protocol player pending evaluation (queue created when promoted)
1275 if (
1276 player.state.type != PlayerType.PROTOCOL
1277 and player.player_id not in self._pending_protocol_evaluations
1278 ):
1279 await self.mass.player_queues.on_player_register(player)
1280
1281 # always call update to fix special attributes like display name, group volume etc.
1282 player.update_state()
1283
1284 # Schedule debounced update of all players since can_group_with values may change
1285 # when a new player is added (provider IDs expand to include the new player)
1286 self._schedule_update_all_players()
1287
1288 async def register_or_update(self, player: Player) -> None:
1289 """Register a new player on the controller or update existing one."""
1290 if self.mass.closing:
1291 return
1292
1293 if player.player_id in self._players:
1294 self._players[player.player_id] = player
1295 player.update_state()
1296 # Also schedule update when replacing existing player
1297 self._schedule_update_all_players()
1298 return
1299
1300 await self.register(player)
1301
1302 def trigger_player_update(
1303 self, player_id: str, force_update: bool = False, debounce_delay: float = 0.25
1304 ) -> None:
1305 """Trigger a (debounced) update for the given player."""
1306 if self.mass.closing:
1307 return
1308 if not (player := self.get_player(player_id)):
1309 return
1310 task_id = f"player_update_state_{player_id}"
1311 self.mass.call_later(
1312 debounce_delay,
1313 player.update_state,
1314 force_update=force_update,
1315 task_id=task_id,
1316 )
1317
1318 async def unregister(self, player_id: str, permanent: bool = False) -> None:
1319 """
1320 Unregister a player from the player controller.
1321
1322 Called (by a PlayerProvider) when a player is removed or no longer available
1323 (for a longer period of time). This will remove the player from the player
1324 controller and optionally remove the player's config from the mass config.
1325 If the player is not registered, this will silently be ignored.
1326
1327 :param player_id: Player ID of the player to unregister.
1328 :param permanent: If True, remove the player permanently by deleting its config.
1329 If False, the player config will not be removed.
1330 """
1331 player = self._players.get(player_id)
1332 if player is None:
1333 return
1334 await self._cleanup_player_memberships(player_id)
1335 del self._players[player_id]
1336 self.mass.player_queues.on_player_remove(player_id, permanent=permanent)
1337 await player.on_unload()
1338 if permanent:
1339 # player permanent removal: cleanup protocol links, delete config
1340 # and signal PLAYER_REMOVED event
1341 self._cleanup_protocol_links(player)
1342 self.delete_player_config(player_id)
1343 self.logger.info("Player removed: %s", player.name)
1344 if player.state.type != PlayerType.PROTOCOL:
1345 self.mass.signal_event(EventType.PLAYER_REMOVED, player_id)
1346 else:
1347 # temporary unavailable: mark player as unavailable
1348 # note: the player will be re-registered later if it comes back online
1349 player.state.available = False
1350 self.logger.info("Player unavailable: %s", player.name)
1351 if player.state.type != PlayerType.PROTOCOL:
1352 self.mass.signal_event(
1353 EventType.PLAYER_UPDATED, object_id=player.player_id, data=player.state
1354 )
1355 # Schedule debounced update of all players since can_group_with values may change
1356 self._schedule_update_all_players()
1357
1358 @api_command("players/remove", required_role="admin")
1359 async def remove(self, player_id: str) -> None:
1360 """
1361 Remove a player from a provider.
1362
1363 Can only be called when a PlayerProvider supports ProviderFeature.REMOVE_PLAYER.
1364 """
1365 player = self.get_player(player_id)
1366 if player is None:
1367 # we simply permanently delete the player config since it is not registered
1368 self.delete_player_config(player_id)
1369 return
1370 if player.state.type == PlayerType.GROUP:
1371 # Handle group player removal
1372 player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
1373 await player.provider.remove_group_player(player_id)
1374 return
1375 player.provider.check_feature(ProviderFeature.REMOVE_PLAYER)
1376 await player.provider.remove_player(player_id)
1377 # check for group memberships that need to be updated
1378 if player.state.active_group and (
1379 group_player := self.mass.players.get_player(player.state.active_group)
1380 ):
1381 # try to remove from the group
1382 with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
1383 await group_player.set_members(
1384 player_ids_to_remove=[player_id],
1385 )
1386 # We removed the player and can now clean up its config
1387 self.delete_player_config(player_id)
1388
1389 def delete_player_config(self, player_id: str) -> None:
1390 """
1391 Permanently delete a player's configuration.
1392
1393 Should only be called for players that are not registered by the player controller.
1394 """
1395 # we simply permanently delete the player by wiping its config
1396 conf_key = f"{CONF_PLAYERS}/{player_id}"
1397 dsp_conf_key = f"{CONF_PLAYER_DSP}/{player_id}"
1398 for key in (conf_key, dsp_conf_key):
1399 self.mass.config.remove(key)
1400
1401 def signal_player_state_update(
1402 self,
1403 player: Player,
1404 changed_values: dict[str, tuple[Any, Any]],
1405 force_update: bool = False,
1406 skip_forward: bool = False,
1407 ) -> None:
1408 """
1409 Signal a player state update.
1410
1411 Called by a Player when its state has changed.
1412 This will update the player state in the controller and signal the event bus.
1413 """
1414 player_id = player.player_id
1415 if self.mass.closing:
1416 return
1417
1418 # ignore updates for disabled players
1419 if not player.state.enabled and ATTR_ENABLED not in changed_values:
1420 return
1421
1422 if len(changed_values) == 0 and not force_update:
1423 # nothing changed
1424 return
1425
1426 # always signal update to the playerqueue
1427 if player.state.type != PlayerType.PROTOCOL:
1428 self.mass.player_queues.on_player_update(player, changed_values)
1429
1430 # to prevent spamming the eventbus on small changes (e.g. elapsed time),
1431 # we check if there are only changes in the elapsed time
1432 clean_changed_keys = set(changed_values.keys()) - {"current_media.elapsed_time"}
1433 if clean_changed_keys == {ATTR_ELAPSED_TIME} and not force_update:
1434 # ignore small changes in elapsed time
1435 prev_value = changed_values[ATTR_ELAPSED_TIME][0] or 0
1436 new_value = changed_values[ATTR_ELAPSED_TIME][1] or 0
1437 if abs(prev_value - new_value) < 5:
1438 return
1439
1440 # handle DSP reload of the leader when grouping/ungrouping
1441 if ATTR_GROUP_MEMBERS in changed_values:
1442 prev_group_members, new_group_members = changed_values[ATTR_GROUP_MEMBERS]
1443 self._handle_group_dsp_change(player, prev_group_members or [], new_group_members)
1444
1445 if ATTR_GROUP_MEMBERS in changed_values:
1446 # Removed group members also need to be updated since they are no longer part
1447 # of this group and are available for playback again
1448 prev_group_members = changed_values[ATTR_GROUP_MEMBERS][0] or []
1449 new_group_members = changed_values[ATTR_GROUP_MEMBERS][1] or []
1450 removed_members = set(prev_group_members) - set(new_group_members)
1451 for _removed_player_id in removed_members:
1452 if removed_player := self.get_player(_removed_player_id):
1453 removed_player.update_state()
1454
1455 # Handle external source takeover - detect when active_source changes to
1456 # something external while we have a grouped protocol active
1457 if ATTR_ACTIVE_SOURCE in changed_values:
1458 prev_source, new_source = changed_values[ATTR_ACTIVE_SOURCE]
1459 task_id = f"external_source_takeover_{player_id}"
1460 self.mass.call_later(
1461 3,
1462 self._handle_external_source_takeover,
1463 player,
1464 prev_source,
1465 new_source,
1466 task_id=task_id,
1467 )
1468 became_inactive = False
1469 if ATTR_AVAILABLE in changed_values:
1470 became_inactive = changed_values[ATTR_AVAILABLE][1] is False
1471 if not became_inactive and ATTR_ENABLED in changed_values:
1472 became_inactive = changed_values[ATTR_ENABLED][1] is False
1473 if became_inactive and (player.state.active_group or player.state.synced_to):
1474 self.mass.create_task(self._cleanup_player_memberships(player.player_id))
1475
1476 # signal player update on the eventbus
1477 if player.state.type != PlayerType.PROTOCOL:
1478 self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player)
1479
1480 # signal a separate PlayerOptionsUpdated event
1481 if options := changed_values.get("options"):
1482 self.mass.signal_event(
1483 EventType.PLAYER_OPTIONS_UPDATED, object_id=player_id, data=options
1484 )
1485
1486 if skip_forward and not force_update:
1487 return
1488
1489 # update/signal group player(s) child's when group updates
1490 for child_player in self.iter_group_members(player, exclude_self=True):
1491 self.trigger_player_update(child_player.player_id)
1492 # update/signal group player(s) when child updates
1493 for group_player in self._get_player_groups(player, powered_only=False):
1494 self.trigger_player_update(group_player.player_id)
1495 # update/signal manually synced to player when child updates
1496 if (synced_to := player.state.synced_to) and (
1497 synced_to_player := self.get_player(synced_to)
1498 ):
1499 self.trigger_player_update(synced_to_player.player_id)
1500 # update/signal active groups when a group member updates
1501 if (active_group := player.state.active_group) and (
1502 active_group_player := self.get_player(active_group)
1503 ):
1504 self.trigger_player_update(active_group_player.player_id)
1505 # If this is a protocol player, forward the state update to the parent player
1506 if player.protocol_parent_id and (
1507 parent_player := self.mass.players.get_player(player.protocol_parent_id)
1508 ):
1509 self.trigger_player_update(parent_player.player_id)
1510 # If this is a parent player with linked protocols, forward state updates
1511 # to linked protocol players so their state reflects parent dependencies
1512 if player.state.type != PlayerType.PROTOCOL and player.linked_output_protocols:
1513 for linked in player.linked_output_protocols:
1514 if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
1515 self.mass.players.trigger_player_update(protocol_player.player_id)
1516 # trigger update of all players in a provider if group related fields changed
1517 if any(key in changed_values for key in ("group_members", "synced_to", "available")):
1518 for prov_player in player.provider.players:
1519 self.trigger_player_update(prov_player.player_id)
1520
1521 async def register_player_control(self, player_control: PlayerControl) -> None:
1522 """Register a new PlayerControl on the controller."""
1523 if self.mass.closing:
1524 return
1525 control_id = player_control.id
1526
1527 if control_id in self._controls:
1528 msg = f"PlayerControl {control_id} is already registered"
1529 raise AlreadyRegisteredError(msg)
1530
1531 # make sure that the playercontrol's provider is set to the instance_id
1532 prov = self.mass.get_provider(player_control.provider)
1533 if not prov or prov.instance_id != player_control.provider:
1534 raise RuntimeError(f"Invalid provider ID given: {player_control.provider}")
1535
1536 self._controls[control_id] = player_control
1537
1538 self.logger.info(
1539 "PlayerControl registered: %s/%s",
1540 control_id,
1541 player_control.name,
1542 )
1543
1544 # always call update to update any attached players etc.
1545 self.update_player_control(player_control.id)
1546
1547 async def register_or_update_player_control(self, player_control: PlayerControl) -> None:
1548 """Register a new playercontrol on the controller or update existing one."""
1549 if self.mass.closing:
1550 return
1551 if player_control.id in self._controls:
1552 self._controls[player_control.id] = player_control
1553 self.update_player_control(player_control.id)
1554 return
1555 await self.register_player_control(player_control)
1556
1557 def update_player_control(self, control_id: str) -> None:
1558 """Update playercontrol state."""
1559 if self.mass.closing:
1560 return
1561 # update all players that are using this control
1562 for player in list(self._players.values()):
1563 if control_id in (
1564 player.state.power_control,
1565 player.state.volume_control,
1566 player.state.mute_control,
1567 ):
1568 self.mass.loop.call_soon(player.update_state)
1569
1570 def remove_player_control(self, control_id: str) -> None:
1571 """Remove a player_control from the player manager."""
1572 control = self._controls.pop(control_id, None)
1573 if control is None:
1574 return
1575 self._controls.pop(control_id, None)
1576 self.logger.info("PlayerControl removed: %s", control.name)
1577
1578 def get_player_provider(self, player_id: str) -> PlayerProvider:
1579 """Return PlayerProvider for given player."""
1580 player = self._players[player_id]
1581 assert player # for type checker
1582 return player.provider
1583
1584 def get_active_queue(self, player: Player) -> PlayerQueue | None:
1585 """Return the current active queue for a player (if any)."""
1586 # account for player that is synced (sync child)
1587 if player.state.synced_to and player.state.synced_to != player.player_id:
1588 if sync_leader := self.get_player(player.state.synced_to):
1589 return self.get_active_queue(sync_leader)
1590 # handle active group player
1591 if player.state.active_group and player.state.active_group != player.player_id:
1592 if group_player := self.get_player(player.state.active_group):
1593 return self.get_active_queue(group_player)
1594 # active_source may be filled queue id (or None)
1595 active_source = player.state.active_source or player.player_id
1596 if active_queue := self.mass.player_queues.get(active_source):
1597 return active_queue
1598 # handle active protocol player with parent player queue
1599 if player.type == PlayerType.PROTOCOL and player.protocol_parent_id:
1600 if parent_player := self.mass.players.get_player(player.protocol_parent_id):
1601 return self.get_active_queue(parent_player)
1602 return None
1603
1604 async def set_group_volume(self, group_player: Player, volume_level: int) -> None:
1605 """Handle adjusting the overall/group volume to a playergroup (or synced players)."""
1606 cur_volume = group_player.state.group_volume
1607 volume_dif = volume_level - cur_volume
1608 coros = []
1609 # handle group volume by only applying the volume to powered members
1610 for child_player in self.iter_group_members(
1611 group_player, only_powered=True, exclude_self=False
1612 ):
1613 if child_player.state.volume_control == PLAYER_CONTROL_NONE:
1614 continue
1615 cur_child_volume = child_player.state.volume_level or 0
1616 new_child_volume = int(cur_child_volume + volume_dif)
1617 new_child_volume = max(0, new_child_volume)
1618 new_child_volume = min(100, new_child_volume)
1619 # Use private method to skip permission check - already validated on group
1620 # ATTR_MUTE_LOCK on muted players prevents auto-unmute during group volume changes
1621 coros.append(self._handle_cmd_volume_set(child_player.player_id, new_child_volume))
1622 await asyncio.gather(*coros)
1623
1624 def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None:
1625 """Get the (player specific) volume for a announcement."""
1626 volume_strategy = self.mass.config.get_raw_player_config_value(
1627 player_id,
1628 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
1629 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
1630 )
1631 volume_strategy_volume = self.mass.config.get_raw_player_config_value(
1632 player_id,
1633 CONF_ENTRY_ANNOUNCE_VOLUME.key,
1634 CONF_ENTRY_ANNOUNCE_VOLUME.default_value,
1635 )
1636 if volume_strategy == "none":
1637 return None
1638 volume_level = volume_override
1639 if volume_level is None and volume_strategy == "absolute":
1640 volume_level = int(cast("float", volume_strategy_volume))
1641 elif volume_level is None and volume_strategy == "relative":
1642 if (player := self.get_player(player_id)) and player.state.volume_level is not None:
1643 volume_level = int(
1644 player.state.volume_level + cast("float", volume_strategy_volume)
1645 )
1646 elif volume_level is None and volume_strategy == "percentual":
1647 if (player := self.get_player(player_id)) and player.state.volume_level is not None:
1648 percentual = (player.state.volume_level / 100) * cast(
1649 "float", volume_strategy_volume
1650 )
1651 volume_level = int(player.state.volume_level + percentual)
1652 if volume_level is not None:
1653 announce_volume_min = cast(
1654 "float",
1655 self.mass.config.get_raw_player_config_value(
1656 player_id,
1657 CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key,
1658 CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value,
1659 ),
1660 )
1661 volume_level = max(int(announce_volume_min), volume_level)
1662 announce_volume_max = cast(
1663 "float",
1664 self.mass.config.get_raw_player_config_value(
1665 player_id,
1666 CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key,
1667 CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value,
1668 ),
1669 )
1670 volume_level = min(int(announce_volume_max), volume_level)
1671 return None if volume_level is None else int(volume_level)
1672
1673 def iter_group_members(
1674 self,
1675 group_player: Player,
1676 only_powered: bool = False,
1677 only_playing: bool = False,
1678 active_only: bool = False,
1679 exclude_self: bool = True,
1680 ) -> Iterator[Player]:
1681 """Get (child) players attached to a group player or syncgroup."""
1682 for child_id in list(group_player.state.group_members):
1683 if child_player := self.get_player(child_id, False):
1684 if not child_player.state.available or not child_player.state.enabled:
1685 continue
1686 if only_powered and child_player.state.powered is False:
1687 continue
1688 if active_only and child_player.state.active_group != group_player.player_id:
1689 continue
1690 if exclude_self and child_player.player_id == group_player.player_id:
1691 continue
1692 if only_playing and child_player.state.playback_state not in (
1693 PlaybackState.PLAYING,
1694 PlaybackState.PAUSED,
1695 ):
1696 continue
1697 yield child_player
1698
1699 async def wait_for_state(
1700 self,
1701 player: Player,
1702 wanted_state: PlaybackState,
1703 timeout: float = 60.0,
1704 minimal_time: float = 0,
1705 ) -> None:
1706 """Wait for the given player to reach the given state."""
1707 start_timestamp = time.time()
1708 self.logger.debug(
1709 "Waiting for player %s to reach state %s", player.state.name, wanted_state
1710 )
1711 try:
1712 async with asyncio.timeout(timeout):
1713 while player.state.playback_state != wanted_state:
1714 await asyncio.sleep(0.1)
1715
1716 except TimeoutError:
1717 self.logger.debug(
1718 "Player %s did not reach state %s within the timeout of %s seconds",
1719 player.state.name,
1720 wanted_state,
1721 timeout,
1722 )
1723 elapsed_time = round(time.time() - start_timestamp, 2)
1724 if elapsed_time < minimal_time:
1725 self.logger.debug(
1726 "Player %s reached state %s too soon (%s vs %s seconds) - add fallback sleep...",
1727 player.state.name,
1728 wanted_state,
1729 elapsed_time,
1730 minimal_time,
1731 )
1732 await asyncio.sleep(minimal_time - elapsed_time)
1733 else:
1734 self.logger.debug(
1735 "Player %s reached state %s within %s seconds",
1736 player.state.name,
1737 wanted_state,
1738 elapsed_time,
1739 )
1740
1741 async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
1742 """Call (by config manager) when the configuration of a player changes."""
1743 player = self.get_player(config.player_id)
1744 player_provider = self.mass.get_provider(config.provider)
1745 player_disabled = ATTR_ENABLED in changed_keys and not config.enabled
1746 player_enabled = ATTR_ENABLED in changed_keys and config.enabled
1747
1748 if player_disabled and player and player.state.available:
1749 # edge case: ensure that the player is powered off if the player gets disabled
1750 if player.state.power_control != PLAYER_CONTROL_NONE:
1751 await self._handle_cmd_power(config.player_id, False)
1752 elif player.state.playback_state != PlaybackState.IDLE:
1753 await self.cmd_stop(config.player_id)
1754
1755 # signal player provider that the player got enabled/disabled
1756 if (player_enabled or player_disabled) and player_provider:
1757 assert isinstance(player_provider, PlayerProvider) # for type checking
1758 if player_disabled:
1759 player_provider.on_player_disabled(config.player_id)
1760 elif player_enabled:
1761 player_provider.on_player_enabled(config.player_id)
1762 return # enabling/disabling a player will be handled by the provider
1763
1764 if not player:
1765 return # guard against player not being registered (yet)
1766
1767 resume_queue: PlayerQueue | None = (
1768 self.mass.player_queues.get(player.state.active_source)
1769 if player.state.active_source
1770 else None
1771 )
1772
1773 # ensure player state gets updated with any updated config
1774 player.set_config(config)
1775 await player.on_config_updated()
1776 player.update_state()
1777 # if the PlayerQueue was playing, restart playback
1778 if resume_queue and resume_queue.state == PlaybackState.PLAYING:
1779 requires_restart = any(
1780 v for v in config.values.values() if v.key in changed_keys and v.requires_reload
1781 )
1782 if requires_restart:
1783 # always stop first to ensure the player uses the new config
1784 await self.mass.player_queues.stop(resume_queue.queue_id)
1785 self.mass.call_later(
1786 1, self.mass.player_queues.resume, resume_queue.queue_id, False
1787 )
1788
1789 async def on_player_dsp_change(self, player_id: str) -> None:
1790 """Call (by config manager) when the DSP settings of a player change."""
1791 # signal player provider that the config changed
1792 if not (player := self.get_player(player_id)):
1793 return
1794 if player.state.playback_state == PlaybackState.PLAYING:
1795 self.logger.info("Restarting playback of Player %s after DSP change", player_id)
1796 # this will restart the queue stream/playback
1797 if player.mass_queue_active:
1798 self.mass.call_later(
1799 0, self.mass.player_queues.resume, player.state.active_source, False
1800 )
1801 return
1802 # if the player is not using a queue, we need to stop and start playback
1803 await self.cmd_stop(player_id)
1804 await self.cmd_play(player_id)
1805
1806 async def _cleanup_player_memberships(self, player_id: str) -> None:
1807 """Ensure a player is detached from any groups or syncgroups."""
1808 if not self.get_player(player_id):
1809 return
1810 with suppress(UnsupportedFeaturedException, PlayerCommandFailed, PlayerUnavailableError):
1811 await self.cmd_ungroup(player_id)
1812
1813 def _get_player_with_redirect(self, player_id: str) -> Player:
1814 """Get player with check if playback related command should be redirected."""
1815 player = self.get_player(player_id, True)
1816 assert player is not None # for type checking
1817 if player.state.synced_to and (sync_leader := self.get_player(player.state.synced_to)):
1818 self.logger.info(
1819 "Player %s is synced to %s and can not accept "
1820 "playback related commands itself, "
1821 "redirected the command to the sync leader.",
1822 player.name,
1823 sync_leader.name,
1824 )
1825 return sync_leader
1826 if player.state.active_group and (
1827 active_group := self.get_player(player.state.active_group)
1828 ):
1829 self.logger.info(
1830 "Player %s is part of a playergroup and can not accept "
1831 "playback related commands itself, "
1832 "redirected the command to the group leader.",
1833 player.name,
1834 )
1835 return active_group
1836 return player
1837
1838 def _get_active_plugin_source(self, player: Player) -> PluginSource | None:
1839 """Get the active PluginSource for a player if any."""
1840 # Check if any plugin source is in use by this player
1841 for plugin_source in self.get_plugin_sources():
1842 if plugin_source.in_use_by == player.player_id:
1843 return plugin_source
1844 if player.state.active_source == plugin_source.id:
1845 return plugin_source
1846 return None
1847
1848 def _get_player_groups(
1849 self, player: Player, available_only: bool = True, powered_only: bool = False
1850 ) -> Iterator[Player]:
1851 """Return all groupplayers the given player belongs to."""
1852 for _player in self.all_players(return_unavailable=not available_only):
1853 if _player.player_id == player.player_id:
1854 continue
1855 if _player.state.type != PlayerType.GROUP:
1856 continue
1857 if powered_only and _player.state.powered is False:
1858 continue
1859 if player.player_id in _player.state.group_members:
1860 yield _player
1861
1862 # Protocol linking methods are provided by ProtocolLinkingMixin (protocol_linking.py)
1863
1864 async def _play_announcement( # noqa: PLR0915
1865 self,
1866 player: Player,
1867 announcement: PlayerMedia,
1868 volume_level: int | None = None,
1869 ) -> None:
1870 """Handle (default/fallback) implementation of the play announcement feature.
1871
1872 This default implementation will;
1873 - stop playback of the current media (if needed)
1874 - power on the player (if needed)
1875 - raise the volume a bit
1876 - play the announcement (from given url)
1877 - wait for the player to finish playing
1878 - restore the previous power and volume
1879 - restore playback (if needed and if possible)
1880
1881 This default implementation will only be used if the player
1882 (provider) has no native support for the PLAY_ANNOUNCEMENT feature.
1883 """
1884 prev_state = player.state.playback_state
1885 prev_power = player.state.powered or prev_state != PlaybackState.IDLE
1886 prev_synced_to = player.state.synced_to
1887 prev_group = (
1888 self.get_player(player.state.active_group) if player.state.active_group else None
1889 )
1890 prev_source = player.state.active_source
1891 prev_media = player.state.current_media
1892 prev_media_name = prev_media.title or prev_media.uri if prev_media else None
1893 if prev_synced_to:
1894 # ungroup player if its currently synced
1895 self.logger.debug(
1896 "Announcement to player %s - ungrouping player from %s...",
1897 player.state.name,
1898 prev_synced_to,
1899 )
1900 await self.cmd_ungroup(player.player_id)
1901 elif prev_group:
1902 # if the player is part of a group player, we need to ungroup it
1903 if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
1904 self.logger.debug(
1905 "Announcement to player %s - ungrouping from group player %s...",
1906 player.state.name,
1907 prev_group.display_name,
1908 )
1909 await prev_group.set_members(player_ids_to_remove=[player.player_id])
1910 else:
1911 # if the player is part of a group player that does not support ungrouping,
1912 # we need to power off the groupplayer instead
1913 self.logger.debug(
1914 "Announcement to player %s - turning off group player %s...",
1915 player.state.name,
1916 prev_group.display_name,
1917 )
1918 await self._handle_cmd_power(player.player_id, False)
1919 elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
1920 # normal/standalone player: stop player if its currently playing
1921 self.logger.debug(
1922 "Announcement to player %s - stop existing content (%s)...",
1923 player.state.name,
1924 prev_media_name,
1925 )
1926 await self.cmd_stop(player.player_id)
1927 # wait for the player to stop
1928 await self.wait_for_state(player, PlaybackState.IDLE, 10, 0.4)
1929 # adjust volume if needed
1930 # in case of a (sync) group, we need to do this for all child players
1931 prev_volumes: dict[str, int] = {}
1932 async with TaskManager(self.mass) as tg:
1933 for volume_player_id in player.state.group_members or (player.player_id,):
1934 if not (volume_player := self.get_player(volume_player_id)):
1935 continue
1936 # catch any players that have a different source active
1937 if (
1938 volume_player.state.active_source
1939 not in (
1940 player.state.active_source,
1941 volume_player.player_id,
1942 None,
1943 )
1944 and volume_player.state.playback_state == PlaybackState.PLAYING
1945 ):
1946 self.logger.warning(
1947 "Detected announcement to playergroup %s while group member %s is playing "
1948 "other content, this may lead to unexpected behavior.",
1949 player.state.name,
1950 volume_player.state.name,
1951 )
1952 tg.create_task(self.cmd_stop(volume_player.player_id))
1953 if volume_player.state.volume_control == PLAYER_CONTROL_NONE:
1954 continue
1955 if (prev_volume := volume_player.state.volume_level) is None:
1956 continue
1957 announcement_volume = self.get_announcement_volume(volume_player_id, volume_level)
1958 if announcement_volume is None:
1959 continue
1960 temp_volume = announcement_volume or player.state.volume_level
1961 if temp_volume != prev_volume:
1962 prev_volumes[volume_player_id] = prev_volume
1963 self.logger.debug(
1964 "Announcement to player %s - setting temporary volume (%s)...",
1965 volume_player.state.name,
1966 announcement_volume,
1967 )
1968 tg.create_task(
1969 self._handle_cmd_volume_set(volume_player.player_id, announcement_volume)
1970 )
1971 # play the announcement
1972 self.logger.debug(
1973 "Announcement to player %s - playing the announcement on the player...",
1974 player.state.name,
1975 )
1976 await self.play_media(player_id=player.player_id, media=announcement)
1977 # wait for the player(s) to play
1978 await self.wait_for_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1)
1979 # wait for the player to stop playing
1980 if not announcement.duration:
1981 if not announcement.custom_data:
1982 raise ValueError("Announcement missing duration and custom_data")
1983 media_info = await async_parse_tags(
1984 announcement.custom_data["announcement_url"], require_duration=True
1985 )
1986 announcement.duration = int(media_info.duration) if media_info.duration else None
1987
1988 if announcement.duration is None:
1989 raise ValueError("Announcement duration could not be determined")
1990
1991 await self.wait_for_state(
1992 player,
1993 PlaybackState.IDLE,
1994 timeout=announcement.duration + 10,
1995 minimal_time=float(announcement.duration) + 2,
1996 )
1997 self.logger.debug(
1998 "Announcement to player %s - restore previous state...", player.state.name
1999 )
2000 # restore volume
2001 async with TaskManager(self.mass) as tg:
2002 for volume_player_id, prev_volume in prev_volumes.items():
2003 tg.create_task(self._handle_cmd_volume_set(volume_player_id, prev_volume))
2004 await asyncio.sleep(0.2)
2005 # either power off the player or resume playing
2006 if not prev_power:
2007 if player.state.power_control != PLAYER_CONTROL_NONE:
2008 self.logger.debug(
2009 "Announcement to player %s - turning player off again...", player.state.name
2010 )
2011 await self._handle_cmd_power(player.player_id, False)
2012 # nothing to do anymore, player was not previously powered
2013 # and does not support power control
2014 return
2015 if prev_synced_to:
2016 self.logger.debug(
2017 "Announcement to player %s - syncing back to %s...",
2018 player.state.name,
2019 prev_synced_to,
2020 )
2021 await self.cmd_set_members(prev_synced_to, player_ids_to_add=[player.player_id])
2022 elif prev_group:
2023 if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
2024 self.logger.debug(
2025 "Announcement to player %s - grouping back to group player %s...",
2026 player.state.name,
2027 prev_group.display_name,
2028 )
2029 await prev_group.set_members(player_ids_to_add=[player.player_id])
2030 elif prev_state == PlaybackState.PLAYING:
2031 # if the player is part of a group player that does not support set_members,
2032 # we need to restart the groupplayer
2033 self.logger.debug(
2034 "Announcement to player %s - restarting playback on group player %s...",
2035 player.state.name,
2036 prev_group.display_name,
2037 )
2038 await self.cmd_play(prev_group.player_id)
2039 elif prev_state == PlaybackState.PLAYING:
2040 # player was playing something before the announcement - try to resume that here
2041 await self._handle_cmd_resume(player.player_id, prev_source, prev_media)
2042
2043 async def _poll_players(self) -> None:
2044 """Background task that polls players for updates."""
2045 while True:
2046 for player in list(self._players.values()):
2047 # if the player is playing, update elapsed time every tick
2048 # to ensure the queue has accurate details
2049 player_playing = player.state.playback_state == PlaybackState.PLAYING
2050 if player_playing:
2051 self.mass.loop.call_soon(
2052 self.mass.player_queues.on_player_update,
2053 player,
2054 {"corrected_elapsed_time": (None, player.corrected_elapsed_time)},
2055 )
2056 # Poll player;
2057 if not player.needs_poll:
2058 continue
2059 try:
2060 last_poll: float = player.extra_data[ATTR_LAST_POLL]
2061 except KeyError:
2062 last_poll = 0.0
2063 if (self.mass.loop.time() - last_poll) < player.poll_interval:
2064 continue
2065 player.extra_data[ATTR_LAST_POLL] = self.mass.loop.time()
2066 try:
2067 await player.poll()
2068 except Exception as err:
2069 self.logger.warning(
2070 "Error while requesting latest state from player %s: %s",
2071 player.state.name,
2072 str(err),
2073 exc_info=err if self.logger.isEnabledFor(10) else None,
2074 )
2075 # Yield to event loop to prevent blocking
2076 await asyncio.sleep(0)
2077 await asyncio.sleep(1)
2078
2079 async def _handle_select_plugin_source(
2080 self, player: Player, plugin_prov: PluginProvider
2081 ) -> None:
2082 """Handle playback/select of given plugin source on player."""
2083 plugin_source = plugin_prov.get_source()
2084 if plugin_source.in_use_by and plugin_source.in_use_by != player.player_id:
2085 self.logger.debug(
2086 "Plugin source %s is already in use by player %s, stopping playback there first.",
2087 plugin_source.name,
2088 plugin_source.in_use_by,
2089 )
2090 with suppress(PlayerCommandFailed):
2091 await self.cmd_stop(plugin_source.in_use_by)
2092 stream_url = await self.mass.streams.get_plugin_source_url(plugin_source, player.player_id)
2093 plugin_source.in_use_by = player.player_id
2094 # Call on_select callback if available
2095 if plugin_source.on_select:
2096 await plugin_source.on_select()
2097 await self.play_media(
2098 player_id=player.player_id,
2099 media=PlayerMedia(
2100 uri=stream_url,
2101 media_type=MediaType.PLUGIN_SOURCE,
2102 title=plugin_source.name,
2103 custom_data={
2104 "provider": plugin_prov.instance_id,
2105 "source_id": plugin_source.id,
2106 "player_id": player.player_id,
2107 "audio_format": plugin_source.audio_format,
2108 },
2109 ),
2110 )
2111 # trigger player update to ensure the source is set
2112 self.trigger_player_update(player.player_id)
2113
2114 def _handle_group_dsp_change(
2115 self, player: Player, prev_group_members: list[str], new_group_members: list[str]
2116 ) -> None:
2117 """Handle DSP reload when group membership changes."""
2118 prev_child_count = len(prev_group_members)
2119 new_child_count = len(new_group_members)
2120 is_player_group = player.state.type == PlayerType.GROUP
2121
2122 # handle special case for PlayerGroups: since there are no leaders,
2123 # DSP still always work with a single player in the group.
2124 multi_device_dsp_threshold = 1 if is_player_group else 0
2125
2126 prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold
2127 new_is_multiple_devices = new_child_count > multi_device_dsp_threshold
2128
2129 if prev_is_multiple_devices == new_is_multiple_devices:
2130 return # no change in multi-device status
2131
2132 supports_multi_device_dsp = (
2133 PlayerFeature.MULTI_DEVICE_DSP in player.state.supported_features
2134 )
2135
2136 dsp_enabled: bool
2137 if player.state.type == PlayerType.GROUP:
2138 # Since player groups do not have leaders, we will use the only child
2139 # that was in the group before and after the change
2140 if prev_is_multiple_devices:
2141 if childs := new_group_members:
2142 # We shrank the group from multiple players to a single player
2143 # So the now only child will control the DSP
2144 dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
2145 else:
2146 dsp_enabled = False
2147 elif childs := prev_group_members:
2148 # We grew the group from a single player to multiple players,
2149 # let's see if the previous single player had DSP enabled
2150 dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
2151 else:
2152 dsp_enabled = False
2153 else:
2154 dsp_enabled = self.mass.config.get_player_dsp_config(player.player_id).enabled
2155
2156 if dsp_enabled and not supports_multi_device_dsp:
2157 # We now know that the group configuration has changed so:
2158 # - multi-device DSP is not supported
2159 # - we switched from a group with multiple players to a single player
2160 # (or vice versa)
2161 # - the leader has DSP enabled
2162 self.mass.create_task(self.mass.players.on_player_dsp_change(player.player_id))
2163
2164 def _handle_external_source_takeover(
2165 self, player: Player, prev_source: str | None, new_source: str | None
2166 ) -> None:
2167 """
2168 Handle when an external source takes over playback on a player.
2169
2170 When a player has an active grouped output protocol (e.g., AirPlay group) and
2171 an external source (e.g., Spotify Connect, TV input) takes over playback,
2172 we need to clear the active output protocol and ungroup the protocol players.
2173
2174 This prevents the situation where the player appears grouped via protocol
2175 but is actually playing from a different source.
2176
2177 :param player: The player whose active_source changed.
2178 :param prev_source: The previous active_source value.
2179 :param new_source: The new active_source value.
2180 """
2181 # Only relevant for non-protocol players
2182 if player.type == PlayerType.PROTOCOL:
2183 return
2184
2185 # Not a takeover if the player is not actively playing
2186 if player.playback_state != PlaybackState.PLAYING:
2187 return
2188
2189 # Only relevant if we have an active output protocol (not native)
2190 if not player.active_output_protocol or player.active_output_protocol == "native":
2191 return
2192
2193 # Check if new source is external (not MA-managed)
2194 if self._is_ma_managed_source(player, new_source):
2195 return
2196
2197 # Get the active protocol player
2198 protocol_player = self.get_player(player.active_output_protocol)
2199 if not protocol_player:
2200 return
2201
2202 # If the source matches the active protocol's domain, it's expected - not a takeover
2203 # e.g., source "airplay" when using AirPlay protocol is normal
2204 if new_source and new_source.lower() == protocol_player.provider.domain.lower():
2205 return
2206
2207 # Confirmed external source takeover
2208 self.logger.info(
2209 "External source '%s' took over on %s while playing via protocol %s - "
2210 "clearing active output protocol and ungrouping",
2211 new_source,
2212 player.display_name,
2213 protocol_player.provider.domain,
2214 )
2215
2216 # Clear active output protocol
2217 player.set_active_output_protocol(None)
2218
2219 # Ungroup the protocol player (async task)
2220 self.mass.create_task(protocol_player.ungroup())
2221
2222 def _is_ma_managed_source(self, player: Player, source: str | None) -> bool:
2223 """
2224 Check if a source is managed by Music Assistant.
2225
2226 MA-managed sources include:
2227 - None (=autodetect, no source explicitly set by player)
2228 - The player's own ID (MA queue)
2229 - Any active queue ID
2230 - Any plugin source ID
2231
2232 :param player: The player to check.
2233 :param source: The source ID to check.
2234 :return: True if the source is MA-managed, False if external.
2235 """
2236 if source is None:
2237 return True
2238
2239 # Player's own ID means MA queue is active
2240 if source == player.player_id:
2241 return True
2242
2243 # Check if it's a known queue ID
2244 if self.mass.player_queues.get(source):
2245 return True
2246
2247 # Check if it's a plugin source
2248 return any(plugin_source.id == source for plugin_source in self.get_plugin_sources())
2249
2250 def _schedule_update_all_players(self, delay: float = 2.0) -> None:
2251 """
2252 Schedule a debounced update of all players' state.
2253
2254 Used when a new player is registered to ensure all existing players
2255 update their dynamic properties (like can_group_with) that may have changed.
2256
2257 :param delay: Delay in seconds before triggering updates (default 2.0).
2258 """
2259 if self.mass.closing:
2260 return
2261
2262 async def _update_all_players() -> None:
2263 if self.mass.closing:
2264 return
2265
2266 for player in self.all_players(
2267 return_unavailable=True,
2268 return_disabled=False,
2269 return_protocol_players=True,
2270 ):
2271 # Use call_soon to schedule updates without blocking
2272 # This spreads the updates across event loop iterations
2273 self.mass.loop.call_soon(player.update_state)
2274
2275 # Use mass.call_later with task_id for automatic debouncing
2276 # Each call resets the timer, so rapid registrations only trigger one update
2277 task_id = "update_all_players_on_registration"
2278 self.mass.call_later(delay, _update_all_players, task_id=task_id)
2279
2280 async def _auto_ungroup_if_synced(self, player: Player, log_context: str) -> None:
2281 """
2282 Automatically ungroup a player if it's synced to another player.
2283
2284 :param player: The player to check and potentially ungroup.
2285 :param log_context: Additional context for the log message (e.g., target player name).
2286 """
2287 if not player.state.synced_to:
2288 return
2289 self.logger.info(
2290 "Player %s is already synced to %s, ungrouping it first before %s",
2291 player.name,
2292 player.state.synced_to,
2293 log_context,
2294 )
2295 await self.cmd_set_members(player.state.synced_to, player_ids_to_remove=[player.player_id])
2296 await asyncio.sleep(2)
2297
2298 async def _handle_set_members(
2299 self,
2300 parent_player: Player,
2301 target_player: str,
2302 player_ids_to_add: list[str] | None,
2303 player_ids_to_remove: list[str] | None,
2304 ) -> None:
2305 """
2306 Handle the actual set_members logic.
2307
2308 Skips the permission checks (internal use only).
2309
2310 :param parent_player: The parent player to add/remove members to/from.
2311 :param target_player: player_id of the syncgroup leader or group player.
2312 :param player_ids_to_add: List of player_id's to add to the target player.
2313 :param player_ids_to_remove: List of player_id's to remove from the target player.
2314 """
2315 # handle dissolve sync group if the target player is currently
2316 # a sync leader and is being removed from itself
2317 should_stop = False
2318 if player_ids_to_remove and target_player in player_ids_to_remove:
2319 self.logger.info(
2320 "Dissolving sync group of player %s as it is being removed from itself",
2321 parent_player.name,
2322 )
2323 player_ids_to_add = None
2324 player_ids_to_remove = [
2325 x for x in parent_player.state.group_members if x != target_player
2326 ]
2327 should_stop = True
2328 # filter all player ids on compatibility and availability
2329 final_player_ids_to_add: list[str] = []
2330 for child_player_id in player_ids_to_add or []:
2331 if child_player_id == target_player:
2332 continue
2333 if child_player_id in final_player_ids_to_add:
2334 continue
2335 if (
2336 not (child_player := self.get_player(child_player_id))
2337 or not child_player.state.available
2338 ):
2339 self.logger.warning("Player %s is not available", child_player_id)
2340 continue
2341
2342 # check if player can be synced/grouped with the target player
2343 # state.can_group_with already handles all expansion and translation
2344 if child_player_id not in parent_player.state.can_group_with:
2345 self.logger.warning(
2346 "Player %s can not be grouped with %s",
2347 child_player.name,
2348 parent_player.name,
2349 )
2350 continue
2351
2352 if (
2353 child_player.state.synced_to
2354 and child_player.state.synced_to == target_player
2355 and child_player_id in parent_player.state.group_members
2356 ):
2357 continue # already synced to this target
2358
2359 # handle edge case: child player is synced to a different player
2360 # automatically ungroup it first and wait for state to propagate
2361 if child_player.state.synced_to and child_player.state.synced_to != target_player:
2362 await self._auto_ungroup_if_synced(child_player, f"joining {parent_player.name}")
2363
2364 # power on the player if needed
2365 if (
2366 not child_player.state.powered
2367 and child_player.state.power_control != PLAYER_CONTROL_NONE
2368 ):
2369 await self._handle_cmd_power(child_player.player_id, True)
2370 # if we reach here, all checks passed
2371 final_player_ids_to_add.append(child_player_id)
2372
2373 # process player ids to remove and filter out invalid/unavailable players and edge cases
2374 final_player_ids_to_remove: list[str] = []
2375 if player_ids_to_remove:
2376 for child_player_id in player_ids_to_remove:
2377 if child_player_id not in parent_player.state.group_members:
2378 continue
2379 final_player_ids_to_remove.append(child_player_id)
2380
2381 # Forward command to the appropriate player after all (base) sanity checks
2382 # GROUP players (sync_group, universal_group) manage their own members internally
2383 # and don't need protocol translation - call their set_members directly
2384 if parent_player.type == PlayerType.GROUP:
2385 await parent_player.set_members(
2386 player_ids_to_add=final_player_ids_to_add,
2387 player_ids_to_remove=final_player_ids_to_remove,
2388 )
2389 return
2390 # For regular players, handle protocol selection and translation
2391 # Store playback state before changing members to detect protocol changes
2392 was_playing = parent_player.playback_state in (
2393 PlaybackState.PLAYING,
2394 PlaybackState.PAUSED,
2395 )
2396 previous_protocol = parent_player.active_output_protocol if was_playing else None
2397
2398 await self._handle_set_members_with_protocols(
2399 parent_player, final_player_ids_to_add, final_player_ids_to_remove
2400 )
2401
2402 if should_stop:
2403 # Stop playback on the player if it is being removed from itself
2404 await self._handle_cmd_stop(parent_player.player_id)
2405 return
2406
2407 # Check if protocol changed due to member change and restart playback if needed
2408 if not should_stop and was_playing:
2409 # Determine which protocol would be used now with new members
2410 _new_target_player, new_protocol = self._select_best_output_protocol(parent_player)
2411 new_protocol_id = new_protocol.output_protocol_id if new_protocol else "native"
2412 previous_protocol_id = previous_protocol or "native"
2413
2414 # If protocol changed, restart playback
2415 if new_protocol_id != previous_protocol_id:
2416 self.logger.info(
2417 "Protocol changed from %s to %s due to member change, restarting playback",
2418 previous_protocol_id,
2419 new_protocol_id,
2420 )
2421 # Restart playback on the new protocol using resume
2422 await self.cmd_resume(
2423 parent_player.player_id,
2424 parent_player.state.active_source,
2425 parent_player.state.current_media,
2426 )
2427
2428 async def _handle_set_members_with_protocols(
2429 self,
2430 parent_player: Player,
2431 player_ids_to_add: list[str],
2432 player_ids_to_remove: list[str],
2433 ) -> None:
2434 """
2435 Handle set_members considering protocol and native members.
2436
2437 Translates visible player IDs to protocol player IDs when appropriate,
2438 and forwards to the correct player's set_members.
2439
2440 :param parent_player: The parent player to add/remove members to/from.
2441 :param player_ids_to_add: List of visible player IDs to add as members.
2442 :param player_ids_to_remove: List of visible player IDs to remove from members.
2443 """
2444 # Get parent's active protocol domain and player if available
2445 parent_protocol_domain = None
2446 parent_protocol_player = None
2447 if (
2448 parent_player.active_output_protocol
2449 and parent_player.active_output_protocol != "native"
2450 ):
2451 parent_protocol_player = self.get_player(parent_player.active_output_protocol)
2452 if parent_protocol_player:
2453 parent_protocol_domain = parent_protocol_player.provider.domain
2454
2455 self.logger.debug(
2456 "set_members on %s: active_protocol=%s, adding=%s, removing=%s",
2457 parent_player.state.name,
2458 parent_protocol_domain or "none",
2459 player_ids_to_add,
2460 player_ids_to_remove,
2461 )
2462
2463 # Translate members to add
2464 (
2465 protocol_members_to_add,
2466 native_members_to_add,
2467 parent_protocol_player,
2468 parent_protocol_domain,
2469 ) = self._translate_members_for_protocols(
2470 parent_player, player_ids_to_add, parent_protocol_player, parent_protocol_domain
2471 )
2472
2473 self.logger.debug(
2474 "Translated members: protocol=%s (domain=%s), native=%s",
2475 protocol_members_to_add,
2476 parent_protocol_domain,
2477 native_members_to_add,
2478 )
2479
2480 # Translate members to remove
2481 protocol_members_to_remove, native_members_to_remove = (
2482 self._translate_members_to_remove_for_protocols(
2483 parent_player, player_ids_to_remove, parent_protocol_player, parent_protocol_domain
2484 )
2485 )
2486
2487 # Forward protocol members to protocol player's set_members
2488 if (protocol_members_to_add or protocol_members_to_remove) and parent_protocol_player:
2489 await self._forward_protocol_set_members(
2490 parent_player,
2491 parent_protocol_player,
2492 protocol_members_to_add,
2493 protocol_members_to_remove,
2494 )
2495
2496 # Forward native members to parent player's set_members
2497 if native_members_to_add or native_members_to_remove:
2498 filtered_native_add = self._filter_native_members(native_members_to_add, parent_player)
2499 # For removal, allow protocol players if they're actually in the parent's group_members
2500 # This handles native protocol players (e.g., native AirPlay) where group_members
2501 # contains protocol player IDs
2502 filtered_native_remove = [
2503 pid
2504 for pid in native_members_to_remove
2505 if (p := self.get_player(pid))
2506 and (p.type != PlayerType.PROTOCOL or pid in parent_player.group_members)
2507 ]
2508 self.logger.debug(
2509 "Native grouping on %s: filtered_add=%s, filtered_remove=%s",
2510 parent_player.state.name,
2511 filtered_native_add,
2512 filtered_native_remove,
2513 )
2514 if filtered_native_add or filtered_native_remove:
2515 self.logger.info(
2516 "Calling set_members on native player %s with add=%s, remove=%s",
2517 parent_player.state.name,
2518 filtered_native_add,
2519 filtered_native_remove,
2520 )
2521 await parent_player.set_members(
2522 player_ids_to_add=filtered_native_add or None,
2523 player_ids_to_remove=filtered_native_remove or None,
2524 )
2525
2526 # Private command handlers (no permission checks)
2527
2528 async def _handle_cmd_resume(
2529 self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
2530 ) -> None:
2531 """
2532 Handle resume playback command.
2533
2534 Skips the permission checks (internal use only).
2535 """
2536 player = self._get_player_with_redirect(player_id)
2537 source = source or player.state.active_source
2538 media = media or player.state.current_media
2539 # power on the player if needed
2540 if not player.state.powered and player.state.power_control != PLAYER_CONTROL_NONE:
2541 await self._handle_cmd_power(player.player_id, True)
2542 # Redirect to queue controller if it is active
2543 if active_queue := self.mass.player_queues.get(source or player_id):
2544 await self.mass.player_queues.resume(active_queue.queue_id)
2545 return
2546 # try to handle command on player directly
2547 # TODO: check if player has an active source with native resume support
2548 active_source = next((x for x in player.state.source_list if x.id == source), None)
2549 if (
2550 player.state.playback_state in (PlaybackState.IDLE, PlaybackState.PAUSED)
2551 and active_source
2552 and active_source.can_play_pause
2553 ):
2554 # player has some other source active and native resume support
2555 await player.play()
2556 return
2557 if active_source and not active_source.passive:
2558 await self.select_source(player_id, active_source.id)
2559 return
2560 if media:
2561 # try to re-play the current media item
2562 await player.play_media(media)
2563 return
2564 # fallback: just send play command - which will fail if nothing can be played
2565 await player.play()
2566
2567 async def _handle_cmd_power(self, player_id: str, powered: bool) -> None:
2568 """
2569 Handle player power on/off command.
2570
2571 Skips the permission checks (internal use only).
2572 """
2573 player = self.get_player(player_id, True)
2574 assert player is not None # for type checking
2575 player_state = player.state
2576
2577 if player_state.powered == powered:
2578 self.logger.debug(
2579 "Ignoring power %s command for player %s: already in state %s",
2580 "ON" if powered else "OFF",
2581 player_state.name,
2582 "ON" if player_state.powered else "OFF",
2583 )
2584 return # nothing to do
2585
2586 # ungroup player at power off
2587 player_was_synced = bool(player.state.synced_to or player.group_members)
2588 if player.type == PlayerType.PLAYER and not powered:
2589 # ungroup player if it is synced (or is a sync leader itself)
2590 # NOTE: ungroup will be ignored if the player is not grouped or synced
2591 await self.cmd_ungroup(player_id)
2592
2593 # always stop player at power off
2594 if (
2595 not powered
2596 and not player_was_synced
2597 and player_state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
2598 ):
2599 await self.cmd_stop(player_id)
2600 # short sleep: allow the stop command to process and prevent race conditions
2601 await asyncio.sleep(0.2)
2602
2603 # power off all synced childs when player is a sync leader
2604 elif not powered and player_state.type == PlayerType.PLAYER and player_state.group_members:
2605 async with TaskManager(self.mass) as tg:
2606 for member in self.iter_group_members(player, True):
2607 if member.power_control == PLAYER_CONTROL_NONE:
2608 continue
2609 tg.create_task(self._handle_cmd_power(member.player_id, False))
2610
2611 # handle actual power command
2612 if player_state.power_control == PLAYER_CONTROL_NONE:
2613 self.logger.debug(
2614 "Player %s does not support power control, ignoring power command",
2615 player_state.name,
2616 )
2617 return
2618 if player_state.power_control == PLAYER_CONTROL_NATIVE:
2619 # player supports power command natively: forward to player provider
2620 await player.power(powered)
2621 elif player_state.power_control == PLAYER_CONTROL_FAKE:
2622 # user wants to use fake power control - so we (optimistically) update the state
2623 # and store the state in the cache
2624 player.extra_data[ATTR_FAKE_POWER] = powered
2625 player.update_state() # trigger update of the player state
2626 await self.mass.cache.set(
2627 key=player_id,
2628 data=powered,
2629 provider=self.domain,
2630 category=CACHE_CATEGORY_PLAYER_POWER,
2631 )
2632 else:
2633 # handle external player control
2634 player_control = self._controls.get(player.state.power_control)
2635 control_name = player_control.name if player_control else player.state.power_control
2636 self.logger.debug("Redirecting power command to PlayerControl %s", control_name)
2637 if not player_control or not player_control.supports_power:
2638 raise UnsupportedFeaturedException(
2639 f"Player control {control_name} is not available"
2640 )
2641 if powered:
2642 assert player_control.power_on is not None # for type checking
2643 await player_control.power_on()
2644 else:
2645 assert player_control.power_off is not None # for type checking
2646 await player_control.power_off()
2647
2648 # always trigger a state update to update the UI
2649 player.update_state()
2650
2651 # handle 'auto play on power on' feature
2652 if (
2653 not player_state.active_group
2654 and not player_state.synced_to
2655 and powered
2656 and player.config.get_value(CONF_AUTO_PLAY)
2657 and player_state.active_source in (None, player_id)
2658 and not player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS)
2659 ):
2660 await self.mass.player_queues.resume(player_id)
2661
2662 async def _handle_cmd_volume_set(self, player_id: str, volume_level: int) -> None:
2663 """
2664 Handle Player volume set command.
2665
2666 Skips the permission checks (internal use only).
2667 """
2668 player = self.get_player(player_id, True)
2669 assert player is not None # for type checker
2670 if player.type == PlayerType.GROUP:
2671 # redirect to special group volume control
2672 await self.cmd_group_volume(player_id, volume_level)
2673 return
2674
2675 # Check if player has mute lock (set when individually muted in a group)
2676 # If locked, don't auto-unmute when volume changes
2677 has_mute_lock = player.extra_data.get(ATTR_MUTE_LOCK, False)
2678 if (
2679 not has_mute_lock
2680 # use player.state here to get accumulated mute control from any linked protocol players
2681 and player.state.mute_control not in (PLAYER_CONTROL_NONE, PLAYER_CONTROL_FAKE)
2682 and player.state.volume_muted
2683 ):
2684 # if player is muted and not locked, we unmute it first
2685 # skip this for fake mute since it uses volume to simulate mute
2686 self.logger.debug(
2687 "Unmuting player %s before setting volume",
2688 player.state.name,
2689 )
2690 await self.cmd_volume_mute(player_id, False)
2691
2692 # Check if a plugin source is active with a volume callback
2693 if plugin_source := self._get_active_plugin_source(player):
2694 if plugin_source.on_volume:
2695 await plugin_source.on_volume(volume_level)
2696 # Handle native volume control support
2697 if player.volume_control == PLAYER_CONTROL_NATIVE:
2698 # player supports volume command natively: forward to player
2699 await player.volume_set(volume_level)
2700 return
2701 # Handle fake volume control support
2702 if player.volume_control == PLAYER_CONTROL_FAKE:
2703 # user wants to use fake volume control - so we (optimistically) update the state
2704 # and store the state in the cache
2705 player.extra_data[ATTR_FAKE_VOLUME] = volume_level
2706 # trigger update
2707 player.update_state()
2708 return
2709 # player has no volume support at all
2710 if player.volume_control == PLAYER_CONTROL_NONE:
2711 raise UnsupportedFeaturedException(
2712 f"Player {player.state.name} does not support volume control"
2713 )
2714 # handle external player control
2715 if player_control := self._controls.get(player.state.volume_control):
2716 control_name = player_control.name if player_control else player.state.volume_control
2717 self.logger.debug("Redirecting volume command to PlayerControl %s", control_name)
2718 if not player_control or not player_control.supports_volume:
2719 raise UnsupportedFeaturedException(
2720 f"Player control {control_name} is not available"
2721 )
2722 assert player_control.volume_set is not None
2723 await player_control.volume_set(volume_level)
2724 return
2725 if protocol_player := self.get_player(player.state.volume_control):
2726 # redirect to protocol player volume control
2727 self.logger.debug(
2728 "Redirecting volume command to protocol player %s",
2729 protocol_player.provider.manifest.name,
2730 )
2731 await self._handle_cmd_volume_set(protocol_player.player_id, volume_level)
2732 return
2733
2734 async def _handle_play_media(self, player_id: str, media: PlayerMedia) -> None:
2735 """
2736 Handle play media command without group redirect.
2737
2738 Skips permission checks and all redirect logic (internal use only).
2739
2740 :param player_id: player_id of the player to handle the command.
2741 :param media: The Media that needs to be played on the player.
2742 """
2743 player = self.get_player(player_id, raise_unavailable=True)
2744 assert player is not None
2745 # set active source if media has a source_id (e.g. plugin source or mass queue source)
2746 if media.source_id:
2747 player.set_active_mass_source(media.source_id)
2748
2749 # Select best output protocol for playback
2750 target_player, output_protocol = self._select_best_output_protocol(player)
2751
2752 if target_player.player_id != player.player_id:
2753 # Playing via linked protocol - update active output protocol
2754 # output_protocol is guaranteed to be non-None when target_player != player
2755 assert output_protocol is not None
2756 self.logger.debug(
2757 "Starting playback on %s via protocol %s (target=%s), group_members=%s",
2758 player.state.name,
2759 output_protocol.output_protocol_id,
2760 target_player.display_name,
2761 target_player.state.group_members,
2762 )
2763 player.set_active_output_protocol(output_protocol.output_protocol_id)
2764 # if the (protocol)player has power control and is currently powered off,
2765 # we need to power it on before playback
2766 if (
2767 target_player.state.powered is False
2768 and target_player.power_control != PLAYER_CONTROL_NONE
2769 ):
2770 await self._handle_cmd_power(target_player.player_id, True)
2771 # forward play media command to protocol player
2772 await target_player.play_media(media)
2773 # notify the native player that protocol playback started
2774 await player.on_protocol_playback(output_protocol=output_protocol)
2775 else:
2776 # Native playback
2777 self.logger.debug(
2778 "Starting playback on %s via native, group_members=%s",
2779 player.state.name,
2780 player.state.group_members,
2781 )
2782 player.set_active_output_protocol("native")
2783 await player.play_media(media)
2784
2785 async def _handle_enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
2786 """
2787 Handle enqueue next media command without group redirect.
2788
2789 Skips permission checks and all redirect logic (internal use only).
2790
2791 :param player_id: player_id of the player to handle the command.
2792 :param media: The Media that needs to be enqueued on the player.
2793 """
2794 player = self.get_player(player_id, raise_unavailable=True)
2795 assert player is not None
2796 if target_player := self._get_control_target(
2797 player,
2798 required_feature=PlayerFeature.ENQUEUE,
2799 require_active=True,
2800 ):
2801 self.logger.debug(
2802 "Redirecting enqueue command to protocol player %s",
2803 target_player.provider.manifest.name,
2804 )
2805 await target_player.enqueue_next_media(media)
2806 return
2807
2808 if PlayerFeature.ENQUEUE not in player.state.supported_features:
2809 raise UnsupportedFeaturedException(
2810 f"Player {player.state.name} does not support enqueueing"
2811 )
2812 await player.enqueue_next_media(media)
2813
2814 async def _handle_select_source(self, player_id: str, source: str | None) -> None:
2815 """
2816 Handle select source command without group redirect.
2817
2818 Skips permission checks and all redirect logic (internal use only).
2819
2820 :param player_id: player_id of the player to handle the command.
2821 :param source: The ID of the source that needs to be activated/selected.
2822 """
2823 if source is None:
2824 source = player_id # default to MA queue source
2825 player = self.get_player(player_id, True)
2826 assert player is not None
2827 # check if player is already playing and source is different
2828 # in that case we need to stop the player first
2829 prev_source = player.state.active_source
2830 if prev_source and source != prev_source:
2831 with suppress(PlayerCommandFailed, RuntimeError):
2832 # just try to stop (regardless of state)
2833 await self._handle_cmd_stop(player_id)
2834 await asyncio.sleep(2) # small delay to allow stop to process
2835 # check if source is a pluginsource
2836 # in that case the source id is the instance_id of the plugin provider
2837 if plugin_prov := self.mass.get_provider(source):
2838 player.set_active_mass_source(source)
2839 await self._handle_select_plugin_source(player, cast("PluginProvider", plugin_prov))
2840 return
2841 # check if source is a mass queue
2842 # this can be used to restore the queue after a source switch
2843 if self.mass.player_queues.get(source):
2844 player.set_active_mass_source(source)
2845 return
2846 # basic check if player supports source selection
2847 if PlayerFeature.SELECT_SOURCE not in player.state.supported_features:
2848 raise UnsupportedFeaturedException(
2849 f"Player {player.state.name} does not support source selection"
2850 )
2851 # basic check if source is valid for player
2852 if not any(x for x in player.state.source_list if x.id == source):
2853 raise PlayerCommandFailed(
2854 f"{source} is an invalid source for player {player.state.name}"
2855 )
2856 # forward to player
2857 await player.select_source(source)
2858
2859 async def _handle_cmd_stop(self, player_id: str) -> None:
2860 """
2861 Handle stop command without any redirects.
2862
2863 Skips permission checks and all redirect logic (internal use only).
2864
2865 :param player_id: player_id of the player to handle the command.
2866 """
2867 player = self.get_player(player_id, raise_unavailable=True)
2868 assert player is not None
2869 player.mark_stop_called()
2870 # Delegate to active protocol player if one is active
2871 target_player = player
2872 if (
2873 player.active_output_protocol
2874 and player.active_output_protocol != "native"
2875 and (protocol_player := self.get_player(player.active_output_protocol))
2876 ):
2877 target_player = protocol_player
2878 if PlayerFeature.POWER in target_player.supported_features:
2879 # if protocol player supports/requires power,
2880 # we power it off instead of just stopping (which also stops playback)
2881 await self._handle_cmd_power(target_player.player_id, False)
2882 return
2883
2884 # handle command on player(protocol) directly
2885 await target_player.stop()
2886
2887 async def _handle_cmd_play(self, player_id: str) -> None:
2888 """
2889 Handle play command without group redirect.
2890
2891 Skips permission checks and all redirect logic (internal use only).
2892
2893 :param player_id: player_id of the player to handle the command.
2894 """
2895 player = self.get_player(player_id, raise_unavailable=True)
2896 assert player is not None
2897 if player.state.playback_state == PlaybackState.PLAYING:
2898 self.logger.info(
2899 "Ignore PLAY request to player %s: player is already playing", player.state.name
2900 )
2901 return
2902 # Check if a plugin source is active with a play callback
2903 if plugin_source := self._get_active_plugin_source(player):
2904 if plugin_source.can_play_pause and plugin_source.on_play:
2905 await plugin_source.on_play()
2906 return
2907 # handle unpause (=play if player is paused)
2908 if player.state.playback_state == PlaybackState.PAUSED:
2909 active_source = next(
2910 (x for x in player.state.source_list if x.id == player.state.active_source), None
2911 )
2912 # raise if active source does not support play/pause
2913 if active_source and not active_source.can_play_pause:
2914 msg = (
2915 f"The active source ({active_source.name}) on player "
2916 f"{player.state.name} does not support play/pause"
2917 )
2918 raise PlayerCommandFailed(msg)
2919 # Delegate to active protocol player if one is active
2920 if target_player := self._get_control_target(
2921 player, PlayerFeature.PAUSE, require_active=True
2922 ):
2923 await target_player.play()
2924 return
2925
2926 # player is not paused: try to resume the player
2927 # Note: We handle resume inline here without calling _handle_cmd_resume
2928 active_source = next(
2929 (x for x in player.state.source_list if x.id == player.state.active_source), None
2930 )
2931 media = player.state.current_media
2932 # power on the player if needed
2933 if not player.state.powered and player.state.power_control != PLAYER_CONTROL_NONE:
2934 await self._handle_cmd_power(player.player_id, True)
2935 if active_source and not active_source.passive:
2936 await self._handle_select_source(player_id, active_source.id)
2937 return
2938 if media:
2939 # try to re-play the current media item
2940 await player.play_media(media)
2941 return
2942 # fallback: just send play command - which will fail if nothing can be played
2943 await player.play()
2944
2945 async def _handle_cmd_pause(self, player_id: str) -> None:
2946 """
2947 Handle pause command without any redirects.
2948
2949 Skips permission checks and all redirect logic (internal use only).
2950
2951 :param player_id: player_id of the player to handle the command.
2952 """
2953 player = self.get_player(player_id, raise_unavailable=True)
2954 assert player is not None
2955 # Check if a plugin source is active with a pause callback
2956 if plugin_source := self._get_active_plugin_source(player):
2957 if plugin_source.can_play_pause and plugin_source.on_pause:
2958 await plugin_source.on_pause()
2959 return
2960 # handle command on player/source directly
2961 active_source = next(
2962 (x for x in player.state.source_list if x.id == player.state.active_source), None
2963 )
2964 if active_source and not active_source.can_play_pause:
2965 # raise if active source does not support play/pause
2966 msg = (
2967 f"The active source ({active_source.name}) on player "
2968 f"{player.state.name} does not support play/pause"
2969 )
2970 raise PlayerCommandFailed(msg)
2971 # Delegate to active protocol player if one is active
2972 if not (
2973 target_player := self._get_control_target(
2974 player, PlayerFeature.PAUSE, require_active=True
2975 )
2976 ):
2977 # if player(protocol) does not support pause, we need to send stop
2978 self.logger.debug(
2979 "Player/protocol %s does not support pause, using STOP instead",
2980 player.state.name,
2981 )
2982 await self._handle_cmd_stop(player.player_id)
2983 return
2984 # handle command on player(protocol) directly
2985 await target_player.pause()
2986
2987 def __iter__(self) -> Iterator[Player]:
2988 """Iterate over all players."""
2989 return iter(self._players.values())
2990