Fix sending announcements to playergroups/synced players (#1199)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 2 Apr 2024 17:36:35 +0000 (19:36 +0200)
committerGitHub <noreply@github.com>
Tue, 2 Apr 2024 17:36:35 +0000 (19:36 +0200)
Fix sending announcements to playergroups

music_assistant/common/models/player.py
music_assistant/server/controllers/players.py
music_assistant/server/controllers/streams.py
music_assistant/server/models/player_provider.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/snapcast/__init__.py
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/ugp/__init__.py

index 33bee344a19b2277fa40c35f4c1ab0182cdf1459..eb906bf4f78f415ff938226e0ea90076fd762fcd 100644 (file)
@@ -48,12 +48,14 @@ class Player(DataClassDictMixin):
     #   and this may include the player's own id.
     group_childs: set[str] = field(default_factory=set)
 
-    # active_source: return player_id of the active queue for this player
-    # if the player is grouped and a group is active, this will be set to the group's player_id
-    # otherwise it will be set to the own player_id
-    # can also be an actual different source if the player supports that
+    # active_source: return active source for this player
+    # can be set to a MA queue id or some player specific source
     active_source: str | None = None
 
+    # active_source: return player_id of the active group for this player (if any)
+    # if the player is grouped and a group is active, this will be set to the group's player_id
+    active_group: str | None = None
+
     # current_item_id: return item_id/uri of the current active/loaded item on the player
     # this may be a MA queue_item_id, url, uri or some provider specific string
     current_item_id: str | None = None
index 52a09eea8c3292f1c21d312c71e81e81a1812bb8..3f5cb23e65a792734d679652e42c4cca51f2a037 100644 (file)
@@ -252,7 +252,8 @@ class PlayerController(CoreController):
         if player_id not in self._players:
             return
         player = self._players[player_id]
-        # calculate active_source (if needed)
+        # calculate active group and active source
+        player.active_group = self._get_active_player_group(player)
         player.active_source = self._get_active_source(player)
         # calculate group volume
         player.group_volume = self._get_group_volume_level(player)
@@ -461,12 +462,10 @@ class PlayerController(CoreController):
         self.update(player_id)
         # handle actions when a syncgroup child turns on
         if active_group_player := self._get_active_player_group(player):
-            if active_group_player.player_id.startswith(SYNCGROUP_PREFIX):
-                self._on_syncgroup_child_power(
-                    active_group_player.player_id, player.player_id, powered
-                )
-            elif player_prov := self.get_player_provider(active_group_player.player_id):
-                player_prov.on_child_power(active_group_player.player_id, player.player_id, powered)
+            if active_group_player.startswith(SYNCGROUP_PREFIX):
+                self._on_syncgroup_child_power(active_group_player, player.player_id, powered)
+            elif player_prov := self.get_player_provider(active_group_player):
+                player_prov.on_child_power(active_group_player, player.player_id, powered)
         # handle 'auto play on power on'  feature
         elif (
             powered
@@ -627,68 +626,66 @@ class PlayerController(CoreController):
         try:
             # mark announcement_in_progress on player
             player.announcement_in_progress = True
-            self.logger.info(
-                "Playback announcement to player %s (with pre-announce: %s): %s",
-                player.display_name,
-                use_pre_announce,
-                url,
-            )
-            # work out preferences for announcements
+            # determine if the player(group) has native announcements support
+            native_announce_support = PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features
+            if not native_announce_support and player.synced_to:
+                # redirect to sync master if player is group child
+                self.logger.warning(
+                    "Detected announcement request to a player that is currently synced, "
+                    "this will be redirected to the entire syncgroup."
+                )
+                await self.play_announcement(player.synced_to, url, use_pre_announce, volume_level)
+                return
+            if not native_announce_support and player.active_group:
+                # redirect to group player if playergroup is active
+                self.logger.warning(
+                    "Detected announcement request to a player which has a group active, "
+                    "this will be redirected to the group."
+                )
+                await self.play_announcement(
+                    player.active_group, url, use_pre_announce, volume_level
+                )
+                return
+            # determine pre-announce from (group)player config
             if use_pre_announce is None and "tts" in url:
                 use_pre_announce = self.mass.config.get_raw_player_config_value(
                     player_id,
                     CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
                     CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value,
                 )
-            volume_strategy = self.mass.config.get_raw_player_config_value(
-                player_id,
-                CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
-                CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
-            )
-            volume_strategy = self.mass.config.get_raw_player_config_value(
-                player_id,
-                CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
-                CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
+            self.logger.info(
+                "Playback announcement to player %s (with pre-announce: %s): %s",
+                player.display_name,
+                use_pre_announce,
+                url,
             )
-            volume_strategy_volume = self.mass.config.get_raw_player_config_value(
-                player_id,
-                CONF_ENTRY_ANNOUNCE_VOLUME.key,
-                CONF_ENTRY_ANNOUNCE_VOLUME.default_value,
+            # create a queue item for the announcement so
+            # we can send a regular play-media call downstream
+            announcement = QueueItem(
+                queue_id=player.player_id,
+                queue_item_id=url,
+                name="Announcement",
+                duration=None,
+                streamdetails=StreamDetails(
+                    provider="url",
+                    item_id=url,
+                    audio_format=AudioFormat(
+                        content_type=ContentType.try_parse(url),
+                    ),
+                    stream_type=StreamType.HTTP,
+                    media_type=MediaType.ANNOUNCEMENT,
+                    path=url,
+                    target_loudness=-10,
+                    data={"url": url, "use_pre_announce": use_pre_announce},
+                ),
             )
-            if volume_level is None and volume_strategy == "absolute":
-                volume_level = volume_strategy_volume
-            elif volume_level is None and volume_strategy == "relative":
-                volume_level = player.volume_level + volume_strategy_volume
-            elif volume_level is None and volume_strategy == "percentual":
-                percentual = (player.volume_level / 100) * volume_strategy_volume
-                volume_level = player.volume_level + percentual
-            if volume_level is not None:
-                announce_volume_min = self.mass.config.get_raw_player_config_value(
-                    player_id,
-                    CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key,
-                    CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value,
-                )
-                announce_volume_max = self.mass.config.get_raw_player_config_value(
-                    player_id,
-                    CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key,
-                    CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value,
-                )
-                volume_level = max(announce_volume_min, volume_level)
-                volume_level = min(announce_volume_max, volume_level)
-
-            # check for native announce support
-            if PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features:
+            # handle native announce support
+            if native_announce_support:
                 if prov := self.mass.get_provider(player.provider):
-                    # use stream server to host announcement on local network
-                    # this ensures playback on all players, including ones that do not
-                    # like https hosts and it also offers the pre-announce 'bell'
-                    announcement_url = self.mass.streams.get_announcement_url(
-                        player.player_id, url, use_pre_announce=use_pre_announce
-                    )
-                    await prov.play_announcement(player_id, announcement_url, volume_level)
+                    await prov.play_announcement(player_id, announcement, volume_level)
                     return
             # use fallback/default implementation
-            await self._play_announcement(player, url, use_pre_announce, volume_level)
+            await self._play_announcement(player, announcement, volume_level)
         finally:
             player.announcement_in_progress = False
 
@@ -838,6 +835,43 @@ class PlayerController(CoreController):
         msg = f"Provider {player_prov.name} does not support creating groups"
         raise UnsupportedFeaturedException(msg)
 
+    def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None:
+        """Get the (player specific) volume for a announcement."""
+        volume_strategy = self.mass.config.get_raw_player_config_value(
+            player_id,
+            CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
+            CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
+        )
+        volume_strategy_volume = self.mass.config.get_raw_player_config_value(
+            player_id,
+            CONF_ENTRY_ANNOUNCE_VOLUME.key,
+            CONF_ENTRY_ANNOUNCE_VOLUME.default_value,
+        )
+        volume_level = volume_override
+        if volume_level is None and volume_strategy == "absolute":
+            volume_level = volume_strategy_volume
+        elif volume_level is None and volume_strategy == "relative":
+            player = self.get(player_id)
+            volume_level = player.volume_level + volume_strategy_volume
+        elif volume_level is None and volume_strategy == "percentual":
+            player = self.get(player_id)
+            percentual = (player.volume_level / 100) * volume_strategy_volume
+            volume_level = player.volume_level + percentual
+        if volume_level is not None:
+            announce_volume_min = self.mass.config.get_raw_player_config_value(
+                player_id,
+                CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key,
+                CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value,
+            )
+            volume_level = max(announce_volume_min, volume_level)
+            announce_volume_max = self.mass.config.get_raw_player_config_value(
+                player_id,
+                CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key,
+                CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value,
+            )
+            volume_level = min(announce_volume_max, volume_level)
+        return volume_level
+
     def _check_redirect(self, player_id: str) -> str:
         """Check if playback related command should be redirected."""
         player = self.get(player_id, True)
@@ -874,15 +908,15 @@ class PlayerController(CoreController):
             ):
                 yield _player
 
-    def _get_active_player_group(self, player: Player) -> Player | None:
+    def _get_active_player_group(self, player: Player) -> str | None:
         """Return the currently active groupplayer for the given player (if any)."""
         # prefer active source group
         for group_player in self._get_player_groups(player, available_only=True, powered_only=True):
             if player.active_source in (group_player.player_id, group_player.active_source):
-                return group_player
+                return group_player.player_id
         # fallback to just the first powered group
         for group_player in self._get_player_groups(player, available_only=True, powered_only=True):
-            return group_player
+            return group_player.player_id
         return None
 
     def _get_active_source(self, player: Player) -> str:
@@ -891,12 +925,9 @@ class PlayerController(CoreController):
         if player.synced_to and (parent_player := self.get(player.synced_to)):
             return parent_player.active_source
         # fallback to the first active group player
-        if player.powered:
-            for group_player in self._get_player_groups(
-                player, available_only=True, powered_only=True
-            ):
-                if group_player.state in (PlayerState.PLAYING, PlayerState.PAUSED):
-                    return group_player.active_source
+        if player.active_group:
+            group_player = self.get(player.active_group)
+            return self._get_active_source(group_player)
         # defaults to the player's own player id if not active source set
         return player.active_source or player.player_id
 
@@ -1145,9 +1176,8 @@ class PlayerController(CoreController):
     async def _play_announcement(
         self,
         player: Player,
-        url: str,
-        use_pre_announce: bool,
-        announcement_volume: int | None = None,
+        announcement: QueueItem,
+        volume_level: int | None = None,
     ) -> None:
         """Handle (default/fallback) implementation of the play announcement feature.
 
@@ -1163,36 +1193,7 @@ class PlayerController(CoreController):
         This default implementation will only be used if the player's
         provider has no native support for the PLAY_ANNOUNCEMENT feature.
         """
-        if player.synced_to:
-            # redirect to sync master if player is group child
-            self.mass.create_task(self.play_announcement(player.synced_to, url))
-            return
-        if active_group := self._get_active_player_group(player):
-            # redirect to group player if playergroup is active
-            self.mass.create_task(self.play_announcement(active_group.player_id, url))
-            return
-        # create a queue item for the announcement so
-        # we can send a regular play-media call downstream
-        queue_item = QueueItem(
-            queue_id=player.player_id,
-            queue_item_id=url,
-            name="Announcement",
-            duration=None,
-            streamdetails=StreamDetails(
-                provider="url",
-                item_id=url,
-                audio_format=AudioFormat(
-                    content_type=ContentType.try_parse(url),
-                ),
-                stream_type=StreamType.HTTP,
-                media_type=MediaType.ANNOUNCEMENT,
-                data={"url": url, "use_pre_announce": use_pre_announce},
-                path=url,
-                target_loudness=-10,
-            ),
-        )
         prev_power = player.powered
-        prev_volume = player.volume_level
         prev_state = player.state
         queue = self.mass.player_queues.get_active_queue(player.player_id)
         prev_queue_active = queue.active
@@ -1208,22 +1209,37 @@ class PlayerController(CoreController):
             # wait for the player to stop
             with suppress(TimeoutError):
                 await self.wait_for_state(player, PlayerState.IDLE, 10)
+        # a small amount of pause before the volume command
+        # prevents that the last piece of music is very loud
+        await asyncio.sleep(0.2)
         # adjust volume if needed
-        temp_volume = announcement_volume or player.volume_level
-        if temp_volume > prev_volume:
-            self.logger.debug(
-                "Announcement to player %s - setting temporary volume (%s)...",
-                player.display_name,
-                announcement_volume,
-            )
-            await self.cmd_volume_set(player.player_id, announcement_volume)
-            await asyncio.sleep(0.5)
-            # play the announcement
-            self.logger.debug(
-                "Announcement to player %s - playing the announcement on the player...",
-                player.display_name,
-            )
-        await self.play_media(player_id=player.player_id, queue_item=queue_item)
+        # in case of a (sync) group, we need to do this for all child players
+        prev_volumes: dict[str, int] = {}
+        async with asyncio.TaskGroup() as tg:
+            for volume_player_id in player.group_childs or (player.player_id,):
+                if not (volume_player := self.get(volume_player_id)):
+                    continue
+                if volume_player.active_source != player.active_source:
+                    continue
+                prev_volume = volume_player.volume_level
+                announcement_volume = self.get_announcement_volume(volume_player_id, volume_level)
+                temp_volume = announcement_volume or player.volume_level
+                if temp_volume != prev_volume:
+                    prev_volumes[volume_player_id] = prev_volume
+                    self.logger.debug(
+                        "Announcement to player %s - setting temporary volume (%s)...",
+                        volume_player.display_name,
+                        announcement_volume,
+                    )
+                    tg.create_task(
+                        self.cmd_volume_set(volume_player.player_id, announcement_volume)
+                    )
+        # play the announcement
+        self.logger.debug(
+            "Announcement to player %s - playing the announcement on the player...",
+            player.display_name,
+        )
+        await self.play_media(player_id=player.player_id, queue_item=announcement)
         # wait for the player to play
         with suppress(TimeoutError):
             await self.wait_for_state(player, PlayerState.PLAYING, 10)
@@ -1234,15 +1250,17 @@ class PlayerController(CoreController):
         # wait for the player to stop playing
         with suppress(TimeoutError):
             await self.wait_for_state(
-                player, PlayerState.IDLE, (queue_item.streamdetails.duration or 30) + 3
+                player, PlayerState.IDLE, (announcement.streamdetails.duration or 60) + 3
             )
         self.logger.debug(
             "Announcement to player %s - restore previous state...", player.display_name
         )
         # restore volume
-        if temp_volume != prev_volume:
-            await self.cmd_volume_set(player.player_id, prev_volume)
-            await asyncio.sleep(0.5)
+        async with asyncio.TaskGroup() as tg:
+            for volume_player_id, prev_volume in prev_volumes.items():
+                tg.create_task(self.cmd_volume_set(volume_player_id, prev_volume))
+
+        await asyncio.sleep(0.2)
         player.current_item_id = prev_item_id
         # either power off the player or resume playing
         if not prev_power:
index b674b23e50d846e6ed32717bd67d143a8f1df749..2cd0fa10bf5b5ba730b8fbfcde8d465b7c25e06a 100644 (file)
@@ -192,9 +192,11 @@ class MultiClientStreamJob:
             self.subscribed_players[player_id] = sub_queue = asyncio.Queue(2)
 
             if self._all_clients_connected.is_set():
-                # client subscribes while we're already started - we dont support that (for now?)
-                msg = f"Client {player_id} is joining while the stream is already started"
-                raise RuntimeError(msg)
+                # client subscribes while we're already started,
+                # that will most probably lead to a bad experience but support it anyways
+                self.logger.warning(
+                    "Client %s is joining while the stream is already started", player_id
+                )
             self.logger.debug("Subscribed client %s", player_id)
 
             if len(self.subscribed_players) == len(self.expected_players):
@@ -775,6 +777,9 @@ class StreamsController(CoreController):
     ) -> str:
         """Get the url for the special announcement stream."""
         self.announcements[player_id] = announcement_url
+        # use stream server to host announcement on local network
+        # this ensures playback on all players, including ones that do not
+        # like https hosts and it also offers the pre-announce 'bell'
         return f"{self.base_url}/announcement/{player_id}.{content_type.value}?pre_announce={use_pre_announce}"  # noqa: E501
 
     async def get_flow_stream(
index 2a4e46f991cf177a4ba1e39631fbe06ff98f002f..d7f3db8ae54a27e941b125361628c61fe4bf8b10 100644 (file)
@@ -22,7 +22,12 @@ from music_assistant.common.models.config_entries import (
     PlayerConfig,
 )
 from music_assistant.common.models.enums import ConfigEntryType
-from music_assistant.constants import CONF_GROUP_MEMBERS, CONF_GROUP_PLAYERS, SYNCGROUP_PREFIX
+from music_assistant.constants import (
+    CONF_GROUP_MEMBERS,
+    CONF_GROUP_PLAYERS,
+    SYNCGROUP_PREFIX,
+    UGP_PREFIX,
+)
 
 from .provider import Provider
 
@@ -49,14 +54,10 @@ class PlayerProvider(Provider):
             CONF_ENTRY_VOLUME_NORMALIZATION_TARGET,
             CONF_ENTRY_HIDE_PLAYER,
             CONF_ENTRY_TTS_PRE_ANNOUNCE,
-            CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
-            CONF_ENTRY_ANNOUNCE_VOLUME,
-            CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
-            CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
         )
         if player_id.startswith(SYNCGROUP_PREFIX):
             # add default entries for syncgroups
-            return (
+            entries = (
                 *entries,
                 ConfigEntry(
                     key=CONF_GROUP_MEMBERS,
@@ -74,6 +75,15 @@ class PlayerProvider(Provider):
                 ),
                 CONF_ENTRY_PLAYER_ICON_GROUP,
             )
+        if not player_id.startswith((SYNCGROUP_PREFIX, UGP_PREFIX)):
+            # add default entries for announce feature
+            entries = (
+                *entries,
+                CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
+                CONF_ENTRY_ANNOUNCE_VOLUME,
+                CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
+                CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
+            )
         return entries
 
     def on_player_config_changed(self, config: PlayerConfig, changed_keys: set[str]) -> None:
@@ -148,7 +158,7 @@ class PlayerProvider(Provider):
         """
 
     async def play_announcement(
-        self, player_id: str, announcement_url: str, volume_level: int | None = None
+        self, player_id: str, announcement: QueueItem, volume_level: int | None = None
     ) -> None:
         """Handle (provider native) playback of an announcement on given player."""
         # will only be called for players with PLAY_ANNOUNCEMENT feature set.
index 107e8d8b2d77284bb9c72d73eddc7f71833d3442..5c5fe3a340b207d16e8e250323ea4bfb7287deda 100644 (file)
@@ -199,7 +199,7 @@ class AirplayStream:
         self._audio_reader_task: asyncio.Task | None = None
         self._cliraop_proc: AsyncProcess | None = None
         self._ffmpeg_proc: AsyncProcess | None = None
-        self._buffer = asyncio.Queue(10)
+        self._buffer = asyncio.Queue(5)
 
     async def start(self, start_ntp: int) -> None:
         """Initialize CLIRaop process for a player."""
@@ -337,9 +337,6 @@ class AirplayStream:
         prev_metadata_checksum: str = ""
         prev_progress_report: float = 0
         async for line in self._cliraop_proc.iter_stderr():
-            line = line.decode().strip()  # noqa: PLW2901
-            if not line:
-                continue
             if "elapsed milliseconds:" in line:
                 # this is received more or less every second while playing
                 millis = int(line.split("elapsed milliseconds: ")[1])
@@ -611,14 +608,7 @@ class AirplayProvider(PlayerProvider):
                 if airplay_player.active_stream and airplay_player.active_stream.running:
                     tg.create_task(airplay_player.active_stream.stop(wait=wait_stopped))
         # select audio source
-        if queue_item.queue_id.startswith(UGP_PREFIX):
-            # special case: we got forwarded a request from the UGP
-            # use the existing stream job that was already created by UGP
-            stream_job = self.mass.streams.multi_client_jobs[queue_item.queue_id]
-            stream_job.expected_players.add(player_id)
-            input_format = stream_job.pcm_format
-            audio_source = stream_job.subscribe(player_id)
-        elif queue_item.media_type == MediaType.ANNOUNCEMENT:
+        if queue_item.media_type == MediaType.ANNOUNCEMENT:
             # special case: stream announcement
             input_format = AIRPLAY_PCM_FORMAT
             audio_source = self.mass.streams.get_announcement_stream(
@@ -626,6 +616,13 @@ class AirplayProvider(PlayerProvider):
                 output_format=AIRPLAY_PCM_FORMAT,
                 use_pre_announce=queue_item.streamdetails.data["use_pre_announce"],
             )
+        elif queue_item.queue_id.startswith(UGP_PREFIX):
+            # special case: we got forwarded a request from the UGP
+            # use the existing stream job that was already created by UGP
+            stream_job = self.mass.streams.multi_client_jobs[queue_item.queue_id]
+            stream_job.expected_players.add(player_id)
+            input_format = stream_job.pcm_format
+            audio_source = stream_job.subscribe(player_id)
         else:
             queue = self.mass.player_queues.get(queue_item.queue_id)
             input_format = AIRPLAY_PCM_FORMAT
index e42fd6fd3ca3630d4219159bc36a30b523a77ae6..88106e25df7d1187a2f3947aec08d001de986ee6 100644 (file)
@@ -321,14 +321,7 @@ class SnapCastProvider(PlayerProvider):
         snap_group = self._get_snapgroup(player_id)
         await snap_group.set_stream(stream.identifier)
 
-        if queue_item.queue_id.startswith(UGP_PREFIX):
-            # special case: we got forwarded a request from the UGP
-            # use the existing stream job that was already created by UGP
-            stream_job = self.mass.streams.multi_client_jobs[queue_item.queue_id]
-            stream_job.expected_players.add(player_id)
-            input_format = stream_job.pcm_format
-            audio_source = stream_job.subscribe(player_id)
-        elif queue_item.media_type == MediaType.ANNOUNCEMENT:
+        if queue_item.media_type == MediaType.ANNOUNCEMENT:
             # special case: stream announcement
             input_format = DEFAULT_SNAPCAST_FORMAT
             audio_source = self.mass.streams.get_announcement_stream(
@@ -336,6 +329,13 @@ class SnapCastProvider(PlayerProvider):
                 output_format=DEFAULT_SNAPCAST_FORMAT,
                 use_pre_announce=queue_item.streamdetails.data["use_pre_announce"],
             )
+        elif queue_item.queue_id.startswith(UGP_PREFIX):
+            # special case: we got forwarded a request from the UGP
+            # use the existing stream job that was already created by UGP
+            stream_job = self.mass.streams.multi_client_jobs[queue_item.queue_id]
+            stream_job.expected_players.add(player_id)
+            input_format = stream_job.pcm_format
+            audio_source = stream_job.subscribe(player_id)
         else:
             queue = self.mass.player_queues.get(queue_item.queue_id)
             input_format = DEFAULT_SNAPCAST_FORMAT
index e6312bd943ba7c031ed95f7edae7045a790c7cef..5027dfff3f0ea171c4968da04f6f742effe70d5a 100644 (file)
@@ -33,7 +33,7 @@ from music_assistant.common.models.enums import (
 )
 from music_assistant.common.models.errors import PlayerCommandFailed, PlayerUnavailableError
 from music_assistant.common.models.player import DeviceInfo, Player
-from music_assistant.constants import CONF_CROSSFADE, VERBOSE_LOG_LEVEL
+from music_assistant.constants import CONF_CROSSFADE, SYNCGROUP_PREFIX, VERBOSE_LOG_LEVEL
 from music_assistant.server.helpers.didl_lite import create_didl_metadata
 from music_assistant.server.models.player_provider import PlayerProvider
 
@@ -397,15 +397,29 @@ class SonosPlayerProvider(PlayerProvider):
         await self._enqueue_item(sonos_player, url=url, queue_item=queue_item)
 
     async def play_announcement(
-        self, player_id: str, announcement_url: str, volume_level: int | None = None
+        self, player_id: str, announcement: QueueItem, volume_level: int | None = None
     ) -> None:
         """Handle (provider native) playback of an announcement on given player."""
+        if player_id.startswith(SYNCGROUP_PREFIX):
+            # handle syncgroup, unwrap to all underlying child's
+            async with asyncio.TaskGroup() as tg:
+                if group_player := self.mass.players.get(player_id):
+                    # execute on all child players
+                    for child_player_id in group_player.group_childs:
+                        tg.create_task(
+                            self.play_announcement(child_player_id, announcement, volume_level)
+                        )
+            return
+        announcement_url = self.mass.streams.resolve_stream_url(
+            player_id, announcement, ContentType.MP3
+        )
         sonos_player = self.sonosplayers[player_id]
         self.logger.debug(
             "Playing announcement %s using websocket audioclip on %s",
             announcement_url,
             sonos_player.zone_name,
         )
+        volume_level = self.mass.players.get_announcement_volume(player_id, volume_level)
         try:
             response, _ = await sonos_player.websocket.play_clip(
                 announcement_url,
index 5efa409c4d3f59d01071d5a5c2bb49e4b67af781..900f5424bfa3043a04acb805951fc919f576caa3 100644 (file)
@@ -20,6 +20,7 @@ from music_assistant.common.models.config_entries import (
 )
 from music_assistant.common.models.enums import (
     ConfigEntryType,
+    MediaType,
     PlayerFeature,
     PlayerState,
     PlayerType,
@@ -185,6 +186,10 @@ class UniversalGroupProvider(PlayerProvider):
             name="Music Assistant",
             duration=None,
         )
+        # special case: handle announcement sent to this UGP
+        # we just forward this as-is downstream and let all child players handle this themselves
+        if queue_item.media_type == MediaType.ANNOUNCEMENT:
+            ugp_queue_item = queue_item
 
         # forward the stream job to all group members
         async with asyncio.TaskGroup() as tg: