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