Repository: music-assistant/server
Pull request #2145: Several improvements to announcements
Author: login: chatziko
Labels: name: enhancement
Created: 2025-04-24T14:13:23Z | Merged: 2025-04-25T17:02:38Z


The generic announcement implementation is particularly glitchy for snapcast players cause pause/resume is far from smooth (this is known and mentioned in the doc). This PR adds a native announcement implementation which provides a much better experience. The idea is pretty simple:
- Use a separete snapcast stream for announcements
- Switch the snapcast group to it while the announcement is playing
- Swtch back afterwards

This is as close to "ducking" as we can easily get, the announcement plays over the music (unfortunately __not__ mixed) and the playback is not interrupted at all.

#### Stream names

The current implementation uses stream names that are derived from the __queue__ name. This has the following issues:

- It is buggy when two players use the same queue simultaneously (eg in universal groups). See https://github.com/music-assistant/support/issues/3922
- It makes it harder to find which stream correspond to which player, if we need to delete a stream while it is not active.

So I opted for a cleaner solution:
- each player has at most two streams active: one for music one for announcements
- the stream names are based on the player name (not the queue name)

It's a bit less pretty, but the player and queue names very often coincide, and stream names are only visible in snapweb anyway, nobody really sees them.


#### Volume level

Unfortunately snapcast sets a volume per client (not per stream), so we need a way to set the announcement volume without affecting the music volume. I opted for the simplest solution: save the previous volume, change it, restore later.

This has the downside that the temporary change will be visible in the UI. We could hide it (eg by hiding snapcast volume updates from the player controller), but it's probably not worth the extra complexity.


#### Other notes

- The changes are quite simple actually, mainly adding a `play_announcement` method, and refactor some code that is common with `play_media`.

- Interacting with the player (pause, play, etc) during announcement is possible. Eg: an announcement starts when idle then
  the user presses play. Playback will start, and the snapcast group will switch to the music steam when the announcement is over.

- The control script (`control.py`) is not used at all for announcement streams.

- I deleted a couple of lines that seemed obsolete to me, in separate commits for easier review


Diff:
diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py
index a439f9b18c..2dde638615 100644
--- a/music_assistant/controllers/streams.py
+++ b/music_assistant/controllers/streams.py
@@ -919,25 +919,39 @@ async def get_announcement_stream(
         use_pre_announce: bool = False,
     ) -> AsyncGenerator[bytes, None]:
         """Get the special announcement stream."""
+        filter_params = ["loudnorm=I=-10:LRA=11:TP=-2"]
+
+        if use_pre_announce:
+            # Note: TTS URLs might take a while to load cause the actual data are often generated
+            # asynchronously by the TTS provider. If we ask ffmpeg to mix the pre-announce, it will
+            # wait until it reads the TTS data, so the whole stream will be delayed. It is much
+            # faster to first play the pre-announce using a separate ffmpeg stream, and only
+            # afterwards play the TTS itself.
+            #
+            # For this to be effective the player itself needs to be able to start playback fast.
+            # If the returned stream is used as input to ffmpeg we should pass -probesize 8096.
+            #
+            # Finally we also need to make sure we don't make other blocking requests to the TTS
+            # data, eg to get the duration (async_parse_tags).
+            #
+            async for chunk in get_ffmpeg_stream(
+                audio_input=ANNOUNCE_ALERT_FILE,
+                input_format=AudioFormat(content_type=ContentType.try_parse(ANNOUNCE_ALERT_FILE)),
+                output_format=output_format,
+                filter_params=filter_params,
+            ):
+                yield chunk
+
         # work out output format/details
         fmt = announcement_url.rsplit(".")[-1]
         audio_format = AudioFormat(content_type=ContentType.try_parse(fmt))
-        extra_args = []
-        filter_params = ["loudnorm=I=-10:LRA=11:TP=-2"]
-        if use_pre_announce:
-            extra_args += [
-                "-i",
-                ANNOUNCE_ALERT_FILE,
-                "-filter_complex",
-                "[1:a][0:a]concat=n=2:v=0:a=1,loudnorm=I=-10:LRA=11:TP=-1.5",
-            ]
-            filter_params = []
+        extra_input_args = ["-probesize", "8096"]  # start the stream before reading all TTS input
         async for chunk in get_ffmpeg_stream(
             audio_input=announcement_url,
             input_format=audio_format,
             output_format=output_format,
-            extra_args=extra_args,
             filter_params=filter_params,
+            extra_input_args=extra_input_args,
         ):
             yield chunk
 
diff --git a/music_assistant/providers/snapcast/__init__.py b/music_assistant/providers/snapcast/__init__.py
index 6c57939d9e..015b9a7f96 100644
--- a/music_assistant/providers/snapcast/__init__.py
+++ b/music_assistant/providers/snapcast/__init__.py
@@ -11,6 +11,7 @@
 import time
 import urllib.parse
 from contextlib import suppress
+from enum import StrEnum
 from typing import TYPE_CHECKING, cast
 
 from bidict import bidict
@@ -83,7 +84,8 @@
 DEFAULT_SNAPSERVER_PORT = 1705
 DEFAULT_SNAPSTREAM_IDLE_THRESHOLD = 60000
 
-MASS_STREAM_POSTFIX = "Music Assistant"
+MASS_STREAM_PREFIX = "Music Assistant - "
+MASS_ANNOUNCEMENT_POSTFIX = " (announcement)"
 SNAPWEB_DIR = pathlib.Path(__file__).parent.resolve().joinpath("snapweb")
 CONTROL_SCRIPT = pathlib.Path(__file__).parent.resolve().joinpath("control.py")
 
@@ -105,6 +107,13 @@
 )
 
 
+class SnapCastStreamType(StrEnum):
+    """Enum for Snapcast Stream Type."""
+
+    MUSIC = "MUSIC"
+    ANNOUNCEMENT = "ANNOUNCEMENT"
+
+
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
@@ -404,6 +413,7 @@ def _handle_player_init(self, snap_client: Snapclient) -> None:
                     PlayerFeature.SET_MEMBERS,
                     PlayerFeature.VOLUME_SET,
                     PlayerFeature.VOLUME_MUTE,
+                    PlayerFeature.PLAY_ANNOUNCEMENT,
                 },
                 synced_to=self._synced_to(player_id),
                 can_group_with={self.instance_id},
@@ -423,14 +433,18 @@ def _handle_player_update(self, snap_client: Snapclient) -> None:
         player.volume_muted = snap_client.muted
         player.available = snap_client.connected
         player.synced_to = self._synced_to(player_id)
-        # if player.active_group is None:
-        if stream := self._get_snapstream(player_id):
+
+        # Note: when the active stream is a MASS stream the active_source is __not__ updated at all.
+        # So it doesn't matter whether a MASS stream is for music or announcements.
+        if stream := self._get_active_snapstream(player_id):
             if stream.identifier == "default":
                 player.active_source = None
-            elif not stream.identifier.startswith(MASS_STREAM_POSTFIX):
+            elif not stream.identifier.startswith(MASS_STREAM_PREFIX):
+                # unknown source
                 player.active_source = stream.identifier
         else:
             player.active_source = None
+
         self._group_childs(player_id)
         self.mass.players.update(player_id)
 
@@ -455,25 +469,30 @@ async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
         """Send VOLUME_SET command to given player."""
         snap_client_id = self._get_snapclient_id(player_id)
         await self._snapserver.client(snap_client_id).set_volume(volume_level)
-        self.mass.players.update(snap_client_id)
 
     async def cmd_stop(self, player_id: str) -> None:
         """Send STOP command to given player."""
+        # update the state first to avoid race conditions, if an active play_announcement
+        # finishes the player.state should be IDLE.
         player = self.mass.players.get(player_id, raise_unavailable=False)
+        player.state = PlayerState.IDLE
+        player.current_media = None
+        player.active_source = None
+        self._set_childs_state(player_id)
+        self.mass.players.update(player_id)
 
-        await self._get_snapgroup(player_id).set_stream("default")
-        await self._delete_current_snapstream(self._get_snapstream(player_id))
+        # we change the active stream only if music was playing
+        if not player.announcement_in_progress:
+            await self._get_snapgroup(player_id).set_stream("default")
+
+        # but we always delete the music stream (whether it was active or not)
+        await self._delete_stream(self._get_stream_name(player_id, SnapCastStreamType.MUSIC))
 
         if stream_task := self._stream_tasks.pop(player_id, None):
             if not stream_task.done():
                 stream_task.cancel()
                 with suppress(asyncio.CancelledError):
                     await stream_task
-        player.state = PlayerState.IDLE
-        player.current_media = None
-        player.active_source = None
-        self._set_childs_state(player_id)
-        self.mass.players.update(player_id)
 
     async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
         """Send MUTE command to given player."""
@@ -518,7 +537,7 @@ async def cmd_ungroup(self, player_id: str) -> None:
         self.mass.players.update(player_id, skip_forward=True)
         self.mass.players.update(mass_player.synced_to, skip_forward=True)
 
-    async def play_media(self, player_id: str, media: PlayerMedia) -> None:  # noqa: PLR0915
+    async def play_media(self, player_id: str, media: PlayerMedia) -> None:
         """Handle PLAY MEDIA on given player."""
         player = self.mass.players.get(player_id)
         if player.synced_to:
@@ -533,23 +552,20 @@ async def play_media(self, player_id: str, media: PlayerMedia) -> None:  # noqa:
                     await stream_task
 
         # get stream or create new one
-        stream = await self._get_or_create_stream(player_id, media.queue_id or player_id)
-        snap_group = self._get_snapgroup(player_id)
-        await snap_group.set_stream(stream.identifier)
+        stream_name = self._get_stream_name(player_id, SnapCastStreamType.MUSIC)
+        stream = await self._get_or_create_stream(stream_name, media.queue_id or player_id)
+
+        # if no announcement is playing we activate the stream now, otherwise it
+        # will be activated by play_announcement when the announcement is over.
+        if not player.announcement_in_progress:
+            snap_group = self._get_snapgroup(player_id)
+            await snap_group.set_stream(stream.identifier)
 
         player.current_media = media
         player.active_source = media.queue_id
 
         # select audio source
-        if media.media_type == MediaType.ANNOUNCEMENT:
-            # special case: stream announcement
-            input_format = DEFAULT_SNAPCAST_FORMAT
-            audio_source = self.mass.streams.get_announcement_stream(
-                media.custom_data["url"],
-                output_format=DEFAULT_SNAPCAST_FORMAT,
-                use_pre_announce=media.custom_data["use_pre_announce"],
-            )
-        elif media.media_type == MediaType.PLUGIN_SOURCE:
+        if media.media_type == MediaType.PLUGIN_SOURCE:
             # special case: plugin source stream
             input_format = DEFAULT_SNAPCAST_FORMAT
             audio_source = self.mass.streams.get_plugin_source_stream(
@@ -584,12 +600,7 @@ async def play_media(self, player_id: str, media: PlayerMedia) -> None:  # noqa:
             )
 
         async def _streamer() -> None:
