Fix various issues with Sonos and AirPlay playback (#2543)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 23 Oct 2025 23:45:44 +0000 (01:45 +0200)
committerGitHub <noreply@github.com>
Thu, 23 Oct 2025 23:45:44 +0000 (01:45 +0200)
14 files changed:
music_assistant/constants.py
music_assistant/controllers/media/podcasts.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/streams.py
music_assistant/helpers/upnp.py
music_assistant/providers/airplay/bin/cliraop-linux-aarch64
music_assistant/providers/airplay/bin/cliraop-linux-x86_64
music_assistant/providers/airplay/bin/cliraop-macos-arm64
music_assistant/providers/airplay/player.py
music_assistant/providers/airplay/raop.py
music_assistant/providers/sonos/player.py
music_assistant/providers/sonos/provider.py
music_assistant/providers/sonos_s1/player.py
music_assistant/providers/sonos_s1/provider.py

index bf7d815e257714d34e0521915a7aff07d98fcdcb..9cfaa5618a6ca80f15a0d148d49e8dcdfd35337c 100644 (file)
@@ -580,6 +580,9 @@ CONF_ENTRY_HTTP_PROFILE_DEFAULT_1 = ConfigEntry.from_dict(
 CONF_ENTRY_HTTP_PROFILE_DEFAULT_2 = ConfigEntry.from_dict(
     {**CONF_ENTRY_HTTP_PROFILE.to_dict(), "default_value": "no_content_length"}
 )
+CONF_ENTRY_HTTP_PROFILE_DEFAULT_3 = ConfigEntry.from_dict(
+    {**CONF_ENTRY_HTTP_PROFILE.to_dict(), "default_value": "forced_content_length"}
+)
 
 CONF_ENTRY_HTTP_PROFILE_FORCED_1 = ConfigEntry.from_dict(
     {**CONF_ENTRY_HTTP_PROFILE_DEFAULT_1.to_dict(), "hidden": True}
index 4a7aea7dcde8f944297e9e557013a5c411841639..e81bf0d9fb43c1b3de0b4da32373af4a40e3b1bc 100644 (file)
@@ -154,7 +154,7 @@ class PodcastsController(MediaControllerBase[Podcast]):
                 "metadata": serialize_to_json(item.metadata),
                 "external_ids": serialize_to_json(item.external_ids),
                 "publisher": item.publisher,
-                "total_episodes": item.total_episodes,
+                "total_episodes": item.total_episodes or 0,
                 "search_name": create_safe_string(item.name, True, True),
                 "search_sort_name": create_safe_string(item.sort_name, True, True),
             },
@@ -186,7 +186,7 @@ class PodcastsController(MediaControllerBase[Podcast]):
                     update.external_ids if overwrite else cur_item.external_ids
                 ),
                 "publisher": cur_item.publisher or update.publisher,
-                "total_episodes": cur_item.total_episodes or update.total_episodes,
+                "total_episodes": cur_item.total_episodes or update.total_episodes or 0,
                 "search_name": create_safe_string(name, True, True),
                 "search_sort_name": create_safe_string(sort_name, True, True),
             },
index a634f8f271f0d5fe2102427f171a0eb7cbbee156..21efbf00f7c06b66e75eb50d609040f7385f774a 100644 (file)
@@ -1956,7 +1956,7 @@ class PlayerQueuesController(CoreController):
         if player.current_media.source_id == queue_id and player.current_media.queue_item_id:
             return player.current_media.queue_item_id
         # special case for sonos players
-        if player.current_media.uri.startswith(f"mass:{queue_id}"):
+        if player.current_media.uri and player.current_media.uri.startswith(f"mass:{queue_id}"):
             if player.current_media.queue_item_id:
                 return player.current_media.queue_item_id
             return player.current_media.uri.split(":")[-1]
