Allow chime URL to be customized for Announcements (#2403)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 16 Sep 2025 00:06:46 +0000 (02:06 +0200)
committerGitHub <noreply@github.com>
Tue, 16 Sep 2025 00:06:46 +0000 (02:06 +0200)
music_assistant/constants.py
music_assistant/controllers/players.py
music_assistant/controllers/streams.py
music_assistant/helpers/util.py
music_assistant/models/player.py
music_assistant/providers/airplay/player.py
music_assistant/providers/snapcast/player.py
music_assistant/providers/squeezelite/player.py
music_assistant/providers/universal_group/player.py
pyproject.toml
requirements_all.txt

index 70116edb3c569c40d3f5167def83e07b6f32bb0c..d482ec7a32c3ba9301a136b10b21c5da054f485b 100644 (file)
@@ -77,6 +77,7 @@ CONF_ANNOUNCE_VOLUME_STRATEGY: Final[str] = "announce_volume_strategy"
 CONF_ANNOUNCE_VOLUME: Final[str] = "announce_volume"
 CONF_ANNOUNCE_VOLUME_MIN: Final[str] = "announce_volume_min"
 CONF_ANNOUNCE_VOLUME_MAX: Final[str] = "announce_volume_max"
+CONF_PRE_ANNOUNCE_CHIME_URL: Final[str] = "pre_announcement_chime_url"
 CONF_ICON: Final[str] = "icon"
 CONF_LANGUAGE: Final[str] = "language"
 CONF_SAMPLE_RATES: Final[str] = "sample_rates"
@@ -479,6 +480,8 @@ CONF_ENTRY_ANNOUNCE_VOLUME_MAX = ConfigEntry(
 CONF_ENTRY_ANNOUNCE_VOLUME_MAX_HIDDEN = ConfigEntry.from_dict(
     {**CONF_ENTRY_ANNOUNCE_VOLUME_MAX.to_dict(), "hidden": True}
 )
+
+
 HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES = (
     CONF_ENTRY_ANNOUNCE_VOLUME_HIDDEN,
     CONF_ENTRY_ANNOUNCE_VOLUME_MIN_HIDDEN,
index 00349b319fd5290725376d43a6b2a73bb59deffb..3ad662724c86d28792ded38d55963da722c76488 100644 (file)
@@ -47,6 +47,7 @@ from music_assistant_models.errors import (
 from music_assistant_models.player_control import PlayerControl  # noqa: TC002
 
 from music_assistant.constants import (
+    ANNOUNCE_ALERT_FILE,
     ATTR_ANNOUNCEMENT_IN_PROGRESS,
     ATTR_FAKE_MUTE,
     ATTR_FAKE_POWER,
@@ -61,14 +62,16 @@ from music_assistant.constants import (
     CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
     CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
     CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
+    CONF_ENTRY_TTS_PRE_ANNOUNCE,
     CONF_PLAYER_DSP,
     CONF_PLAYERS,
-    CONF_TTS_PRE_ANNOUNCE,
+    CONF_PRE_ANNOUNCE_CHIME_URL,
 )
+from music_assistant.controllers.streams import AnnounceData
 from music_assistant.helpers.api import api_command
 from music_assistant.helpers.tags import async_parse_tags
 from music_assistant.helpers.throttle_retry import Throttler
-from music_assistant.helpers.util import TaskManager
+from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url
 from music_assistant.models.core_controller import CoreController
 from music_assistant.models.player import Player, PlayerMedia, PlayerState
 from music_assistant.models.player_provider import PlayerProvider
@@ -753,14 +756,29 @@ class PlayerController(CoreController):
         self,
         player_id: str,
         url: str,
-        use_pre_announce: bool | None = None,
+        pre_announce: bool | str | None = None,
         volume_level: int | None = None,
+        pre_announce_url: str | None = None,
     ) -> None:
-        """Handle playback of an announcement (url) on given player."""
+        """
+        Handle playback of an announcement (url) on given player.
+
+        - player_id: player_id of the player to handle the command.
+        - url: URL of the announcement to play.
+        - pre_announce: optional bool if pre-announce should be used.
+        - volume_level: optional volume level to set for the announcement.
+        - pre_announce_url: optional custom URL to use for the pre-announce chime.
+        """
         player = self.get(player_id, True)
         assert player is not None  # for type checking
         if not url.startswith("http"):
             raise PlayerCommandFailed("Only URLs are supported for announcements")
+        if (
+            pre_announce
+            and pre_announce_url
+            and not validate_announcement_chime_url(pre_announce_url)
+        ):
+            raise PlayerCommandFailed("Invalid pre-announce chime URL specified.")
         # prevent multiple announcements at the same time to the same player with a lock
         if player_id not in self._announce_locks:
             self._announce_locks[player_id] = lock = asyncio.Lock()
@@ -775,11 +793,23 @@ class PlayerController(CoreController):
                     PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features
                 )
                 # determine pre-announce from (group)player config
-                if use_pre_announce is None and "tts" in url:
-                    use_pre_announce = await self.mass.config.get_player_config_value(
+                if pre_announce is None and "tts" in url:
+                    conf_pre_announce = self.mass.config.get_raw_player_config_value(
                         player_id,
-                        CONF_TTS_PRE_ANNOUNCE,
+                        CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
+                        CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value,
                     )
+                    pre_announce = cast("bool", conf_pre_announce)
+                if pre_announce_url is None:
+                    if conf_pre_announce_url := self.mass.config.get_raw_player_config_value(
+                        player_id,
+                        CONF_PRE_ANNOUNCE_CHIME_URL,
+                    ):
+                        # player default custom chime url
+                        pre_announce_url = cast("str", conf_pre_announce_url)
+                    else:
+                        # use global default chime url
+                        pre_announce_url = ANNOUNCE_ALERT_FILE
                 # if player type is group with all members supporting announcements,
                 # we forward the request to each individual player
                 if player.type == PlayerType.GROUP and (
@@ -795,24 +825,30 @@ class PlayerController(CoreController):
                                 self.play_announcement(
                                     group_member,
                                     url=url,
-                                    use_pre_announce=use_pre_announce,
+                                    pre_announce=pre_announce,
                                     volume_level=volume_level,
+                                    pre_announce_url=pre_announce_url,
                                 )
                             )
                     return
                 self.logger.info(
                     "Playback announcement to player %s (with pre-announce: %s): %s",
                     player.display_name,
-                    use_pre_announce,
+                    pre_announce,
                     url,
                 )
                 # create a PlayerMedia object for the announcement so
                 # we can send a regular play-media call downstream
+                announce_data = AnnounceData(
+                    announcement_url=url,
+                    pre_announce=pre_announce,
+                    pre_announce_url=pre_announce_url,
+                )
                 announcement = PlayerMedia(
-                    uri=self.mass.streams.get_announcement_url(player_id, url, use_pre_announce),
+                    uri=self.mass.streams.get_announcement_url(player_id, url, announce_data),
                     media_type=MediaType.ANNOUNCEMENT,
                     title="Announcement",
-                    custom_data={"url": url, "use_pre_announce": use_pre_announce},
+                    custom_data=announce_data,
                 )
                 # handle native announce support
                 if native_announce_support:
index 8f4cc06ec92b3429e7b0453abf251c07ce899745..ef66b7d6bee15b9b612a65c6fb14797992c4fe1c 100644 (file)
@@ -14,7 +14,7 @@ import os
 import urllib.parse
 from collections.abc import AsyncGenerator
 from dataclasses import dataclass, field
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, TypedDict
 
 from aiofiles.os import wrap
 from aiohttp import web
@@ -72,7 +72,6 @@ from music_assistant.helpers.util import (
     get_free_space_percentage,
     get_ip_addresses,
     select_free_port,
-    try_parse_bool,
 )
 from music_assistant.helpers.webserver import Webserver
 from music_assistant.models.core_controller import CoreController
@@ -83,6 +82,7 @@ if TYPE_CHECKING:
     from music_assistant_models.player_queue import PlayerQueue
     from music_assistant_models.queue_item import QueueItem
 
+    from music_assistant.mass import MusicAssistant
     from music_assistant.models.player import Player
 
 
@@ -110,14 +110,22 @@ class CrossfadeData:
     session_id: str | None = None
 
 
+class AnnounceData(TypedDict):
+    """Announcement data."""
+
+    announcement_url: str
+    pre_announce: bool
+    pre_announce_url: str
+
+
 class StreamsController(CoreController):
     """Webserver Controller to stream audio to players."""
 
     domain: str = "streams"
 
-    def __init__(self, *args, **kwargs) -> None:
+    def __init__(self, mass: MusicAssistant) -> None:
         """Initialize instance."""
-        super().__init__(*args, **kwargs)
+        super().__init__(mass)
         self._server = Webserver(self.logger, enable_dynamic_routes=True)
         self.register_dynamic_route = self._server.register_dynamic_route
         self.unregister_dynamic_route = self._server.unregister_dynamic_route
@@ -127,7 +135,7 @@ class StreamsController(CoreController):
             "streaming audio to players on the local network."
         )
         self.manifest.icon = "cast-audio"
-        self.announcements: dict[str, str] = {}
+        self.announcements: dict[str, AnnounceData] = {}
         # prefer /tmp/.audio as audio cache dir
         self._audio_cache_dir = os.path.join("/tmp/.audio")  # noqa: S108
         self.allow_cache_default = "auto"
@@ -446,7 +454,7 @@ class StreamsController(CoreController):
             # crossfade is not supported on this player due to missing gapless playback
             self.logger.warning(
                 "Crossfade disabled: Player %s does not support gapless playback",
-                queue_player.display_name,
+                queue_player.display_name if queue_player else "Unknown Player",
             )
             crossfade = False
 
@@ -545,9 +553,10 @@ class StreamsController(CoreController):
             reason="OK",
             headers=headers,
         )
-        http_profile: str = await self.mass.config.get_player_config_value(
+        http_profile_value = await self.mass.config.get_player_config_value(
             queue_id, CONF_HTTP_PROFILE
         )
+        http_profile = str(http_profile_value) if http_profile_value is not None else "default"
         if http_profile == "forced_content_length":
             # just set an insane high content length to make sure the player keeps playing
             resp.content_length = get_chunksize(output_format, 12 * 3600)
@@ -625,26 +634,26 @@ class StreamsController(CoreController):
         player = self.mass.player_queues.get(player_id)
         if not player:
             raise web.HTTPNotFound(reason=f"Unknown Player: {player_id}")
-        if player_id not in self.announcements:
+        if not (announce_data := self.announcements.get(player_id)):
             raise web.HTTPNotFound(reason=f"No pending announcements for Player: {player_id}")
-        announcement_url = self.announcements[player_id]
-        use_pre_announce = try_parse_bool(request.query.get("pre_announce"))
 
         # work out output format/details
-        fmt = request.match_info.get("fmt", announcement_url.rsplit(".")[-1])
+        fmt = request.match_info["fmt"]
         audio_format = AudioFormat(content_type=ContentType.try_parse(fmt))
 
-        http_profile: str = await self.mass.config.get_player_config_value(
+        http_profile_value = await self.mass.config.get_player_config_value(
             player_id, CONF_HTTP_PROFILE
         )
+        http_profile = str(http_profile_value) if http_profile_value is not None else "default"
         if http_profile == "forced_content_length":
             # given the fact that an announcement is just a short audio clip,
             # just send it over completely at once so we have a fixed content length
             data = b""
             async for chunk in self.get_announcement_stream(
-                announcement_url=announcement_url,
+                announcement_url=announce_data["announcement_url"],
                 output_format=audio_format,
-                use_pre_announce=use_pre_announce,
+                pre_announce=announce_data["pre_announce"],
+                pre_announce_url=announce_data["pre_announce_url"],
             ):
                 data += chunk
             return web.Response(
@@ -671,13 +680,14 @@ class StreamsController(CoreController):
         # all checks passed, start streaming!
         self.logger.debug(
             "Start serving audio stream for Announcement %s to %s",
-            announcement_url,
+            announce_data["announcement_url"],
             player.display_name,
         )
         async for chunk in self.get_announcement_stream(
-            announcement_url=announcement_url,
+            announcement_url=announce_data["announcement_url"],
             output_format=audio_format,
-            use_pre_announce=use_pre_announce,
+            pre_announce=announce_data["pre_announce"],
+            pre_announce_url=announce_data["pre_announce_url"],
         ):
             try:
                 await resp.write(chunk)
@@ -686,7 +696,7 @@ class StreamsController(CoreController):
 
         self.logger.debug(
             "Finished serving audio stream for Announcement %s to %s",
-            announcement_url,
+            announce_data["announcement_url"],
             player.display_name,
         )
 
@@ -708,8 +718,12 @@ class StreamsController(CoreController):
         output_format = await self.get_output_format(
             output_format_str=request.match_info["fmt"],
             player=player,
-            content_sample_rate=plugin_source.audio_format.sample_rate,
-            content_bit_depth=plugin_source.audio_format.bit_depth,
+            content_sample_rate=plugin_source.audio_format.sample_rate
+            if plugin_source.audio_format
+            else 44100,
+            content_bit_depth=plugin_source.audio_format.bit_depth
+            if plugin_source.audio_format
+            else 16,
         )
         headers = {
             **DEFAULT_STREAM_HEADERS,
@@ -724,9 +738,10 @@ class StreamsController(CoreController):
             headers=headers,
         )
         resp.content_type = f"audio/{output_format.output_format_str}"
-        http_profile: str = await self.mass.config.get_player_config_value(
+        http_profile_value = await self.mass.config.get_player_config_value(
             player_id, CONF_HTTP_PROFILE
         )
+        http_profile = str(http_profile_value) if http_profile_value is not None else "default"
         if http_profile == "forced_content_length":
             # guess content length based on duration
             resp.content_length = get_chunksize(output_format, 12 * 3600)
@@ -761,16 +776,15 @@ class StreamsController(CoreController):
     def get_announcement_url(
         self,
         player_id: str,
-        announcement_url: str,
-        use_pre_announce: bool = False,
+        announce_data: AnnounceData,
         content_type: ContentType = ContentType.MP3,
     ) -> str:
         """Get the url for the special announcement stream."""
-        self.announcements[player_id] = announcement_url
+        self.announcements[player_id] = announce_data
         # 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
+        return f"{self.base_url}/announcement/{player_id}.{content_type.value}"
 
     async def get_queue_flow_stream(
         self,
@@ -936,12 +950,13 @@ class StreamsController(CoreController):
         self,
         announcement_url: str,
         output_format: AudioFormat,
-        use_pre_announce: bool = False,
+        pre_announce: bool | str = False,
+        pre_announce_url: str = ANNOUNCE_ALERT_FILE,
     ) -> AsyncGenerator[bytes, None]:
         """Get the special announcement stream."""
         filter_params = ["loudnorm=I=-10:LRA=11:TP=-2"]
 
-        if use_pre_announce:
+        if 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
@@ -955,8 +970,8 @@ class StreamsController(CoreController):
             # So far players seem to tolerate this, but it might break some player in the future.
 
             async for chunk in get_ffmpeg_stream(
-                audio_input=ANNOUNCE_ALERT_FILE,
-                input_format=AudioFormat(content_type=ContentType.try_parse(ANNOUNCE_ALERT_FILE)),
+                audio_input=pre_announce_url,
+                input_format=AudioFormat(content_type=ContentType.try_parse(pre_announce_url)),
                 output_format=output_format,
                 filter_params=filter_params,
             ):
@@ -1091,6 +1106,9 @@ class StreamsController(CoreController):
     ) -> AsyncGenerator[bytes, None]:
         """Get the audio stream for a single queue item with crossfade to the next item."""
         queue = self.mass.player_queues.get(queue_item.queue_id)
+        if not queue:
+            raise RuntimeError(f"Queue {queue_item.queue_id} not found")
+
         streamdetails = queue_item.streamdetails
         assert streamdetails
         crossfade_duration = self.mass.config.get_raw_player_config_value(
@@ -1101,7 +1119,7 @@ class StreamsController(CoreController):
 
         self.logger.debug(
             "Start Streaming queue track: %s (%s) for queue %s - crossfade: %s",
-            queue_item.streamdetails.uri,
+            queue_item.streamdetails.uri if queue_item.streamdetails else "Unknown URI",
             queue_item.name,
             queue.display_name,
             f"{crossfade_duration} seconds",
@@ -1285,7 +1303,7 @@ class StreamsController(CoreController):
         if cache_enabled == "disabled":
             max_cache_size = 0.001
 
-        def _clean_old_files(foldersize: float):
+        def _clean_old_files(foldersize: float) -> None:
             files: list[os.DirEntry] = [x for x in os.scandir(self._audio_cache_dir) if x.is_file()]
             files.sort(key=lambda x: x.stat().st_atime)
             for _file in files:
index a244e95855c22dd5f30ae23837148c9a17dbfea7..e494e0f0c3f85051e586f5ab324f859688b15f37 100644 (file)
@@ -633,6 +633,29 @@ def percentage(part: float, whole: float) -> int:
     return int(100 * float(part) / float(whole))
 
 
+def validate_announcement_chime_url(url: str) -> bool:
+    """Validate announcement chime URL format."""
+    if not url or not url.strip():
+        return True  # Empty URL is valid
+
+    try:
+        parsed = urlparse(url.strip())
+
+        if parsed.scheme not in ("http", "https"):
+            return False
+
+        if not parsed.netloc:
+            return False
+
+        path_lower = parsed.path.lower()
+        audio_extensions = (".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac")
+
+        return any(path_lower.endswith(ext) for ext in audio_extensions)
+
+    except Exception:
+        return False
+
+
 class TaskManager:
     """
     Helper class to run many tasks at once.
index 05979cd8d45abc6e6c70fc99e904414a1c4830d8..74564da70d8c6cb8aa0965bd5b5f68cbf55e4d55 100644 (file)
@@ -79,14 +79,33 @@ from music_assistant.constants import (
     CONF_MUTE_CONTROL,
     CONF_OUTPUT_CODEC,
     CONF_POWER_CONTROL,
+    CONF_PRE_ANNOUNCE_CHIME_URL,
     CONF_SAMPLE_RATES,
     CONF_VOLUME_CONTROL,
 )
-from music_assistant.helpers.util import get_changed_dataclass_values
+from music_assistant.helpers.util import (
+    get_changed_dataclass_values,
+    validate_announcement_chime_url,
+)
 
 if TYPE_CHECKING:
     from .player_provider import PlayerProvider
 
+CONF_ENTRY_PRE_ANNOUNCE_CUSTOM_CHIME_URL = ConfigEntry(
+    key=CONF_PRE_ANNOUNCE_CHIME_URL,
+    type=ConfigEntryType.STRING,
+    label="Custom (pre)announcement chime URL",
+    description="URL to a custom audio file to play before announcements.\n"
+    "Leave empty to use the default chime.\n"
+    "Supports http:// and https:// URLs pointing to "
+    "audio files (.mp3, .wav, .flac, .ogg, .m4a, .aac).\n"
+    "Example: http://homeassistant.local:8123/local/audio/custom_chime.mp3",
+    category="announcements",
+    required=False,
+    depends_on=CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
+    depends_on_value=True,
+    validate=lambda val: validate_announcement_chime_url(cast("str", val)),
+)
 
 BASE_CONFIG_ENTRIES = [
     # config entries that are valid for all player types
@@ -98,6 +117,7 @@ BASE_CONFIG_ENTRIES = [
     CONF_ENTRY_OUTPUT_LIMITER,
     CONF_ENTRY_VOLUME_NORMALIZATION_TARGET,
     CONF_ENTRY_TTS_PRE_ANNOUNCE,
+    CONF_ENTRY_PRE_ANNOUNCE_CUSTOM_CHIME_URL,
     CONF_ENTRY_HTTP_PROFILE,
 ]
 
index c4c1eeee6d9e5563bf8f9b8c6e147e4b9df6e382..8ec1411110bd93c5e86981328697bcb383bf1802 100644 (file)
@@ -220,9 +220,10 @@ class AirPlayPlayer(Player):
             assert media.custom_data
             input_format = AIRPLAY_PCM_FORMAT
             audio_source = self.mass.streams.get_announcement_stream(
-                media.custom_data["url"],
+                media.custom_data["announcement_url"],
                 output_format=AIRPLAY_PCM_FORMAT,
-                use_pre_announce=media.custom_data["use_pre_announce"],
+                pre_announce=media.custom_data["pre_announce"],
+                pre_announce_url=media.custom_data["pre_announce_url"],
             )
         elif media.media_type == MediaType.PLUGIN_SOURCE:
             # special case: plugin source stream
index 0cbeabd179e0abe96bd614f95fc07b7d1b4b4a0d..2c40a4f5242e06b0507f96ad71c4c78c0ea73d1f 100644 (file)
@@ -288,9 +288,10 @@ class SnapCastPlayer(Player):
         input_format = DEFAULT_SNAPCAST_FORMAT
         assert announcement.custom_data is not None  # for type checking
         audio_source = self.mass.streams.get_announcement_stream(
-            announcement.custom_data["url"],
+            announcement.custom_data["announcement_url"],
             output_format=DEFAULT_SNAPCAST_FORMAT,
-            use_pre_announce=announcement.custom_data["use_pre_announce"],
+            pre_announce=announcement.custom_data["pre_announce"],
+            pre_announce_url=announcement.custom_data["pre_announce_url"],
         )
 
         # stream the audio, wait for it to finish (play_announcement should return after the
index 1ce1699d7e27ac6cf61289ef4216e051d29755af..d16325c92c68584ee0c34b1fc727ae2d00e8eacd 100644 (file)
@@ -220,9 +220,10 @@ class SqueezelitePlayer(Player):
         if media.media_type == MediaType.ANNOUNCEMENT:
             # special case: stream announcement
             audio_source = self.mass.streams.get_announcement_stream(
-                media.custom_data["url"],
+                media.custom_data["announcement_url"],
                 output_format=master_audio_format,
-                use_pre_announce=media.custom_data["use_pre_announce"],
+                pre_announce=media.custom_data["pre_announce"],
+                pre_announce_url=media.custom_data["pre_announce_url"],
             )
         elif media.media_type == MediaType.PLUGIN_SOURCE:
             # special case: plugin source stream
index e46448476c55949f53e15b140a83acabe5078e1d..0e1d94ae54ae6914711de5ada48368df2d866aca 100644 (file)
@@ -209,9 +209,10 @@ class UniversalGroupPlayer(GroupPlayer):
         if media.media_type == MediaType.ANNOUNCEMENT and media.custom_data:
             # special case: stream announcement
             audio_source = self.mass.streams.get_announcement_stream(
-                media.custom_data["url"],
+                media.custom_data["announcement_url"],
                 output_format=UGP_FORMAT,
-                use_pre_announce=media.custom_data["use_pre_announce"],
+                pre_announce=media.custom_data["pre_announce"],
+                pre_announce_url=media.custom_data["pre_announce_url"],
             )
         elif media.media_type == MediaType.PLUGIN_SOURCE and media.custom_data:
             # special case: plugin source stream
index f110c7984376dd49ac959fd02145ec5e3c8a0ebf..b08e8b6e053b4e5380cea15f2cca31809e2fab5f 100644 (file)
@@ -25,7 +25,7 @@ dependencies = [
   "ifaddr==0.2.0",
   "mashumaro==3.16",
   "music-assistant-frontend==2.15.4",
-  "music-assistant-models==1.1.55",
+  "music-assistant-models==1.1.56",
   "mutagen==1.47.0",
   "orjson==3.11.3",
   "pillow==11.3.0",
index 4278263cf5ca63991fb74255d7f541683af36051..8150f9413ebecbd866c59b47e1ac9a4bb5270d91 100644 (file)
@@ -32,7 +32,7 @@ liblistenbrainz==0.6.0
 lyricsgenius==3.6.5
 mashumaro==3.16
 music-assistant-frontend==2.15.4
-music-assistant-models==1.1.55
+music-assistant-models==1.1.56
 mutagen==1.47.0
 orjson==3.11.3
 pillow==11.3.0