-            if stream.path:
-                stream_path = stream.path
-            if not stream.path:
-                stream_path = "tcp://" + stream._stream["uri"]["host"]
-            stream_path = stream_path.replace("0.0.0.0", self._snapcast_server_host)
-
+            stream_path = self._get_stream_path(stream)
             self.logger.debug("Start streaming to %s", stream_path)
             async with FFMpeg(
                 audio_input=audio_source,
@@ -622,11 +633,101 @@ async def _streamer() -> None:
         # start streaming the queue (pcm) audio in a background task
         self._stream_tasks[player_id] = self.mass.create_task(_streamer())
 
-    async def _delete_current_snapstream(self, stream: Snapstream) -> None:
-        if not stream.identifier.startswith(MASS_STREAM_POSTFIX):
-            return
-        with suppress(TypeError, KeyError, AttributeError):
-            await self._snapserver.stream_remove_stream(stream.identifier)
+    async def play_announcement(
+        self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None
+    ) -> None:
+        """Handle (provider native) playback of an announcement on given player."""
+        # get stream or create new one
+        stream_name = self._get_stream_name(player_id, SnapCastStreamType.ANNOUNCEMENT)
+        stream = await self._get_or_create_stream(stream_name, None)
+
+        # always activate the stream (announcements have priority over music)
+        snap_group = self._get_snapgroup(player_id)
+        await snap_group.set_stream(stream.identifier)
+
+        # Unfortunately snapcast sets a volume per client (not per stream), so we need a way to
+        # set the announcement volume without affecting the music volume.
+        # We go for the simplest solution: save the previous volume, change it, restore later
+        # (with the downside that the change will be visible in the UI)
+        player = self.mass.players.get(player_id)
+        orig_volume_level = player.volume_level
+        volume_level = self.mass.players.get_announcement_volume(player_id, volume_level)
+        await self.cmd_volume_set(player_id, volume_level)
+
+        input_format = DEFAULT_SNAPCAST_FORMAT
+        audio_source = self.mass.streams.get_announcement_stream(
+            announcement.custom_data["url"],
+            output_format=DEFAULT_SNAPCAST_FORMAT,
+            use_pre_announce=announcement.custom_data["use_pre_announce"],
+        )
+
+        # stream the audio, wait for it to finish (play_announcement should return after the
+        # announcement is over to avoid simultaneous announcements).
+        #
+        # Note: -probesize 8096 is needed to start playing the pre-announce before the TTS
+        #       data arrive (they arrive late, see get_announcement_stream).
+        #
+        stream_path = self._get_stream_path(stream)
+        self.logger.debug("Start announcement streaming to %s", stream_path)
+        async with FFMpeg(
+            audio_input=audio_source,
+            input_format=input_format,
+            output_format=DEFAULT_SNAPCAST_FORMAT,
+            filter_params=get_player_filter_params(
+                self.mass, player_id, input_format, DEFAULT_SNAPCAST_FORMAT
+            ),
+            audio_output=stream_path,
+            extra_input_args=["-y", "-re", "-probesize", "8096"],
+        ) as ffmpeg_proc:
+            await ffmpeg_proc.wait()
+
+        self.logger.debug("Finished announcement streaming to %s", stream_path)
+        # we need to wait a bit for the stream status to become idle
+        # to ensure that all snapclients have consumed the audio
+        while stream.status != "idle":
+            await asyncio.sleep(0.25)
+
+        # delete the announcement stream
+        await self._delete_stream(stream_name)
+
+        # restore volume, if it is still the same we set above
+        # (the user did not change it while the announcement was playing)
+        if player.volume_level == volume_level and orig_volume_level is not None:
+            await self.cmd_volume_set(player_id, orig_volume_level)
+
+        # and restore the group to either the default or the music stream
+        if player.state == PlayerState.IDLE:
+            new_stream_name = "default"
+        else:
+            new_stream_name = self._get_stream_name(player_id, SnapCastStreamType.MUSIC)
+        await self._get_snapgroup(player_id).set_stream(new_stream_name)
+
+    def _get_stream_name(self, player_id: str, stream_type: SnapCastStreamType) -> str:
+        """Return the name of the stream for the given player.
+
+        Each player can have up to two concurrent streams, for music and announcements.
+
+        The stream name depends only on player_id (not queue_id) for two reasones:
+        1. Avoid issues when the same queue_id is simultaneously used by two players
+           (eg in universal groups).
+        2. Easily identify which stream belongs to which player, for instance to be able to
+           delete a music stream even when it is not active due to an announcement.
+        """
+        player = self.mass.players.get(player_id)
+        safe_name = create_safe_string(player.display_name, replace_space=True)
+        stream_name = f"{MASS_STREAM_PREFIX}{safe_name}"
+        if stream_type == SnapCastStreamType.ANNOUNCEMENT:
+            stream_name += MASS_ANNOUNCEMENT_POSTFIX
+        return stream_name
+
+    def _get_stream_path(self, stream: Snapstream) -> str:
+        stream_path = stream.path or f"tcp://{stream._stream['uri']['host']}"
+        return stream_path.replace("0.0.0.0", self._snapcast_server_host)
+
+    async def _delete_stream(self, stream_name: str) -> None:
+        if stream := self._get_snapstream(stream_name):
+            with suppress(TypeError, KeyError, AttributeError):
+                await self._snapserver.stream_remove_stream(stream.identifier)
 
     def _get_snapgroup(self, player_id: str) -> Snapgroup:
         """Get snapcast group for given player_id."""
@@ -634,11 +735,16 @@ def _get_snapgroup(self, player_id: str) -> Snapgroup:
         client: Snapclient = self._snapserver.client(snap_client_id)
         return client.group
 
-    def _get_snapstream(self, player_id: str) -> Snapstream | None:
-        """Get snapcast stream for given player_id."""
+    def _get_snapstream(self, stream_name: str) -> Snapstream | None:
+        """Get a stream by name."""
+        with suppress(KeyError):
+            return self._snapserver.stream(stream_name)
+        return None
+
+    def _get_active_snapstream(self, player_id: str) -> Snapstream | None:
+        """Get active stream for given player_id."""
         if group := self._get_snapgroup(player_id):
-            with suppress(KeyError):
-                return self._snapserver.stream(group.stream)
+            return self._get_snapstream(group.stream)
         return None
 
     def _synced_to(self, player_id: str) -> str | None:
@@ -665,20 +771,15 @@ def _group_childs(self, player_id: str) -> set[str]:
             and self._snapserver.client(snap_client_id).connected
         }
 