index 4633d2bb52f6ee4e9e451f1b6dbe880641b9b0c8..d99c622770b227e1fda82db0b711f64ba0e150ab 100644 (file)
@@ -173,7 +173,7 @@ class StreamsController(CoreController):
             ConfigEntry(
                 key=CONF_VOLUME_NORMALIZATION_RADIO,
                 type=ConfigEntryType.STRING,
-                default_value=VolumeNormalizationMode.FALLBACK_DYNAMIC,
+                default_value=VolumeNormalizationMode.FALLBACK_FIXED_GAIN,
                 label="Volume normalization method for radio streams",
                 options=[
                     ConfigValueOption(x.value.replace("_", " ").title(), x.value)
index c49b0078d6300b8f1eec27fd7f15f9ec33ce5416..0dcd6b52a30fff29e2087ee23cfbf77324111f0d 100644 (file)
@@ -22,9 +22,9 @@ def _get_soap_action(command: str) -> str:
     return f"urn:schemas-upnp-org:service:AVTransport:1#{command}"
 
 
-def _get_body(command: str, arguments: str = "") -> str:
+def _get_body(command: str, arguments: str = "", service: str = "AVTransport") -> str:
     return (
-        f'<u:{command} xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">'
+        f'<u:{command} xmlns:u="urn:schemas-upnp-org:service:{service}:1">'
         r"<InstanceID>0</InstanceID>"
         f"{arguments}"
         f"</u:{command}>"
@@ -98,6 +98,12 @@ def get_xml_soap_set_url(player_media: PlayerMedia) -> tuple[str, str]:
     return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
 
 
+def get_xml_soap_remove_all_tracks() -> tuple[str, str]:
+    """Get UPnP xml and soap for RemoveAllTracksFromQueue."""
+    command = "RemoveAllTracksFromQueue"
+    return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
 def get_xml_soap_set_next_url(player_media: PlayerMedia) -> tuple[str, str]:
     """Get UPnP xml and soap for SetNextAVTransportURI."""
     metadata = create_didl_metadata_str(player_media)
@@ -108,6 +114,53 @@ def get_xml_soap_set_next_url(player_media: PlayerMedia) -> tuple[str, str]:
     return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
 
 
+# RemoveTrackFromQueue
+def get_xml_soap_remove_track(object_id: str) -> tuple[str, str]:
+    """Get UPnP xml and soap for RemoveTrackFromQueue."""
+    command = "RemoveTrackFromQueue"
+    arguments = f"<ObjectID>{object_id}</ObjectID>"
+    return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
+
+
+# AddURIToQueue
+def get_xml_soap_add_uri_to_queue(player_media: PlayerMedia) -> tuple[str, str]:
+    """Get UPnP xml and soap for AddURIToQueue."""
+    metadata = create_didl_metadata_str(player_media)
+    command = "AddURIToQueue"
+    arguments = (
+        f"<EnqueuedURI>{player_media.uri}</EnqueuedURI>"
+        f"<EnqueuedURIMetaData>{metadata}</EnqueuedURIMetaData>"
+        "<DesiredFirstTrackNumberEnqueued>1</DesiredFirstTrackNumberEnqueued>"
+        "<EnqueueAsNext>0</EnqueueAsNext>"
+    )
+    return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
+
+
+# CreateSavedQueue
+def get_xml_soap_create_saved_queue(queue_name: str, player_media: PlayerMedia) -> tuple[str, str]:
+    """Get UPnP xml and soap for CreateSavedQueue."""
+    command = "CreateSavedQueue"
+    metadata = create_didl_metadata_str(player_media)
+    arguments = (
+        f"<Title>{xmlescape(queue_name)}</Title>"
+        f"<EnqueuedURI>{player_media.uri}</EnqueuedURI>"
+        f"<EnqueuedURIMetaData>{metadata}</EnqueuedURIMetaData>"
+    )
+    return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
+
+
+# CreateQueue
+def get_xml_soap_create_queue() -> tuple[str, str]:
+    """Get UPnP xml and soap for CreateQueue."""
+    command = "CreateQueue"
+    arguments = (
+        "<QueueOwnerID>mass</QueueOwnerID>"
+        "<QueueOwnerContext>mass</QueueOwnerContext>"
+        "<QueuePolicy>0</QueuePolicy>"
+    )
+    return _get_xml(_get_body(command, arguments, "Queue")), _get_soap_action(command)
+
+
 # DIDL-LITE
 def create_didl_metadata(media: PlayerMedia) -> str:
     """Create DIDL metadata string from url and PlayerMedia."""
index 21410d3fc0cd268b7d6c6be6bbb6eb6b5635e411..2266b5dab21dd872069929ed4120d154f9b3146b 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 differ
index 95424e6f8fd0614787d1e5d8fd88e93820d20eb1..0b6faf9ef2e44d7c586d8cf3f1a16cd1772e1833 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 and b/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 differ
index 0424e6533fbe2f2ef92a2eecde331e787d9ad981..c62298b89e65493f20329470e4ed3e19b575a9a5 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliraop-macos-arm64 and b/music_assistant/providers/airplay/bin/cliraop-macos-arm64 differ
index f581c6d53f463aa8ba809bb5219ac8bcfe2426c8..8f73efde8b1cbc3af09b9810aaa30890ddaa47e9 100644 (file)
@@ -387,7 +387,9 @@ class AirPlayPlayer(Player):
         self.discovery_info = discovery_info
         cur_address = self.address
         new_address = get_primary_ip_address_from_zeroconf(discovery_info)
-        assert new_address  # should always be set, but guard against None
+        if new_address is None:
+            # should always be set, but guard against None
+            return
         if cur_address != new_address:
             self.logger.debug("Address updated from %s to %s", cur_address, new_address)
             self.address = cur_address
index 36f2486baeeb57be1204183f8e1c8ed2956f835f..7c26662b7f86123a68c8f832b38f56252d7a9c34 100644 (file)
@@ -4,8 +4,6 @@ from __future__ import annotations
 
 import asyncio
 import logging
-import os
-import platform
 import time
 from collections.abc import AsyncGenerator
 from contextlib import suppress
@@ -287,8 +285,6 @@ class RaopStream:
             "-",
         ]
         self._cliraop_proc = AsyncProcess(cliraop_args, stdin=True, stderr=True, name="cliraop")
-        if platform.system() == "Darwin":
-            os.environ["DYLD_LIBRARY_PATH"] = "/usr/local/lib"
         await self._cliraop_proc.start()
         # read first 20 lines of stderr to get the initial status
         for _ in range(20):
index 3486a38c9eef51409090d9895db946d687015021..fca3869f681c3c772a705ba323515846eaedf70d 100644 (file)
@@ -11,7 +11,9 @@ from __future__ import annotations
 
 import asyncio
 import time
+from collections import deque
 from copy import deepcopy
+from dataclasses import dataclass, field
 from typing import TYPE_CHECKING
 
 from aiohttp import ClientConnectorError
@@ -32,13 +34,12 @@ from music_assistant_models.errors import PlayerCommandFailed
 from music_assistant_models.player import PlayerMedia
 
 from music_assistant.constants import (
-    CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
-    CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
+    CONF_ENTRY_HTTP_PROFILE_DEFAULT_1,
     CONF_ENTRY_OUTPUT_CODEC,
     create_sample_rates_config_entry,
 )
 from music_assistant.helpers.tags import async_parse_tags
-from music_assistant.helpers.upnp import get_xml_soap_set_url
+from music_assistant.helpers.upnp import get_xml_soap_set_next_url, get_xml_soap_set_url
 from music_assistant.models.player import Player
 from music_assistant.providers.sonos.const import (
     CONF_AIRPLAY_MODE,
@@ -70,6 +71,54 @@ SUPPORTED_FEATURES = {
 }
 
 
+@dataclass
+class SonosQueue:
+    """Simple representation of a Sonos (cloud) Queue."""
+
+    _items: deque[PlayerMedia] = field(default_factory=lambda: deque(maxlen=5))
+    last_updated: float = time.time()
+
+    @property
+    def items(self) -> list[PlayerMedia]:
+        """Return the current sonos queue items."""
+        return list(self._items)
+
+    def set_items(self, new_items: list[PlayerMedia]) -> None:
+        """Set the sonos queue items."""
+        self._items = deque(new_items, maxlen=5)
+        self.last_updated = time.time()
+
+    def enqueue_next(self, current_item_id: str | None, next_item: PlayerMedia) -> None:
+        """Enqueue the next item in the sonos queue."""
+        if current_item_id is None:
+            self._items.append(next_item)
+        else:
+            current_index = next(
+                (i for i, item in enumerate(self._items) if item.queue_item_id == current_item_id),
+                None,
+            )
+            if current_index is None:
+                raise IndexError("Current item id not found in sonos queue.")
+            prev_items = self.items[: current_index + 1]
+            # because the next item could potentially have been overwritten,
+            # we rebuild the deque here
+            self._items = deque([*prev_items, next_item], maxlen=5)
+        self.last_updated = time.time()
+
+    def get_queue_from_item(self, item_id: str) -> list[PlayerMedia]:
+        """Return the sonos queue starting from the given item id."""
+        current_index = next(
+            (i for i, item in enumerate(self._items) if item.queue_item_id == item_id), None
+        )
+        if current_index is None:
+            raise IndexError("Current item id not found in sonos queue.")
+        return self.items[current_index:]
+
+    def is_item_in_queue(self, item_id: str) -> bool:
+        """Check if the given item id is in the sonos queue."""
+        return any(item.queue_item_id == item_id for item in self._items)
+
+
 class SonosPlayer(Player):
     """Holds the details of the (discovered) Sonosplayer."""
 
@@ -89,6 +138,7 @@ class SonosPlayer(Player):
         # We can do some smart stuff if we link them together where possible.
         # The player we can just guess from the sonos player id (mac address).
         self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}"
+        self.sonos_queue: SonosQueue = SonosQueue()
 
     @property
     def airplay_mode_enabled(self) -> bool:
@@ -177,21 +227,6 @@ class SonosPlayer(Player):
                 self.airplay_player_id,
             )
         )
