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