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