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