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