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}
"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),
},
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),
},
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]
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)
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}>"
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)
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."""
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
import asyncio
import logging
-import os
-import platform
import time
from collections.abc import AsyncGenerator
from contextlib import suppress
"-",
]
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):
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
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,
}
+@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."""
# 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:
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,
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)
: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:
# 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:
media.uri,
{
"name": media.title,
- "type": "station",
+ "type": "track",
"imageUrl": media.image_url,
"id": {
"objectId": object_id,
: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)
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)
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(
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}"
+ )
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
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:
"""
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)
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:
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": "",
},
},
},
"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
},
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,
},
}
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 (
)
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:
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)
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:
# 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)
finally:
self._discovery_running = False
- await do_discover()
+ await asyncio.to_thread(do_discover)
def reschedule() -> None:
self._discovery_reschedule_timer = None