-    async def _get_or_create_stream(self, player_id: str, queue_id: str) -> Snapstream:
+    async def _get_or_create_stream(self, stream_name: str, queue_id: str | None) -> Snapstream:
         """Create new stream on snapcast server (or return existing one)."""
-        mass_queue = self.mass.player_queues.get(queue_id)
-        safe_name = create_safe_string(mass_queue.display_name, replace_space=True)
-        stream_name = f"{MASS_STREAM_POSTFIX} - {safe_name}"
-        # cancel any existing clear stream task
-        self.mass.cancel_timer(f"snapcast_clear_stream_{player_id}")
-
         # prefer to reuse existing stream if possible
-        for stream in self._snapserver.streams:
-            if stream.identifier == stream_name:
-                return stream
+        if stream := self._get_snapstream(stream_name):
+            return stream
 
-        if self._use_builtin_server:
+        # The control script is used only for music streams in the builtin server
+        # (queue_id is None only for announcement streams).
+        if self._use_builtin_server and queue_id:
             extra_args = (
                 f"&controlscript={urllib.parse.quote_plus(str(CONTROL_SCRIPT))}"
                 f"&controlscriptparams=--queueid={urllib.parse.quote_plus(queue_id)}%20"


Reviews:
user: login: marcelveldt
body: While I do (highly) appreciate the code quality improvements of this PR I do not get the implementation of switching to a separate stream for the announcement. Switching a stream should be something like a second or so, versus missing an entire block of music because its playing in the void. Maybe something else is going on on a higher level why this doesn't work as you want/expect ?

With only little effort it would be possible to mix the announcement into the pcm stream sent to the snapcast TCP port, that would be a much better solution. Setting up a second stream is NOT a solution imo.

So, let's first have a discussion about this on discord and discuss the issue you are trying to solve here.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-24T14:45:41Z
user: login: marcelveldt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T15:36:29Z
user: login: chatziko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T16:04:33Z
user: login: marcelveldt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T17:01:59Z
user: login: marcelveldt
body: Nice work @chatziko !
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T17:02:25Z

Review comments:
diff_hunk: @@ -919,25 +919,39 @@ async def get_announcement_stream(
         use_pre_announce: bool = False,
     ) -> AsyncGenerator[bytes, None]:
         """Get the special announcement stream."""
+        filter_params = ["loudnorm=I=-10:LRA=11:TP=-2"]
+
+        if use_pre_announce:
+            # Note: TTS URLs might take a while to load cause the actual data are often generated
+            # asynchronously by the TTS provider. If we ask ffmpeg to mix the pre-announce, it will
+            # wait until it reads the TTS data, so the whole stream will be delayed. It is much
+            # faster to first play the pre-announce using a separate ffmpeg stream, and only
+            # afterwards play the TTS itself.
+            #
+            # For this to be effective the player itself needs to be able to start playback fast.
+            # If the returned stream is used as input to ffmpeg we should pass -probesize 8096.
+            #
+            # Finally we also need to make sure we don't make other blocking requests to the TTS
+            # data, eg to get the duration (async_parse_tags).
+            #
+            async for chunk in get_ffmpeg_stream(
+                audio_input=ANNOUNCE_ALERT_FILE,
+                input_format=AudioFormat(content_type=ContentType.try_parse(ANNOUNCE_ALERT_FILE)),
+                output_format=output_format,
+                filter_params=filter_params,
+            ):
+                yield chunk
+
path: music_assistant/controllers/streams.py
user: login: marcelveldt
body: This will not work in all cases because a player might detect EOF and stop playing. Did you try this with a couple of different players ? Especially players that get the url themselves ?
created_at: 2025-04-25T15:36:28Z
author_association: MEMBER
diff_hunk: @@ -919,25 +919,39 @@ async def get_announcement_stream(
         use_pre_announce: bool = False,
     ) -> AsyncGenerator[bytes, None]:
         """Get the special announcement stream."""
+        filter_params = ["loudnorm=I=-10:LRA=11:TP=-2"]
+
+        if use_pre_announce:
+            # Note: TTS URLs might take a while to load cause the actual data are often generated
+            # asynchronously by the TTS provider. If we ask ffmpeg to mix the pre-announce, it will
+            # wait until it reads the TTS data, so the whole stream will be delayed. It is much
+            # faster to first play the pre-announce using a separate ffmpeg stream, and only
+            # afterwards play the TTS itself.
+            #
+            # For this to be effective the player itself needs to be able to start playback fast.
+            # If the returned stream is used as input to ffmpeg we should pass -probesize 8096.
+            #
+            # Finally we also need to make sure we don't make other blocking requests to the TTS
+            # data, eg to get the duration (async_parse_tags).
+            #
+            async for chunk in get_ffmpeg_stream(
+                audio_input=ANNOUNCE_ALERT_FILE,
+                input_format=AudioFormat(content_type=ContentType.try_parse(ANNOUNCE_ALERT_FILE)),
+                output_format=output_format,
+                filter_params=filter_params,
+            ):
+                yield chunk
+
path: music_assistant/controllers/streams.py
user: login: chatziko
body: Not really, let me test before merging and/or think alternative solutions.

I tried to do it within ffmpeg, that is convince it to start merging A and B before B arrives, but it might be impossible before reading even a single byte of B.
created_at: 2025-04-25T16:04:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -919,25 +919,39 @@ async def get_announcement_stream(
         use_pre_announce: bool = False,
     ) -> AsyncGenerator[bytes, None]:
         """Get the special announcement stream."""
+        filter_params = ["loudnorm=I=-10:LRA=11:TP=-2"]
+
+        if use_pre_announce:
+            # Note: TTS URLs might take a while to load cause the actual data are often generated
+            # asynchronously by the TTS provider. If we ask ffmpeg to mix the pre-announce, it will
+            # wait until it reads the TTS data, so the whole stream will be delayed. It is much
+            # faster to first play the pre-announce using a separate ffmpeg stream, and only
+            # afterwards play the TTS itself.
+            #
+            # For this to be effective the player itself needs to be able to start playback fast.
+            # If the returned stream is used as input to ffmpeg we should pass -probesize 8096.
+            #
+            # Finally we also need to make sure we don't make other blocking requests to the TTS
+            # data, eg to get the duration (async_parse_tags).
+            #
+            async for chunk in get_ffmpeg_stream(
+                audio_input=ANNOUNCE_ALERT_FILE,
+                input_format=AudioFormat(content_type=ContentType.try_parse(ANNOUNCE_ALERT_FILE)),
+                output_format=output_format,
+                filter_params=filter_params,
+            ):
+                yield chunk
+
path: music_assistant/controllers/streams.py
user: login: marcelveldt
body: I did some base tests with a few players and it seems to work just fine. So let's keep it as-is and change only if we get issue reports
created_at: 2025-04-25T17:01:58Z
author_association: MEMBER

PR comments:
user: login: chatziko
created_at: 2025-04-24T15:24:13Z
body: Ok, just opened a post in discord.
author_association: CONTRIBUTOR
user: login: marcelveldt
created_at: 2025-04-25T15:41:16Z
body: As discussed on discord, we are going to merge this so at least we have an improvement over the current implementation and then iterate from there. Doing the announcement streaming mixing is not as straight forward as we think and need more discussion.


author_association: MEMBER