-        # register callback for playerqueue state changes
-        # note we don't filter on the player_id here because we also need to catch
-        # events from group players
-        self._on_unload_callbacks.append(
-            self.mass.subscribe(
-                self._on_mass_queue_items_event,
-                EventType.QUEUE_ITEMS_UPDATED,
-            )
-        )
-        self._on_unload_callbacks.append(
-            self.mass.subscribe(
-                self._on_mass_queue_event,
-                (EventType.QUEUE_UPDATED, EventType.QUEUE_ITEMS_UPDATED),
-            )
-        )
 
     async def get_config_entries(
         self,
@@ -200,8 +235,7 @@ class SonosPlayer(Player):
         base_entries = [
             *await super().get_config_entries(),
             CONF_ENTRY_OUTPUT_CODEC,
-            CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
-            CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
+            CONF_ENTRY_HTTP_PROFILE_DEFAULT_1,
             create_sample_rates_config_entry(
                 # set safe max bit depth to 16 bits because the older Sonos players
                 # do not support 24 bit playback (e.g. Play:1)
@@ -373,6 +407,7 @@ class SonosPlayer(Player):
 
         :param media: Details of the item that needs to be played on the player.
         """
+        self.sonos_queue.set_items([media])
         self._attr_current_media = deepcopy(media)
 
         if self.client.player.is_passive:
@@ -394,22 +429,23 @@ class SonosPlayer(Player):
             # Regular Queue item playback
             # create a sonos cloud queue and load it
             cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/"
-            mass_queue = self.mass.player_queues.get(media.source_id)
+            track_data = self.provider._parse_sonos_queue_item(media)
             await self.client.player.group.play_cloud_queue(
                 cloud_queue_url,
-                http_authorization=media.source_id,
                 item_id=media.queue_item_id,
-                queue_version=str(int(mass_queue.items_last_updated)),
+                track_metadata=track_data["track"],
             )
-            self.mass.call_later(5, self.sync_play_modes, media.source_id)
             return
 
         # All other playback types
-        # play a single uri/url
-        # note that this most probably will only work for (long running) radio streams
-        if not media.duration:
-            # enforce mp3 here because Sonos really does not support FLAC streams without duration
-            media.uri = media.uri.replace(".flac", ".mp3")
+        if media.duration:
+            # use legacy playback for files with known duration
+            await self._play_media_legacy(media)
+            return
+
+        # play duration-less (long running) radio streams
+        # enforce AAC here because Sonos really does not support FLAC streams without duration
+        media.uri = media.uri.replace(".flac", ".aac").replace(".wav", ".aac")
         if media.source_id and media.queue_item_id:
             object_id = f"mass:{media.source_id}:{media.queue_item_id}"
         else:
@@ -418,7 +454,7 @@ class SonosPlayer(Player):
             media.uri,
             {
                 "name": media.title,
-                "type": "station",
+                "type": "track",
                 "imageUrl": media.image_url,
                 "id": {
                     "objectId": object_id,
@@ -461,6 +497,8 @@ class SonosPlayer(Player):
 
          :param media: Details of the item that needs to be enqueued on the player.
         """
+        current_item_id = self.current_media.queue_item_id if self.current_media else None
+        self.sonos_queue.enqueue_next(current_item_id, media)
         if session_id := self.client.player.group.active_session_id:
             await self.client.api.playback_session.refresh_cloud_queue(session_id)
 
@@ -777,36 +815,6 @@ class SonosPlayer(Player):
         self.update_attributes()
         self.update_state()
 
-    async def _on_mass_queue_items_event(self, event: MassEvent) -> None:
-        """Handle incoming event from linked MA playerqueue."""
-        # If the queue items changed and we have an active sonos queue,
-        # we need to inform the sonos queue to refresh the items.
-        if self._attr_active_source != event.object_id:
-            return
-        if not self.connected:
-            return
-        queue = self.mass.player_queues.get(event.object_id)
-        if not queue or queue.state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
-            return
-        if session_id := self.client.player.group.active_session_id:
-            await self.client.api.playback_session.refresh_cloud_queue(session_id)
-
-    async def _on_mass_queue_event(self, event: MassEvent) -> None:
-        """Handle incoming event from linked MA playerqueue."""
-        if self._attr_active_source != event.object_id:
-            return
-        if not self.connected:
-            return
-        if not self.client.player.is_coordinator:
-            return
-        if event.event == EventType.QUEUE_UPDATED:
-            # sync crossfade and repeat modes
-            await self.sync_play_modes(event.object_id)
-        elif event.event == EventType.QUEUE_ITEMS_UPDATED:
-            # refresh cloud queue
-            if session_id := self.client.player.group.active_session_id:
-                await self.client.api.playback_session.refresh_cloud_queue(session_id)
-
     async def sync_play_modes(self, queue_id: str) -> None:
         """Sync the play modes between MA and Sonos."""
         queue = self.mass.player_queues.get(queue_id)
@@ -870,8 +878,6 @@ class SonosPlayer(Player):
         media: PlayerMedia,
     ) -> None:
         """Handle PLAY MEDIA using the legacy upnp api."""
-        # enforce mp3 here because Sonos really does not support FLAC streams without duration
-        media.uri = media.uri.replace(".flac", ".mp3")
         xml_data, soap_action = get_xml_soap_set_url(media)
         player_ip = self.device_info.ip_address
         async with self.mass.http_session_no_ssl.post(
@@ -888,4 +894,24 @@ class SonosPlayer(Player):
                     f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
                 )
             await self.play()
-            return
+
+    async def _enqueue_next_legacy(
+        self,
+        media: PlayerMedia,
+    ) -> None:
+        """Handle enqueuing of the next (queue) item on the player using legacy upnp api."""
+        xml_data, soap_action = get_xml_soap_set_next_url(media)
+        player_ip = self.device_info.ip_address
+        async with self.mass.http_session_no_ssl.post(
+            f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control",
+            headers={
+                "SOAPACTION": soap_action,
+                "Content-Type": "text/xml; charset=utf-8",
+                "Connection": "close",
+            },
+            data=xml_data,
+        ) as resp:
+            if resp.status != 200:
+                raise PlayerCommandFailed(
+                    f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
+                )
index 6079298d61d8d3f8a40d64c7613fda0d67075146..0626f17cf994dd004e2801239e96a3dc8012ec63 100644 (file)
@@ -28,7 +28,7 @@ from .player import SonosPlayer
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import PlayerConfig
-    from music_assistant_models.queue_item import QueueItem
+    from music_assistant_models.player import PlayerMedia
     from zeroconf.asyncio import AsyncServiceInfo
 
 
@@ -152,10 +152,6 @@ class SonosPlayerProvider(PlayerProvider):
         sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
         sonos_player.device_info.ip_address = address
         await sonos_player.setup()
-        # # trigger update on all existing players to update the group status
-        # for _player in self.sonos_players.values():
-        #     if _player.player_id != player_id:
-        #         _player.on_player_event(None)
 
     async def _handle_sonos_queue_itemwindow(self, request: web.Request) -> web.Response:
         """
@@ -166,34 +162,24 @@ class SonosPlayerProvider(PlayerProvider):
         self.logger.log(VERBOSE_LOG_LEVEL, "Cloud Queue ItemWindow request: %s", request.query)
         sonos_playback_id = request.headers["X-Sonos-Playback-Id"]
         sonos_player_id = sonos_playback_id.split(":")[0]
-        queue_version = request.query.get("queueVersion")
-        context_version = request.query.get("contextVersion")
-        if not (mass_queue := self.mass.player_queues.get_active_queue(sonos_player_id)):
-            return web.Response(status=501)
-        if item_id := request.query.get("itemId"):
-            cur_queue_index = self.mass.player_queues.index_by_id(mass_queue.queue_id, item_id)
-        else:
-            cur_queue_index = mass_queue.current_index
-        if cur_queue_index is None:
+        if not (sonos_player := self.mass.players.get(sonos_player_id)):
             return web.Response(status=501)
+        if TYPE_CHECKING:
+            assert isinstance(sonos_player, SonosPlayer)
+
+        context_version = request.query.get("contextVersion", "1")
+        queue_version = request.query.get(
+            "queueVersion", str(int(sonos_player.sonos_queue.last_updated))
+        )
         # because Sonos does not show our queue in the app anyways,
-        # we just return the current and 2 next items in the queue
-        cur_queue_item = self.mass.player_queues.get_item(mass_queue.queue_id, cur_queue_index)
-        queue_items = [cur_queue_item]
-        if next_queue_item := self.mass.player_queues.get_next_item(
-            mass_queue.queue_id, cur_queue_index
-        ):
-            queue_items.append(next_queue_item)
-            if next_next_queue_item := self.mass.player_queues.get_next_item(
-                mass_queue.queue_id, next_queue_item.queue_item_id
-            ):
-                queue_items.append(next_next_queue_item)
+        # we just return the previous, current and next item in the queue
+        items = list(sonos_player.sonos_queue.items)
         result = {
             "includesBeginningOfQueue": False,
-            "includesEndOfQueue": True,
+            "includesEndOfQueue": False,
             "contextVersion": context_version,
             "queueVersion": queue_version,
-            "items": [await self._parse_sonos_queue_item(item) for item in queue_items],
+            "items": [self._parse_sonos_queue_item(x) for x in items],
         }
         return web.json_response(result)
 
@@ -206,12 +192,16 @@ class SonosPlayerProvider(PlayerProvider):
         self.logger.log(VERBOSE_LOG_LEVEL, "Cloud Queue Version request: %s", request.query)
         sonos_playback_id = request.headers["X-Sonos-Playback-Id"]
         sonos_player_id = sonos_playback_id.split(":")[0]
-        if not (self.mass.players.get(sonos_player_id)):
+        if not (sonos_player := self.mass.players.get(sonos_player_id)):
             return web.Response(status=501)
-        mass_queue = self.mass.player_queues.get_active_queue(sonos_player_id)
+        if TYPE_CHECKING:
+            assert isinstance(sonos_player, SonosPlayer)
+
         context_version = request.query.get("contextVersion") or "1"
-        queue_version = str(int(mass_queue.items_last_updated)) if mass_queue else "0"
-        result = {"contextVersion": context_version, "queueVersion": queue_version}
+        result = {
+            "contextVersion": context_version,
+            "queueVersion": str(int(sonos_player.sonos_queue.last_updated)),
+        }
         return web.json_response(result)
 
     async def _handle_sonos_queue_context(self, request: web.Request) -> web.Response:
@@ -223,21 +213,24 @@ class SonosPlayerProvider(PlayerProvider):
         self.logger.log(VERBOSE_LOG_LEVEL, "Cloud Queue Context request: %s", request.query)
         sonos_playback_id = request.headers["X-Sonos-Playback-Id"]
         sonos_player_id = sonos_playback_id.split(":")[0]
-        if not (mass_queue := self.mass.player_queues.get_active_queue(sonos_player_id)):
-            return web.Response(status=501)
-        if not (self.mass.players.get(sonos_player_id)):
+        if not (sonos_player := self.mass.players.get(sonos_player_id)):
             return web.Response(status=501)
+        if TYPE_CHECKING:
+            assert isinstance(sonos_player, SonosPlayer)
+
         result = {
             "contextVersion": "1",
-            "queueVersion": str(int(mass_queue.items_last_updated)),
+            "queueVersion": str(int(sonos_player.sonos_queue.last_updated)),
             "container": {
-                "type": "playlist",
+                "type": "trackList",
                 "name": "Music Assistant",
                 "imageUrl": MASS_LOGO_ONLINE,
                 "service": {"name": "Music Assistant", "id": "mass"},
                 "id": {
                     "serviceId": "mass",
-                    "objectId": f"mass:{mass_queue.queue_id}",
+                    "objectId": f"mass:{sonos_player.sonos_queue.items[-1].source_id}"
+                    if sonos_player.sonos_queue.items
+                    else "mass:unknown",
                     "accountId": "",
                 },
             },
@@ -248,13 +241,13 @@ class SonosPlayerProvider(PlayerProvider):
             },
             "playbackPolicies": {
                 "canSkip": True,
-                "limitedSkips": False,
-                "canSkipToItem": False,  # unsure
+                "limitedSkips": True,
+                "canSkipToItem": True,  # unsure
                 "canSkipBack": True,
                 # seek needs to be disabled because we dont properly support range requests
                 "canSeek": False,
                 "canRepeat": False,  # handled by MA queue controller
-                "canRepeatOne": True,  # synced from MA queue controller
+                "canRepeatOne": False,  # synced from MA queue controller
                 "canCrossfade": False,  # handled by MA queue controller
                 "canShuffle": False,  # handled by MA queue controller
             },
@@ -271,74 +264,44 @@ class SonosPlayerProvider(PlayerProvider):
         json_body = await request.json()
         sonos_playback_id = request.headers["X-Sonos-Playback-Id"]
         sonos_player_id = sonos_playback_id.split(":")[0]
-        if not (mass_player := self.mass.players.get(sonos_player_id)):
-            return web.Response(status=501)
-        if not (self.mass.players.get(sonos_player_id)):
+        if not (sonos_player := self.mass.players.get(sonos_player_id)):
             return web.Response(status=501)
+        if TYPE_CHECKING:
+            assert isinstance(sonos_player, SonosPlayer)
         for item in json_body["items"]:
             if item["type"] != "update":
                 continue
             if "positionMillis" not in item:
                 continue
-            if mass_player.current_media and mass_player.current_media.queue_item_id == item["id"]:
-                mass_player.update_elapsed_time(item["positionMillis"] / 1000)
+            if (
+                sonos_player.current_media
+                and sonos_player.current_media.queue_item_id == item["id"]
+            ):
+                sonos_player.update_elapsed_time(item["positionMillis"] / 1000)
             break
         return web.Response(status=204)
 
-    async def _parse_sonos_queue_item(self, queue_item: QueueItem) -> dict[str, Any]:
-        """Parse a MusicAssistant QueueItem to a Sonos Media (queue) object."""
-        queue = self.mass.player_queues.get(queue_item.queue_id)
-        assert queue  # for type checking
-        stream_url = await self.mass.streams.resolve_stream_url(queue.session_id, queue_item)
-        if streamdetails := queue_item.streamdetails:
-            duration = streamdetails.duration or queue_item.duration
-            if duration and streamdetails.seek_position:
-                duration -= streamdetails.seek_position
-        else:
-            duration = queue_item.duration
-
+    def _parse_sonos_queue_item(self, media: PlayerMedia) -> dict[str, Any]:
+        """Parse MusicAssistant PlayerMedia to a Sonos Media (queue) object."""
         return {
-            "id": queue_item.queue_item_id,
-            "deleted": not queue_item.available,
-            "policies": {
-                "canCrossfade": False,  # crossfading is handled by our streams controller
-                "canSkip": True,
-                "canSkipBack": True,
-                "canSkipToItem": True,
-                # seek needs to be disabled because we dont properly support range requests
-                "canSeek": False,
-                "canRepeat": True,
-                "canRepeatOne": True,
-                "canShuffle": True,
-            },
+            "id": media.queue_item_id or media.uri,
             "track": {
                 "type": "track",
-                "mediaUrl": stream_url,
-                "contentType": f"audio/{stream_url.split('.')[-1]}",
-                "service": {
-                    "name": "Music Assistant",
-                    "id": "8",
-                    "accountId": "",
-                    "objectId": queue_item.queue_item_id,
-                },
-                "name": queue_item.media_item.name if queue_item.media_item else queue_item.name,
-                "imageUrl": self.mass.metadata.get_image_url(
-                    queue_item.image, prefer_proxy=False, image_format="jpeg"
-                )
-                if queue_item.image
-                else None,
-                "durationMillis": duration * 1000 if duration else None,
+                "mediaUrl": media.uri,
+                "contentType": f"audio/{media.uri.split('.')[-1]}",
+                "service": {"name": "Music Assistant", "id": "mass"},
+                "name": media.title,
+                "imageUrl": media.image_url,
+                "durationMillis": media.duration * 1000 if media.duration else 0,
                 "artist": {
-                    "name": artist_str,
+                    "name": media.artist,
                 }
-                if queue_item.media_item
-                and (artist_str := getattr(queue_item.media_item, "artist_str", None))
+                if media.artist
                 else None,
                 "album": {
-                    "name": album.name,
+                    "name": media.album,
                 }
-                if queue_item.media_item
-                and (album := getattr(queue_item.media_item, "album", None))
+                if media.album
                 else None,
             },
         }
index 3d19ff1ac4e0551c4965de6c1b974b74b7ecc60a..2c1f5a512040c98390039225649d3334b9d4b2ba 100644 (file)
@@ -17,10 +17,7 @@ from typing import TYPE_CHECKING, Any, cast
 from music_assistant_models.enums import PlaybackState, PlayerState, PlayerType
 from music_assistant_models.errors import PlayerCommandFailed
 from soco import SoCoException
-from soco.core import (
-    MUSIC_SRC_RADIO,
-    SoCo,
-)
+from soco.core import MUSIC_SRC_RADIO, SoCo
 from soco.data_structures import DidlAudioBroadcast
 
 from music_assistant.constants import (
@@ -208,8 +205,15 @@ class SonosPlayer(Player):
             )
             raise PlayerCommandFailed(msg)
 
+        if not media.duration:
+            # Sonos really does not like FLAC streams without duration
+            media.uri = media.uri.replace(".flac", ".mp3")
+
         didl_metadata = create_didl_metadata(media)
-        await asyncio.to_thread(self.soco.play_uri, media.uri, meta=didl_metadata)
+
+        await asyncio.to_thread(
+            self.soco.play_uri, media.uri, meta=didl_metadata, force_radio=not media.duration
+        )
         self.mass.call_later(2, self.poll)
 
     async def enqueue_next_media(self, media: PlayerMedia) -> None:
@@ -225,7 +229,13 @@ class SonosPlayer(Player):
         didl_metadata = create_didl_metadata(media)
 
         def add_to_queue() -> None:
-            self.soco.add_uri_to_queue(media.uri, didl_metadata)
+            self.soco.avTransport.SetNextAVTransportURI(
+                [
+                    ("InstanceID", 0),
+                    ("NextURI", media.uri),
+                    ("NextURIMetaData", didl_metadata),
+                ]
+            )
 
         await asyncio.to_thread(add_to_queue)
         self.mass.call_later(2, self.poll)
index 654993a06417f6f14b834d0c88e38838ad9d4f04..91d81c2a3b2605400db898e5258c0b17f207ebb3 100644 (file)
@@ -92,7 +92,7 @@ class SonosPlayerProvider(PlayerProvider):
         if not (household_id := self.config.get_value(CONF_HOUSEHOLD_ID)):
             household_id = "Sonos"
 
-        async def do_discover() -> None:
+        def do_discover() -> None:
             """Run discovery and add players in executor thread."""
             self._discovery_running = True
             try:
@@ -107,7 +107,9 @@ class SonosPlayerProvider(PlayerProvider):
                 # process new players
                 for soco in discovered_devices:
                     try:
-                        await self._setup_player(soco)
+                        asyncio.run_coroutine_threadsafe(
+                            self._setup_player(soco), self.mass.loop
+                        ).result()
                     except RequestException as err:
                         # player is offline
                         self.logger.debug("Failed to add SonosPlayer %s: %s", soco, err)
@@ -121,7 +123,7 @@ class SonosPlayerProvider(PlayerProvider):
             finally:
                 self._discovery_running = False
 
-        await do_discover()
+        await asyncio.to_thread(do_discover)
 
         def reschedule() -> None:
             self._discovery_reschedule_timer = None