From: Marcel van der Veldt Date: Thu, 23 Oct 2025 23:45:44 +0000 (+0200) Subject: Fix various issues with Sonos and AirPlay playback (#2543) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=7287753f7e052adff9c5e8515f2fd640f750553c;p=music-assistant-server.git Fix various issues with Sonos and AirPlay playback (#2543) --- diff --git a/music_assistant/constants.py b/music_assistant/constants.py index bf7d815e..9cfaa561 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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} diff --git a/music_assistant/controllers/media/podcasts.py b/music_assistant/controllers/media/podcasts.py index 4a7aea7d..e81bf0d9 100644 --- a/music_assistant/controllers/media/podcasts.py +++ b/music_assistant/controllers/media/podcasts.py @@ -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), }, diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index a634f8f2..21efbf00 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -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] diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index 4633d2bb..d99c6227 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -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) diff --git a/music_assistant/helpers/upnp.py b/music_assistant/helpers/upnp.py index c49b0078..0dcd6b52 100644 --- a/music_assistant/helpers/upnp.py +++ b/music_assistant/helpers/upnp.py @@ -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'' + f'' r"0" f"{arguments}" f"" @@ -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"{object_id}" + 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"{player_media.uri}" + f"{metadata}" + "1" + "0" + ) + 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"{xmlescape(queue_name)}" + f"{player_media.uri}" + f"{metadata}" + ) + 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 = ( + "mass" + "mass" + "0" + ) + 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.""" diff --git a/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 b/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 index 21410d3f..2266b5da 100755 Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/providers/airplay/bin/cliraop-linux-aarch64 differ diff --git a/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 b/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 index 95424e6f..0b6faf9e 100755 Binary files a/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 and b/music_assistant/providers/airplay/bin/cliraop-linux-x86_64 differ diff --git a/music_assistant/providers/airplay/bin/cliraop-macos-arm64 b/music_assistant/providers/airplay/bin/cliraop-macos-arm64 index 0424e653..c62298b8 100755 Binary files a/music_assistant/providers/airplay/bin/cliraop-macos-arm64 and b/music_assistant/providers/airplay/bin/cliraop-macos-arm64 differ diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index f581c6d5..8f73efde 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -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 diff --git a/music_assistant/providers/airplay/raop.py b/music_assistant/providers/airplay/raop.py index 36f2486b..7c26662b 100644 --- a/music_assistant/providers/airplay/raop.py +++ b/music_assistant/providers/airplay/raop.py @@ -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): diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index 3486a38c..fca3869f 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -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}" + ) diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index 6079298d..0626f17c 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -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, }, } diff --git a/music_assistant/providers/sonos_s1/player.py b/music_assistant/providers/sonos_s1/player.py index 3d19ff1a..2c1f5a51 100644 --- a/music_assistant/providers/sonos_s1/player.py +++ b/music_assistant/providers/sonos_s1/player.py @@ -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) diff --git a/music_assistant/providers/sonos_s1/provider.py b/music_assistant/providers/sonos_s1/provider.py index 654993a0..91d81c2a 100644 --- a/music_assistant/providers/sonos_s1/provider.py +++ b/music_assistant/providers/sonos_s1/provider.py @@ -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