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