From 69101ba68eaecdd80a92088aef4fbd1457c28ddb Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 24 Aug 2024 00:53:31 +0200 Subject: [PATCH] New Sonos S2 player provider (#1600) --- .vscode/settings.json | 3 +- music_assistant/common/models/player_queue.py | 1 + .../server/controllers/metadata.py | 5 +- .../server/controllers/player_queues.py | 2 +- music_assistant/server/controllers/streams.py | 10 +- .../server/providers/airplay/__init__.py | 4 +- .../server/providers/sonos/__init__.py | 921 +++++++++++------- .../server/providers/sonos/manifest.json | 13 +- .../server/providers/sonos_s1/__init__.py | 474 +++++++++ .../providers/{sonos => sonos_s1}/helpers.py | 0 .../server/providers/sonos_s1/icon.png | Bin 0 -> 17303 bytes .../server/providers/sonos_s1/icon.svg | 11 + .../server/providers/sonos_s1/manifest.json | 16 + .../providers/{sonos => sonos_s1}/player.py | 69 +- .../server/providers/soundcloud/icon.svg | 1 - .../server/providers/spotify/__init__.py | 2 +- requirements_all.txt | 2 +- 17 files changed, 1113 insertions(+), 421 deletions(-) create mode 100644 music_assistant/server/providers/sonos_s1/__init__.py rename music_assistant/server/providers/{sonos => sonos_s1}/helpers.py (100%) create mode 100644 music_assistant/server/providers/sonos_s1/icon.png create mode 100644 music_assistant/server/providers/sonos_s1/icon.svg create mode 100644 music_assistant/server/providers/sonos_s1/manifest.json rename music_assistant/server/providers/{sonos => sonos_s1}/player.py (95%) delete mode 100644 music_assistant/server/providers/soundcloud/icon.svg diff --git a/.vscode/settings.json b/.vscode/settings.json index dcfe9b833..8e26bf7cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,5 +9,6 @@ "editor.defaultFormatter": "charliermarsh.ruff", "[github-actions-workflow]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "python.analysis.extraPaths": ["../aiosonos/"] } diff --git a/music_assistant/common/models/player_queue.py b/music_assistant/common/models/player_queue.py index e2d6c881f..97a2cd6ec 100644 --- a/music_assistant/common/models/player_queue.py +++ b/music_assistant/common/models/player_queue.py @@ -42,6 +42,7 @@ class PlayerQueue(DataClassDictMixin): flow_mode_start_index: int = 0 stream_finished: bool | None = None end_of_track_reached: bool | None = None + queue_items_last_updated: float = time.time() @property def corrected_elapsed_time(self) -> float: diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py index eb1cad475..8ce6c5d03 100644 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -336,7 +336,10 @@ def get_image_url( # return imageproxy url for images that need to be resolved # the original path is double encoded encoded_url = urllib.parse.quote(urllib.parse.quote(image.path)) - return f"{self.mass.streams.base_url}/imageproxy?path={encoded_url}&provider={image.provider}&size={size}&fmt={image_format}" # noqa: E501 + return ( + f"{self.mass.streams.base_url}/imageproxy?path={encoded_url}" + f"&provider={image.provider}&size={size}&fmt={image_format}" + ) return image.path async def get_thumbnail( diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index 1889dbf68..025202aec 100644 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -1045,6 +1045,7 @@ def signal_update(self, queue_id: str, items_changed: bool = False) -> None: """Signal state changed of given queue.""" queue = self._queues[queue_id] if items_changed: + queue.queue_items_last_updated = time.time() self.mass.signal_event(EventType.QUEUE_ITEMS_UPDATED, object_id=queue_id, data=queue) # save items in cache self.mass.create_task( @@ -1055,7 +1056,6 @@ def signal_update(self, queue_id: str, items_changed: bool = False) -> None: base_key=queue_id, ) ) - # always send the base event self.mass.signal_event(EventType.QUEUE_UPDATED, object_id=queue_id, data=queue) # save state diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index b7f6e4a63..914443c49 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -50,6 +50,7 @@ get_icy_stream, get_player_filter_params, get_silence, + get_stream_details, parse_loudnorm, strip_silence, ) @@ -252,7 +253,10 @@ async def serve_queue_item_stream(self, request: web.Request) -> web.Response: if not queue_item: raise web.HTTPNotFound(reason=f"Unknown Queue item: {queue_item_id}") if not queue_item.streamdetails: - raise web.HTTPNotFound(reason=f"No streamdetails for Queue item: {queue_item_id}") + # raise web.HTTPNotFound(reason=f"No streamdetails for Queue item: {queue_item_id}") + queue_item.streamdetails = await get_stream_details( + mass=self.mass, queue_item=queue_item + ) # work out output format/details output_format = await self._get_output_format( output_format_str=request.match_info["fmt"], @@ -390,8 +394,6 @@ async def serve_queue_flow_stream(self, request: web.Request) -> web.Response: else: title = "Music Assistant" metadata = f"StreamTitle='{title}';".encode() - if current_item and current_item.image: - metadata += f"StreamURL='{current_item.image.path}'".encode() while len(metadata) % 16 != 0: metadata += b"\x00" length = len(metadata) @@ -873,7 +875,7 @@ async def _get_output_format( if default_sample_rate in supported_sample_rates: output_sample_rate = default_sample_rate else: - output_sample_rate = min(supported_sample_rates) + output_sample_rate = max(supported_sample_rates) output_bit_depth = min(default_bit_depth, player_max_bit_depth) output_channels_str = self.mass.config.get_raw_player_config_value( player.player_id, CONF_OUTPUT_CHANNELS, "stereo" diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index a45de6823..6205f2e4a 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -430,7 +430,8 @@ async def _send_metadata(self, queue: PlayerQueue) -> None: title = stream_title # set album to radio station name album = queue.current_item.name - if media_item := queue.current_item.media_item: + elif media_item := queue.current_item.media_item: + title = media_item.name if artist_str := getattr(media_item, "artist_str", None): artist = artist_str if _album := getattr(media_item, "album", None): @@ -499,7 +500,6 @@ class AirplayProvider(PlayerProvider): cliraop_bin: str | None = None _players: dict[str, AirPlayPlayer] - _discovery_running: bool = False _dacp_server: asyncio.Server = None _dacp_info: AsyncServiceInfo = None _play_media_lock: asyncio.Lock = asyncio.Lock() diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 9dd76072e..796743ff3 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -1,23 +1,21 @@ -""" -Sonos Player provider for Music Assistant. - -Note that large parts of this code are copied over from the Home Assistant -integratioon for Sonos. -""" +"""Sonos Player provider for Music Assistant for speakers running the S2 firmware.""" from __future__ import annotations import asyncio import logging -from collections import OrderedDict -from dataclasses import dataclass, field +from time import time from typing import TYPE_CHECKING -import soco.config as soco_config -from requests.exceptions import RequestException -from soco import events_asyncio, zonegroupstate -from soco.discovery import discover -from sonos_websocket.exception import SonosWebsocketError +from aiohttp import web +from aiosonos.api.models import DiscoveryInfo as SonosDiscoveryInfo +from aiosonos.api.models import PlayBackState as SonosPlayBackState +from aiosonos.api.models import SonosCapability +from aiosonos.client import SonosLocalApiClient +from aiosonos.const import EventType as SonosEventType +from aiosonos.const import SonosEvent +from aiosonos.utils import get_discovery_info +from zeroconf import IPVersion, ServiceStateChange from music_assistant.common.models.config_entries import ( CONF_ENTRY_CROSSFADE, @@ -27,79 +25,62 @@ create_sample_rates_config_entry, ) from music_assistant.common.models.enums import ( - ConfigEntryType, PlayerFeature, + PlayerState, PlayerType, ProviderFeature, + RepeatMode, ) -from music_assistant.common.models.errors import PlayerCommandFailed, PlayerUnavailableError +from music_assistant.common.models.errors import PlayerCommandFailed from music_assistant.common.models.player import DeviceInfo, Player, PlayerMedia -from music_assistant.constants import CONF_CROSSFADE, SYNCGROUP_PREFIX, VERBOSE_LOG_LEVEL -from music_assistant.server.helpers.didl_lite import create_didl_metadata +from music_assistant.constants import ( + CONF_CROSSFADE, + MASS_LOGO_ONLINE, + SYNCGROUP_PREFIX, + VERBOSE_LOG_LEVEL, +) from music_assistant.server.helpers.util import TaskManager from music_assistant.server.models.player_provider import PlayerProvider -from .player import SonosPlayer - if TYPE_CHECKING: - from soco.core import SoCo + from zeroconf.asyncio import AsyncServiceInfo - from music_assistant.common.models.config_entries import PlayerConfig, ProviderConfig + from music_assistant.common.models.config_entries import ProviderConfig from music_assistant.common.models.provider import ProviderManifest from music_assistant.server import MusicAssistant from music_assistant.server.models import ProviderInstanceType +PLAYBACK_STATE_MAP = { + SonosPlayBackState.PLAYBACK_STATE_BUFFERING: PlayerState.PLAYING, + SonosPlayBackState.PLAYBACK_STATE_IDLE: PlayerState.IDLE, + SonosPlayBackState.PLAYBACK_STATE_PAUSED: PlayerState.PAUSED, + SonosPlayBackState.PLAYBACK_STATE_PLAYING: PlayerState.PLAYING, +} -PLAYER_FEATURES = ( +PLAYER_FEATURES_BASE = { PlayerFeature.SYNC, PlayerFeature.VOLUME_MUTE, - PlayerFeature.VOLUME_SET, PlayerFeature.ENQUEUE_NEXT, PlayerFeature.PAUSE, - PlayerFeature.PLAY_ANNOUNCEMENT, -) +} -CONF_NETWORK_SCAN = "network_scan" -SUBSCRIPTION_TIMEOUT = 1200 -ZGS_SUBSCRIPTION_TIMEOUT = 2 - - -S2_MODELS = ( - "Sonos Roam", - "Sonos Arc", - "Sonos Beam", - "Sonos Five", - "Sonos Move", - "Sonos One SL", - "Sonos Port", - "Sonos Amp", - "SYMFONISK Bookshelf", - "SYMFONISK Table Lamp", - "Sonos Era 100", - "Sonos Era 300", -) - -CONF_ENTRY_SAMPLE_RATES_SONOS_S2 = create_sample_rates_config_entry(48000, 24, 48000, 24, True) -CONF_ENTRY_SAMPLE_RATES_SONOS_S1 = create_sample_rates_config_entry(48000, 16, 48000, 16, True) +SOURCE_LINE_IN = "line_in" +SOURCE_AIRPLAY = "airplay" +SOURCE_SPOTIFY = "spotify" +SOURCE_UNKNOWN = "unknown" +SOURCE_RADIO = "radio" async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" - # set event listener port to something other than 1400 - # to allow coextistence with HA on the same host - soco_config.EVENT_LISTENER_PORT = 1700 - soco_config.EVENTS_MODULE = events_asyncio - soco_config.REQUEST_TIMEOUT = 9.5 - soco_config.ZGT_EVENT_FALLBACK = False - zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT prov = SonosPlayerProvider(mass, manifest, config) - # set-up soco logging + # set-up aiosonos logging if prov.logger.isEnabledFor(VERBOSE_LOG_LEVEL): - logging.getLogger("soco").setLevel(logging.DEBUG) + logging.getLogger("aiosonos").setLevel(logging.DEBUG) else: - logging.getLogger("soco").setLevel(prov.logger.level + 10) + logging.getLogger("aiosonos").setLevel(prov.logger.level + 10) return prov @@ -117,32 +98,183 @@ async def get_config_entries( values: the (intermediate) raw values for config entries sent with the action. """ # ruff: noqa: ARG001 - return ( - ConfigEntry( - key=CONF_NETWORK_SCAN, - type=ConfigEntryType.BOOLEAN, - label="Enable network scan for discovery", - default_value=False, - description="Enable network scan for discovery of players. \n" - "Can be used if (some of) your players are not automatically discovered.", - ), - ) + return () -@dataclass -class UnjoinData: - """Class to track data necessary for unjoin coalescing.""" +class SonosPlayer: + """Holds the details of the (discovered) Sonosplayer.""" - players: list[SonosPlayer] - event: asyncio.Event = field(default_factory=asyncio.Event) + def __init__( + self, + prov: SonosPlayerProvider, + player_id: str, + discovery_info: SonosDiscoveryInfo, + ip_address: str, + ) -> None: + """Initialize the SonosPlayer.""" + self.prov = prov + self.mass = prov.mass + self.player_id = player_id + self.discovery_info = discovery_info + self.ip_address = ip_address + self.logger = prov.logger.getChild(player_id) + self.connected: bool = False + self.client = SonosLocalApiClient(self.ip_address, self.mass.http_session) + self.mass_player: Player | None = None + self._listen_task: asyncio.Task | None = None + # Sonos speakers can optionally have airplay (most S2 speakers do) + # and this airplay player can also be a player within MA + # we can do some smart stuff if we link them together where possible + # the player if we can just guess from the sonos player id (mac address) + self._airplay_player_id = f"ap{self.player_id[7:-5].lower()}" + + async def connect(self) -> None: + """Connect to the Sonos player.""" + if self._listen_task and not self._listen_task.done(): + self.logger.debug("Already connected to Sonos player: %s", self.player_id) + return + await self.client.connect() + self.connected = True + self.logger.debug("Connected to player API") + init_ready = asyncio.Event() + + async def _listener() -> None: + try: + await self.client.start_listening(init_ready) + except Exception as err: + self.logger.exception("Error in Sonos player listener: %s", err) + if self.connected: + self.connected = False + self.mass.call_later(5, self.connect) + finally: + self.connected = False + + self._listen_task = asyncio.create_task(_listener()) + await init_ready.wait() + + async def disconnect(self) -> None: + """Disconnect the client and cleanup.""" + self.connected = False + if self._listen_task and not self._listen_task.done(): + self._listen_task.cancel() + if self.client: + await self.client.disconnect() + self.logger.debug("Disconnected from player API") + + def update_attributes(self) -> None: # noqa: PLR0915 + """Update the player attributes.""" + if not self.mass_player: + return + if self.client.player.has_fixed_volume: + self.mass_player.volume_level = 100 + else: + self.mass_player.volume_level = self.client.player.volume_level or 100 + self.mass_player.volume_muted = self.client.player.volume_muted + + group_parent = None + if self.client.player.is_coordinator: + # player is group coordinator + active_group = self.client.player.group + self.mass_player.group_childs = ( + self.client.player.group_members + if len(self.client.player.group_members) > 1 + else set() + ) + self.mass_player.synced_to = None + else: + # player is group child (synced to another player) + group_parent = self.prov.sonos_players.get(self.client.player.group.coordinator_id) + if not group_parent: + # handle race condition where the group parent is not yet discovered + return + active_group = group_parent.client.player.group + self.mass_player.group_childs = set() + self.mass_player.synced_to = active_group.coordinator_id + self.mass_player.active_source = active_group.coordinator_id + + # map playback state + self.mass_player.state = PLAYBACK_STATE_MAP[active_group.playback_state] + if ( + not self.mass_player.powered + and active_group.playback_state == SonosPlayBackState.PLAYBACK_STATE_PLAYING + ): + self.mass_player.powered = True + + self.mass_player.elapsed_time = active_group.position + # work out 'can sync with' for this player + self.mass_player.can_sync_with = tuple( + x + for x in self.prov.sonos_players + if x != self.player_id + and x in self.prov.sonos_players + and self.prov.sonos_players[x].client.household_id == self.client.household_id + ) + + # figure out the active source based on the container + if container := active_group.playback_metadata.get("container"): + if group_parent and group_parent.mass_player: + self.mass_player.active_source = group_parent.mass_player.active_source + elif container.get("type") == "linein": + self.mass_player.active_source = SOURCE_LINE_IN + elif container.get("type") == "linein.airplay": + # check if the MA airplay player is active + airplay_player = self.mass.players.get(self._airplay_player_id) + if airplay_player and airplay_player.powered: + self.mass_player.active_source = airplay_player.active_source + else: + self.mass_player.active_source = SOURCE_AIRPLAY + elif container.get("type") == "station": + self.mass_player.active_source = SOURCE_RADIO + elif container.get("id", {}).get("objectId") == f"mass:queue:{self.player_id}": + # mass queue is active + self.mass_player.active_source = self.player_id + elif container.get("id", {}).get("serviceId") == "9": + self.mass_player.active_source = SOURCE_SPOTIFY + else: + self.mass_player.active_source = SOURCE_UNKNOWN + + # parse current media + if (current_item := active_group.playback_metadata.get("currentItem")) and ( + (track := current_item.get("track")) and track.get("name") + ): + track_images = track.get("images", []) + track_image_url = track_images[0].get("url") if track_images else None + track_duration_millis = track.get("durationMillis") + self.mass_player.current_media = PlayerMedia( + uri=track.get("id", {}).get("objectId") or track.get("mediaUrl"), + title=track["name"], + artist=track.get("artist", {}).get("name"), + album=track.get("album", {}).get("name"), + duration=track_duration_millis / 1000 if track_duration_millis else None, + image_url=track_image_url, + ) + elif ( + container and container.get("name") and active_group.playback_metadata.get("streamInfo") + ): + images = container.get("images", []) + image_url = images[0].get("url") if images else None + self.mass_player.current_media = PlayerMedia( + uri=container.get("id", {}).get("objectId"), + title=active_group.playback_metadata["streamInfo"], + album=container["name"], + image_url=image_url, + ) + elif container and container.get("name") and container.get("id"): + images = container.get("images", []) + image_url = images[0].get("url") if images else None + self.mass_player.current_media = PlayerMedia( + uri=container["id"]["objectId"], + title=container["name"], + image_url=image_url, + ) + else: + self.mass_player.current_media = None class SonosPlayerProvider(PlayerProvider): """Sonos Player provider.""" - sonosplayers: dict[str, SonosPlayer] | None = None - _discovery_running: bool = False - _discovery_reschedule_timer: asyncio.TimerHandle | None = None + sonos_players: dict[str, SonosPlayer] @property def supported_features(self) -> tuple[ProviderFeature, ...]: @@ -151,33 +283,79 @@ def supported_features(self) -> tuple[ProviderFeature, ...]: async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" - self.sonosplayers: OrderedDict[str, SonosPlayer] = OrderedDict() - self.topology_condition = asyncio.Condition() - self.boot_counts: dict[str, int] = {} - self.mdns_names: dict[str, str] = {} - self.unjoin_data: dict[str, UnjoinData] = {} - self._discovery_running = False - self.hosts_in_error: dict[str, bool] = {} - self.discovery_lock = asyncio.Lock() - self.creation_lock = asyncio.Lock() - self._known_invisible: set[SoCo] = set() - - async def loaded_in_mass(self) -> None: - """Call after the provider has been loaded.""" - await self._run_discovery() + self.sonos_players: dict[str, SonosPlayer] = {} + self.mass.streams.register_dynamic_route( + "/sonos_queue/v2.3/itemWindow", self._handle_sonos_queue_itemwindow + ) + self.mass.streams.register_dynamic_route( + "/sonos_queue/v2.3/version", self._handle_sonos_queue_version + ) + self.mass.streams.register_dynamic_route( + "/sonos_queue/v2.3/context", self._handle_sonos_queue_context + ) + self.mass.streams.register_dynamic_route( + "/sonos_queue/v2.3/timePlayed", self._handle_sonos_queue_time_played + ) async def unload(self) -> None: """Handle close/cleanup of the provider.""" - if self._discovery_reschedule_timer: - self._discovery_reschedule_timer.cancel() - self._discovery_reschedule_timer = None - # await any in-progress discovery - while self._discovery_running: - await asyncio.sleep(0.5) - await asyncio.gather(*(player.offline() for player in self.sonosplayers.values())) - if events_asyncio.event_listener: - await events_asyncio.event_listener.async_stop() - self.sonosplayers = None + # disconnect all players + await asyncio.gather(*(player.disconnect() for player in self.sonos_players.values())) + self.sonos_players = None + self.mass.streams.unregister_dynamic_route("/sonos_queue/v2.3/itemWindow") + self.mass.streams.unregister_dynamic_route("/sonos_queue/v2.3/version") + self.mass.streams.unregister_dynamic_route("/sonos_queue/v2.3/context") + self.mass.streams.unregister_dynamic_route("/sonos_queue/v2.3/timePlayed") + + async def on_mdns_service_state_change( + self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None + ) -> None: + """Handle MDNS service state callback.""" + if not info: + self.logger.error( + "No info in MDNS service state change for %s - state change: %s", name, state_change + ) + return + if "uuid" not in info.decoded_properties: + # not a S2 player + return + name = name.split("@", 1)[1] if "@" in name else name + player_id = info.decoded_properties["uuid"] + # handle removed player + if state_change == ServiceStateChange.Removed: + if mass_player := self.mass.players.get(player_id): + if not mass_player.available: + return + # the player has become unavailable + self.logger.debug("Player offline: %s", mass_player.display_name) + mass_player.available = False + self.mass.players.update(player_id) + return + # handle update for existing device + if sonos_player := self.sonos_players.get(player_id): + if mass_player := self.mass.players.get(player_id): + cur_address = get_primary_ip_address(info) + if cur_address and cur_address != sonos_player.ip_address: + sonos_player.logger.debug( + "Address updated from %s to %s", sonos_player.ip_address, cur_address + ) + sonos_player.ip_address = cur_address + mass_player.device_info = DeviceInfo( + model=mass_player.device_info.model, + manufacturer=mass_player.device_info.manufacturer, + address=str(cur_address), + ) + if not mass_player.available: + self.logger.debug("Player back online: %s", mass_player.display_name) + sonos_player.client.player_ip = cur_address + await sonos_player.connect(self.mass.http_session) + mass_player.available = True + # always update the latest discovery info + sonos_player.discovery_info = info + self.mass.players.update(player_id) + return + # handle new player + await self._setup_player(player_id, name, info) async def get_player_config_entries( self, @@ -185,154 +363,58 @@ async def get_player_config_entries( ) -> tuple[ConfigEntry, ...]: """Return Config Entries for the given player.""" base_entries = await super().get_player_config_entries(player_id) - if not (sonos_player := self.sonosplayers.get(player_id)): + if not self.sonos_players.get(player_id): # most probably a syncgroup return (*base_entries, CONF_ENTRY_CROSSFADE, CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED) - is_s2 = sonos_player.soco.speaker_info["model_name"] in S2_MODELS return ( *base_entries, CONF_ENTRY_CROSSFADE, - ConfigEntry( - key="sonos_bass", - type=ConfigEntryType.INTEGER, - label="Bass", - default_value=sonos_player.bass, - value=sonos_player.bass, - range=(-10, 10), - description="Set the Bass level for the Sonos player", - category="advanced", - ), - ConfigEntry( - key="sonos_treble", - type=ConfigEntryType.INTEGER, - label="Treble", - default_value=sonos_player.treble, - value=sonos_player.treble, - range=(-10, 10), - description="Set the Treble level for the Sonos player", - category="advanced", - ), - ConfigEntry( - key="sonos_loudness", - type=ConfigEntryType.BOOLEAN, - label="Loudness compensation", - default_value=sonos_player.loudness, - value=sonos_player.loudness, - description="Enable loudness compensation on the Sonos player", - category="advanced", - ), - CONF_ENTRY_SAMPLE_RATES_SONOS_S2 if is_s2 else CONF_ENTRY_SAMPLE_RATES_SONOS_S1, CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, + create_sample_rates_config_entry(48000, 24, 48000, 24, True), ) - def on_player_config_changed( - self, - config: PlayerConfig, - changed_keys: set[str], - ) -> None: - """Call (by config manager) when the configuration of a player changes.""" - super().on_player_config_changed(config, changed_keys) - if "enabled" in changed_keys: - # run discovery to catch any re-enabled players - self.mass.create_task(self._run_discovery()) - if not (sonos_player := self.sonosplayers.get(config.player_id)): - return - if "values/sonos_bass" in changed_keys: - self.mass.create_task( - sonos_player.soco.renderingControl.SetBass, - [("InstanceID", 0), ("DesiredBass", config.get_value("sonos_bass"))], - ) - if "values/sonos_treble" in changed_keys: - self.mass.create_task( - sonos_player.soco.renderingControl.SetTreble, - [("InstanceID", 0), ("DesiredTreble", config.get_value("sonos_treble"))], - ) - if "values/sonos_loudness" in changed_keys: - loudness_value = "1" if config.get_value("sonos_loudness") else "0" - self.mass.create_task( - sonos_player.soco.renderingControl.SetLoudness, - [ - ("InstanceID", 0), - ("Channel", "Master"), - ("DesiredLoudness", loudness_value), - ], - ) - - def is_device_invisible(self, ip_address: str) -> bool: - """Check if device at provided IP is known to be invisible.""" - return any(x for x in self._known_invisible if x.ip_address == ip_address) - async def cmd_stop(self, player_id: str) -> None: """Send STOP command to given player.""" - sonos_player = self.sonosplayers[player_id] - if sonos_player.sync_coordinator: + sonos_player = self.sonos_players[player_id] + if sonos_player.client.player.is_passive: self.logger.debug( "Ignore STOP command for %s: Player is synced to another player.", - sonos_player.zone_name, + player_id, ) return - if "Stop" not in sonos_player.soco.available_actions: - self.logger.debug( - "Ignore STOP command for %s: Player reports this action is not available now.", - sonos_player.zone_name, - ) - await asyncio.to_thread(sonos_player.soco.stop) + await sonos_player.client.player.group.stop() async def cmd_play(self, player_id: str) -> None: """Send PLAY command to given player.""" - sonos_player = self.sonosplayers[player_id] - if sonos_player.sync_coordinator: + sonos_player = self.sonos_players[player_id] + if sonos_player.client.player.is_passive: self.logger.debug( "Ignore PLAY command for %s: Player is synced to another player.", player_id, ) return - if "Play" not in sonos_player.soco.available_actions: - self.logger.debug( - "Ignore STOP command for %s: Player reports this action is not available now.", - sonos_player.zone_name, - ) - await asyncio.to_thread(sonos_player.soco.play) + await sonos_player.client.player.group.play() async def cmd_pause(self, player_id: str) -> None: """Send PAUSE command to given player.""" - sonos_player = self.sonosplayers[player_id] - if sonos_player.sync_coordinator: + sonos_player = self.sonos_players[player_id] + if sonos_player.client.player.is_passive: self.logger.debug( "Ignore PLAY command for %s: Player is synced to another player.", player_id, ) return - if "Pause" not in sonos_player.soco.available_actions: - # pause not possible - await self.cmd_stop(player_id) - return - await asyncio.to_thread(sonos_player.soco.pause) + await sonos_player.client.player.group.pause() async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: """Send VOLUME_SET command to given player.""" - - def set_volume_level(player_id: str, volume_level: int) -> None: - sonos_player = self.sonosplayers[player_id] - sonos_player.soco.volume = volume_level - - await asyncio.to_thread(set_volume_level, player_id, volume_level) + sonos_player = self.sonos_players[player_id] + await sonos_player.client.player.set_volume(volume_level) async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: """Send VOLUME MUTE command to given player.""" - - def set_volume_mute(player_id: str, muted: bool) -> None: - sonos_player = self.sonosplayers[player_id] - sonos_player.soco.mute = muted - - await asyncio.to_thread(set_volume_mute, player_id, muted) - - async def cmd_sync_many(self, target_player: str, child_player_ids: list[str]) -> None: - """Create temporary sync group by joining given players to target player.""" - sonos_master_player = self.sonosplayers[target_player] - await sonos_master_player.join( - [self.sonosplayers[player_id] for player_id in child_player_ids] - ) + sonos_player = self.sonos_players[player_id] + await sonos_player.client.player.set_volume(muted=muted) async def cmd_sync(self, player_id: str, target_player: str) -> None: """Handle SYNC command for given player. @@ -342,9 +424,14 @@ async def cmd_sync(self, player_id: str, target_player: str) -> None: - player_id: player_id of the player to handle the command. - target_player: player_id of the syncgroup master or group player. """ - sonos_player = self.sonosplayers[player_id] - sonos_master_player = self.sonosplayers[target_player] - await sonos_master_player.join([sonos_player]) + await self.cmd_sync_many(target_player, [player_id]) + + async def cmd_sync_many(self, target_player: str, child_player_ids: list[str]) -> None: + """Create temporary sync group by joining given players to target player.""" + sonos_player = self.sonos_players[target_player] + await sonos_player.client.player.group.modify_group_members( + player_ids_to_add=child_player_ids, player_ids_to_remove=[] + ) async def cmd_unsync(self, player_id: str) -> None: """Handle UNSYNC command for given player. @@ -353,8 +440,8 @@ async def cmd_unsync(self, player_id: str) -> None: - player_id: player_id of the player to handle the command. """ - sonos_player = self.sonosplayers[player_id] - await sonos_player.unjoin() + sonos_player = self.sonos_players[player_id] + await sonos_player.client.player.leave_group() async def play_media( self, @@ -362,53 +449,44 @@ async def play_media( media: PlayerMedia, ) -> None: """Handle PLAY MEDIA on given player.""" - sonos_player = self.sonosplayers[player_id] + sonos_player = self.sonos_players[player_id] mass_player = self.mass.players.get(player_id) - if sonos_player.sync_coordinator: + if sonos_player.client.player.is_passive: # this should be already handled by the player manager, but just in case... msg = ( f"Player {mass_player.display_name} can not " "accept play_media command, it is synced to another player." ) raise PlayerCommandFailed(msg) - - didl_metadata = create_didl_metadata(media) - await asyncio.to_thread(sonos_player.soco.play_uri, media.uri, meta=didl_metadata) + mass_queue = self.mass.player_queues.get(media.queue_id) + + # create a sonos cloud queue and load it + cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/" + await sonos_player.client.player.group.play_cloud_queue( + cloud_queue_url, + http_authorization=media.queue_id, + item_id=media.queue_item_id, + queue_version=str(mass_queue.queue_items_last_updated), + ) async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: """Handle enqueuing of the next queue item on the player.""" - sonos_player = self.sonosplayers[player_id] - didl_metadata = create_didl_metadata(media) - # set crossfade according to player setting + sonos_player = self.sonos_players[player_id] + if session_id := sonos_player.client.player.group.active_session_id: + await sonos_player.client.api.playback_session.refresh_cloud_queue(session_id) + # sync play modes from player queue --> sonos + mass_queue = self.mass.player_queues.get(media.queue_id) crossfade = await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE) - if sonos_player.crossfade != crossfade: - - def set_crossfade() -> None: - try: - sonos_player.soco.cross_fade = crossfade - sonos_player.crossfade = crossfade - except Exception as err: - self.logger.warning( - "Unable to set crossfade for player %s: %s", sonos_player.zone_name, err - ) - - await asyncio.to_thread(set_crossfade) - - try: - await asyncio.to_thread( - sonos_player.soco.avTransport.SetNextAVTransportURI, - [("InstanceID", 0), ("NextURI", media.uri), ("NextURIMetaData", didl_metadata)], - timeout=60, - ) - except Exception as err: - self.logger.warning( - "Unable to enqueue next track on player: %s: %s", sonos_player.zone_name, err - ) - else: - self.logger.debug( - "Enqued next track (%s) to player %s", - media.title or media.uri, - sonos_player.soco.player_name, + repeat_single_enabled = mass_queue.repeat_mode == RepeatMode.ONE + repeat_all_enabled = mass_queue.repeat_mode == RepeatMode.ALL + play_modes = sonos_player.client.player.group.play_modes + if ( + play_modes.crossfade != crossfade + or play_modes.repeat != repeat_all_enabled + or play_modes.repeat_one != repeat_single_enabled + ): + await sonos_player.client.player.group.set_play_modes( + crossfade=crossfade, repeat=repeat_all_enabled, repeat_one=repeat_single_enabled ) async def play_announcement( @@ -425,124 +503,249 @@ async def play_announcement( self.play_announcement(child_player_id, announcement, volume_level) ) return - sonos_player = self.sonosplayers[player_id] + sonos_player = self.sonos_players[player_id] self.logger.debug( "Playing announcement %s using websocket audioclip on %s", announcement.uri, sonos_player.zone_name, ) volume_level = self.mass.players.get_announcement_volume(player_id, volume_level) - try: - response, _ = await sonos_player.websocket.play_clip( - announcement.uri, - volume=volume_level, - ) - except SonosWebsocketError as exc: - raise PlayerCommandFailed(f"Error when calling Sonos websocket: {exc}") from exc - if response["success"]: - return + await sonos_player.client.player.play_audio_clip(announcement.uri, volume_level) - async def poll_player(self, player_id: str) -> None: - """Poll player for state updates.""" - if player_id not in self.sonosplayers: + async def _setup_player(self, player_id: str, name: str, info: AsyncServiceInfo) -> None: + """Handle setup of a new player that is discovered using mdns.""" + address = get_primary_ip_address(info) + if address is None: return - sonos_player = self.sonosplayers[player_id] - try: - # the check_poll logic will work out what endpoints need polling now - # based on when we last received info from the device - await sonos_player.check_poll() - # always update the attributes - sonos_player.update_player(signal_update=False) - except ConnectionResetError as err: - raise PlayerUnavailableError from err - - async def _run_discovery(self) -> None: - """Discover Sonos players on the network.""" - if self._discovery_running: + if not self.mass.config.get_raw_player_config_value(player_id, "enabled", True): + self.logger.debug("Ignoring %s in discovery as it is disabled.", name) return + if not (discovery_info := await get_discovery_info(self.mass.http_session, address)): + self.logger.debug("Ignoring %s in discovery as it is not reachable.", name) + return + display_name = discovery_info["device"].get("name") or name + if SonosCapability.PLAYBACK not in discovery_info["device"]["capabilities"]: + # this will happen for satellite speakers in a surround/stereo setup + self.logger.debug( + "Ignoring %s in discovery as it is a passive satellite.", display_name + ) + return + self.logger.debug("Discovered Sonos device %s on %s", name, address) + self.sonos_players[player_id] = sonos_player = SonosPlayer( + self, player_id, discovery_info=discovery_info, ip_address=address + ) + # connect the player first so we can fail early + await sonos_player.connect() + + # collect supported features + supported_features = set(PLAYER_FEATURES_BASE) + if SonosCapability.AUDIO_CLIP in discovery_info["device"]["capabilities"]: + supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT) + if not sonos_player.client.player.has_fixed_volume: + supported_features.add(PlayerFeature.VOLUME_SET) + + sonos_player.mass_player = mass_player = Player( + player_id=player_id, + provider=self.instance_id, + type=PlayerType.PLAYER, + name=display_name, + available=True, + # treat as powered at start if the player is playing/paused + powered=sonos_player.client.player.group.playback_state + in ( + SonosPlayBackState.PLAYBACK_STATE_PLAYING, + SonosPlayBackState.PLAYBACK_STATE_BUFFERING, + SonosPlayBackState.PLAYBACK_STATE_PAUSED, + ), + device_info=DeviceInfo( + model=discovery_info["device"]["modelDisplayName"], + manufacturer=self.manifest.name, + address=address, + ), + supported_features=tuple(supported_features), + ) + sonos_player.update_attributes() + self.mass.players.register_or_update(mass_player) + + # register callback for state changed + def on_player_event(event: SonosEvent) -> None: + """Handle incoming event from player.""" + sonos_player.update_attributes() + self.mass.players.update(player_id) + + sonos_player.client.subscribe( + on_player_event, + ( + SonosEventType.GROUP_UPDATED, + SonosEventType.PLAYER_UPDATED, + ), + ) + # when we add a new player, update 'can_sync_with' for all other players + for other_player_id in self.sonos_players: + if other_player_id == player_id: + continue + self.sonos_players[other_player_id].update_attributes() - allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN) - - def do_discover() -> None: - """Run discovery and add players in executor thread.""" - self._discovery_running = True - try: - self.logger.debug("Sonos discovery started...") - discovered_devices: set[SoCo] = discover(allow_network_scan=allow_network_scan) - if discovered_devices is None: - discovered_devices = set() - # process new players - for soco in discovered_devices: - try: - self._add_player(soco) - except RequestException as err: - # player is offline - self.logger.debug("Failed to add SonosPlayer %s: %s", soco, err) - except Exception as err: - self.logger.warning( - "Failed to add SonosPlayer %s: %s", - soco, - err, - exc_info=err if self.logger.isEnabledFor(10) else None, - ) - finally: - self._discovery_running = False - - await self.mass.create_task(do_discover) + async def _handle_sonos_queue_itemwindow(self, request: web.Request) -> web.Response: + """ + Handle the Sonos CloudQueue ItemWindow endpoint. - def reschedule() -> None: - self._discovery_reschedule_timer = None - self.mass.create_task(self._run_discovery()) + https://docs.sonos.com/reference/itemwindow + """ + 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] + upcoming_window_size = int(request.query.get("upcomingWindowSize") or 10) + previous_window_size = int(request.query.get("previousWindowSize") or 10) + queue_version = request.query.get("queueVersion") + context_version = request.query.get("contextVersion") + queue = self.mass.player_queues.get(sonos_player_id) + if item_id := request.query.get("itemId"): + queue_index = self.mass.player_queues.index_by_id(queue.queue_id, item_id) + else: + queue_index = queue.current_index or 0 + offset = max(queue_index - previous_window_size, 0) + queue_items = self.mass.player_queues.items( + sonos_player_id, + limit=upcoming_window_size + previous_window_size, + offset=max(queue_index - previous_window_size, 0), + ) + sonos_queue_items = [ + { + "id": item.queue_item_id, + "deleted": not item.media_item.available, + "policies": {}, + "track": { + "type": "track", + "mediaUrl": self.mass.streams.resolve_stream_url(item), + "contentType": "audio/flac", + "service": {"name": "Music Assistant", "id": "8", "accountId": ""}, + "name": item.name, + "imageUrl": self.mass.metadata.get_image_url( + item.image, prefer_proxy=False, image_format="jpeg" + ) + if item.image + else None, + "durationMillis": item.duration * 1000 if item.duration else None, + "artist": { + "name": item.media_item.artist_str, + } + if item.media_item and item.media_item.artist_str + else None, + "album": { + "name": item.media_item.album.name, + } + if item.media_item and item.media_item.album + else None, + "quality": { + "bitDepth": item.streamdetails.audio_format.bit_depth, + "sampleRate": item.streamdetails.audio_format.sample_rate, + "codec": item.streamdetails.audio_format.content_type.value, + "lossless": item.streamdetails.audio_format.content_type.is_lossless(), + } + if item.streamdetails + else None, + }, + } + for item in queue_items + ] + result = { + "includesBeginningOfQueue": offset == 0, + "includesEndOfQueue": queue.items <= (queue_index + len(sonos_queue_items)), + "contextVersion": context_version, + "queueVersion": queue_version, + "items": sonos_queue_items, + } + return web.json_response(result) + + async def _handle_sonos_queue_version(self, request: web.Request) -> web.Response: + """ + Handle the Sonos CloudQueue Version endpoint. - # reschedule self once finished - self._discovery_reschedule_timer = self.mass.loop.call_later(1800, reschedule) + https://docs.sonos.com/reference/version + """ + 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] + queue = self.mass.player_queues.get(sonos_player_id) + context_version = request.query.get("contextVersion") or "1" + queue_version = str(queue.queue_items_last_updated) + result = {"contextVersion": context_version, "queueVersion": queue_version} + return web.json_response(result) + + async def _handle_sonos_queue_context(self, request: web.Request) -> web.Response: + """ + Handle the Sonos CloudQueue Context endpoint. - def _add_player(self, soco: SoCo) -> None: - """Add discovered Sonos player.""" - player_id = soco.uid - # check if existing player changed IP - if existing := self.sonosplayers.get(player_id): - if existing.soco.ip_address != soco.ip_address: - existing.update_ip(soco.ip_address) - return - if not soco.is_visible: - return - enabled = self.mass.config.get_raw_player_config_value(player_id, "enabled", True) - if not enabled: - self.logger.debug("Ignoring disabled player: %s", player_id) - return + https://docs.sonos.com/reference/context + """ + 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] + queue = self.mass.player_queues.get(sonos_player_id) + result = { + "contextVersion": "1", + "queueVersion": str(queue.queue_items_last_updated), + "container": { + "type": "playlist", + "name": "Music Assistant", + "imageUrl": MASS_LOGO_ONLINE, + "service": {"name": "Music Assistant", "id": "mass"}, + "id": { + "serviceId": "mass", + "objectId": f"mass:queue:{queue.queue_id}", + "accountId": "", + }, + }, + "reports": {"sendUpdateAfterMillis": 0, "sendPlaybackActions": True}, + "playbackPolicies": { + "canSkip": True, + "limitedSkips": False, + "canSkipToItem": True, + "canSkipBack": True, + "canSeek": False, # somehow not working correctly, investigate later + "canRepeat": True, + "canRepeatOne": True, + "canCrossfade": True, + "canShuffle": False, # handled by our queue controller itself + "showNNextTracks": 5, + "showNPreviousTracks": 5, + }, + } + return web.json_response(result) + + async def _handle_sonos_queue_time_played(self, request: web.Request) -> web.Response: + """ + Handle the Sonos CloudQueue TimePlayed endpoint. - speaker_info = soco.get_speaker_info(True, timeout=7) - if soco.uid not in self.boot_counts: - self.boot_counts[soco.uid] = soco.boot_seqnum - self.logger.debug("Adding new player: %s", speaker_info) - if not (mass_player := self.mass.players.get(soco.uid)): - mass_player = Player( - player_id=soco.uid, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=soco.player_name, - available=True, - powered=False, - supported_features=PLAYER_FEATURES, - device_info=DeviceInfo( - model=speaker_info["model_name"], - address=soco.ip_address, - manufacturer="SONOS", - ), - needs_poll=True, - poll_interval=120, - ) - self.sonosplayers[player_id] = sonos_player = SonosPlayer( - self, - soco=soco, - mass_player=mass_player, - ) - if soco.fixed_volume: - mass_player.supported_features = tuple( - x for x in mass_player.supported_features if x != PlayerFeature.VOLUME_SET - ) - sonos_player.setup() - self.mass.loop.call_soon_threadsafe( - self.mass.players.register_or_update, sonos_player.mass_player - ) + https://docs.sonos.com/reference/timeplayed + """ + self.logger.log(VERBOSE_LOG_LEVEL, "Cloud Queue TimePlayed request: %s", request.query) + json_body = await request.json() + sonos_playback_id = request.headers["X-Sonos-Playback-Id"] + sonos_player_id = sonos_playback_id.split(":")[0] + mass_player = self.mass.players.get(sonos_player_id) + for item in json_body["items"]: + if "positionMillis" not in item: + continue + if mass_player.current_media: + mass_player.current_media.queue_item_id = item["id"] + mass_player.current_media.uri = item["mediaUrl"] + mass_player.current_media.queue_id = sonos_playback_id + mass_player.elapsed_time = item["positionMillis"] / 1000 + mass_player.elapsed_time_last_updated = time() + break + return web.Response(status=204) + + +def get_primary_ip_address(discovery_info: AsyncServiceInfo) -> str | None: + """Get primary IP address from zeroconf discovery info.""" + for address in discovery_info.parsed_addresses(IPVersion.V4Only): + if address.startswith("127"): + # filter out loopback address + continue + if address.startswith("169.254"): + # filter out APIPA address + continue + return address + return None diff --git a/music_assistant/server/providers/sonos/manifest.json b/music_assistant/server/providers/sonos/manifest.json index 0b0a24a5f..98bc17f0d 100644 --- a/music_assistant/server/providers/sonos/manifest.json +++ b/music_assistant/server/providers/sonos/manifest.json @@ -3,15 +3,10 @@ "domain": "sonos", "name": "SONOS", "description": "SONOS Player provider for Music Assistant.", - "codeowners": [ - "@music-assistant" - ], - "requirements": [ - "soco==0.30.4", - "sonos-websocket==0.1.3", - "defusedxml==0.7.1" - ], + "codeowners": ["@music-assistant"], + "requirements": ["aiosonos==0.1.1"], "documentation": "https://music-assistant.io/player-support/sonos/", "multi_instance": false, - "builtin": false + "builtin": false, + "mdns_discovery": ["_sonos._tcp.local."] } diff --git a/music_assistant/server/providers/sonos_s1/__init__.py b/music_assistant/server/providers/sonos_s1/__init__.py new file mode 100644 index 000000000..3f89ebd48 --- /dev/null +++ b/music_assistant/server/providers/sonos_s1/__init__.py @@ -0,0 +1,474 @@ +""" +Sonos Player S1 provider for Music Assistant. + +Based on the SoCo library for Sonos which uses the legacy/V1 UPnP API. + +Note that large parts of this code are copied over from the Home Assistant +integration for Sonos. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections import OrderedDict +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from requests.exceptions import RequestException +from soco import config as soco_config +from soco import events_asyncio, zonegroupstate +from soco.discovery import discover, scan_network + +from music_assistant.common.models.config_entries import ( + CONF_ENTRY_CROSSFADE, + CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED, + ConfigEntry, + ConfigValueType, + create_sample_rates_config_entry, +) +from music_assistant.common.models.enums import ( + ConfigEntryType, + PlayerFeature, + PlayerType, + ProviderFeature, +) +from music_assistant.common.models.errors import PlayerCommandFailed, PlayerUnavailableError +from music_assistant.common.models.player import DeviceInfo, Player, PlayerMedia +from music_assistant.constants import CONF_CROSSFADE, CONF_ENFORCE_MP3, VERBOSE_LOG_LEVEL +from music_assistant.server.helpers.didl_lite import create_didl_metadata +from music_assistant.server.models.player_provider import PlayerProvider + +from .player import SonosPlayer + +if TYPE_CHECKING: + from soco.core import SoCo + + from music_assistant.common.models.config_entries import ProviderConfig + from music_assistant.common.models.provider import ProviderManifest + from music_assistant.server import MusicAssistant + from music_assistant.server.models import ProviderInstanceType + + +PLAYER_FEATURES = ( + PlayerFeature.SYNC, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.ENQUEUE_NEXT, + PlayerFeature.PAUSE, +) + +CONF_NETWORK_SCAN = "network_scan" +CONF_HOUSEHOLD_ID = "household_id" +SUBSCRIPTION_TIMEOUT = 1200 +ZGS_SUBSCRIPTION_TIMEOUT = 2 + +CONF_ENTRY_SAMPLE_RATES = create_sample_rates_config_entry(48000, 16, 48000, 16, True) + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + soco_config.EVENTS_MODULE = events_asyncio + soco_config.REQUEST_TIMEOUT = 9.5 + zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT + prov = SonosPlayerProvider(mass, manifest, config) + # set-up soco logging + if prov.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logging.getLogger("soco").setLevel(logging.DEBUG) + else: + logging.getLogger("soco").setLevel(prov.logger.level + 10) + await prov.handle_async_init() + return prov + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """ + Return Config entries to setup this provider. + + instance_id: id of an existing provider instance (None if new instance setup). + action: [optional] action key called from config entries UI. + values: the (intermediate) raw values for config entries sent with the action. + """ + # ruff: noqa: ARG001 + household_ids = await discover_household_ids(mass) + return ( + ConfigEntry( + key=CONF_NETWORK_SCAN, + type=ConfigEntryType.BOOLEAN, + label="Enable network scan for discovery", + default_value=False, + description="Enable network scan for discovery of players. \n" + "Can be used if (some of) your players are not automatically discovered.\n" + "Should normally not be needed", + ), + ConfigEntry( + key=CONF_HOUSEHOLD_ID, + type=ConfigEntryType.STRING, + label="Household ID", + default_value=household_ids[0] if household_ids else None, + description="Household ID for the Sonos (S1) system. Will be auto detected if empty.", + category="advanced", + required=False, + ), + ) + + +@dataclass +class UnjoinData: + """Class to track data necessary for unjoin coalescing.""" + + players: list[SonosPlayer] + event: asyncio.Event = field(default_factory=asyncio.Event) + + +class SonosPlayerProvider(PlayerProvider): + """Sonos Player provider.""" + + sonosplayers: dict[str, SonosPlayer] | None = None + _discovery_running: bool = False + _discovery_reschedule_timer: asyncio.TimerHandle | None = None + + @property + def supported_features(self) -> tuple[ProviderFeature, ...]: + """Return the features supported by this Provider.""" + return (ProviderFeature.SYNC_PLAYERS, ProviderFeature.PLAYER_GROUP_CREATE) + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + self.sonosplayers: OrderedDict[str, SonosPlayer] = OrderedDict() + self.topology_condition = asyncio.Condition() + self.boot_counts: dict[str, int] = {} + self.mdns_names: dict[str, str] = {} + self.unjoin_data: dict[str, UnjoinData] = {} + self._discovery_running = False + self.hosts_in_error: dict[str, bool] = {} + self.discovery_lock = asyncio.Lock() + self.creation_lock = asyncio.Lock() + self._known_invisible: set[SoCo] = set() + + async def loaded_in_mass(self) -> None: + """Call after the provider has been loaded.""" + await self._run_discovery() + + async def unload(self) -> None: + """Handle close/cleanup of the provider.""" + if self._discovery_reschedule_timer: + self._discovery_reschedule_timer.cancel() + self._discovery_reschedule_timer = None + # await any in-progress discovery + while self._discovery_running: + await asyncio.sleep(0.5) + await asyncio.gather(*(player.offline() for player in self.sonosplayers.values())) + if events_asyncio.event_listener: + await events_asyncio.event_listener.async_stop() + self.sonosplayers = None + + async def get_player_config_entries( + self, + player_id: str, + ) -> tuple[ConfigEntry, ...]: + """Return Config Entries for the given player.""" + base_entries = await super().get_player_config_entries(player_id) + if not (self.sonosplayers.get(player_id)): + # most probably a syncgroup + return (*base_entries, CONF_ENTRY_CROSSFADE, CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED) + return ( + *base_entries, + CONF_ENTRY_CROSSFADE, + CONF_ENTRY_SAMPLE_RATES, + CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED, + ) + + def is_device_invisible(self, ip_address: str) -> bool: + """Check if device at provided IP is known to be invisible.""" + return any(x for x in self._known_invisible if x.ip_address == ip_address) + + async def cmd_stop(self, player_id: str) -> None: + """Send STOP command to given player.""" + sonos_player = self.sonosplayers[player_id] + if sonos_player.sync_coordinator: + self.logger.debug( + "Ignore STOP command for %s: Player is synced to another player.", + player_id, + ) + return + await asyncio.to_thread(sonos_player.soco.stop) + self.mass.call_later(2, sonos_player.poll_speaker) + + async def cmd_play(self, player_id: str) -> None: + """Send PLAY command to given player.""" + sonos_player = self.sonosplayers[player_id] + if sonos_player.sync_coordinator: + self.logger.debug( + "Ignore PLAY command for %s: Player is synced to another player.", + player_id, + ) + return + await asyncio.to_thread(sonos_player.soco.play) + self.mass.call_later(2, sonos_player.poll_speaker) + + async def cmd_pause(self, player_id: str) -> None: + """Send PAUSE command to given player.""" + sonos_player = self.sonosplayers[player_id] + if sonos_player.sync_coordinator: + self.logger.debug( + "Ignore PLAY command for %s: Player is synced to another player.", + player_id, + ) + return + if "Pause" not in sonos_player.soco.available_actions: + # pause not possible + await self.cmd_stop(player_id) + return + await asyncio.to_thread(sonos_player.soco.pause) + self.mass.call_later(2, sonos_player.poll_speaker) + + async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + sonos_player = self.sonosplayers[player_id] + + def set_volume_level(player_id: str, volume_level: int) -> None: + sonos_player.soco.volume = volume_level + + await asyncio.to_thread(set_volume_level, player_id, volume_level) + self.mass.call_later(2, sonos_player.poll_speaker) + + async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: + """Send VOLUME MUTE command to given player.""" + + def set_volume_mute(player_id: str, muted: bool) -> None: + sonos_player = self.sonosplayers[player_id] + sonos_player.soco.mute = muted + + await asyncio.to_thread(set_volume_mute, player_id, muted) + + async def cmd_sync(self, player_id: str, target_player: str) -> None: + """Handle SYNC command for given player. + + Join/add the given player(id) to the given (master) player/sync group. + + - player_id: player_id of the player to handle the command. + - target_player: player_id of the syncgroup master or group player. + """ + sonos_player = self.sonosplayers[player_id] + sonos_master_player = self.sonosplayers[target_player] + await sonos_master_player.join([sonos_player]) + self.mass.call_later(2, sonos_player.poll_speaker) + + async def cmd_unsync(self, player_id: str) -> None: + """Handle UNSYNC command for given player. + + Remove the given player from any syncgroups it currently is synced to. + + - player_id: player_id of the player to handle the command. + """ + sonos_player = self.sonosplayers[player_id] + await sonos_player.unjoin() + self.mass.call_later(2, sonos_player.poll_speaker) + + async def play_media( + self, + player_id: str, + media: PlayerMedia, + ) -> None: + """Handle PLAY MEDIA on given player.""" + sonos_player = self.sonosplayers[player_id] + mass_player = self.mass.players.get(player_id) + if sonos_player.sync_coordinator: + # this should be already handled by the player manager, but just in case... + msg = ( + f"Player {mass_player.display_name} can not " + "accept play_media command, it is synced to another player." + ) + raise PlayerCommandFailed(msg) + if self.mass.config.get_raw_player_config_value(player_id, CONF_ENFORCE_MP3, True): + media.uri = media.uri.replace(".flac", ".mp3") + didl_metadata = create_didl_metadata(media) + await asyncio.to_thread(sonos_player.soco.play_uri, media.uri, meta=didl_metadata) + self.mass.call_later(2, sonos_player.poll_speaker) + + async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: + """Handle enqueuing of the next queue item on the player.""" + sonos_player = self.sonosplayers[player_id] + if self.mass.config.get_raw_player_config_value(player_id, CONF_ENFORCE_MP3, True): + media.uri = media.uri.replace(".flac", ".mp3") + didl_metadata = create_didl_metadata(media) + # set crossfade according to player setting + crossfade = await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE) + if sonos_player.crossfade != crossfade: + + def set_crossfade() -> None: + try: + sonos_player.soco.cross_fade = crossfade + sonos_player.crossfade = crossfade + except Exception as err: + self.logger.warning( + "Unable to set crossfade for player %s: %s", sonos_player.zone_name, err + ) + + await asyncio.to_thread(set_crossfade) + + try: + await asyncio.to_thread( + sonos_player.soco.avTransport.SetNextAVTransportURI, + [("InstanceID", 0), ("NextURI", media.uri), ("NextURIMetaData", didl_metadata)], + timeout=60, + ) + except Exception as err: + self.logger.warning( + "Unable to enqueue next track on player: %s: %s", sonos_player.zone_name, err + ) + else: + self.logger.debug( + "Enqued next track (%s) to player %s", + media.title or media.uri, + sonos_player.soco.player_name, + ) + + async def poll_player(self, player_id: str) -> None: + """Poll player for state updates.""" + if player_id not in self.sonosplayers: + return + sonos_player = self.sonosplayers[player_id] + try: + # the check_poll logic will work out what endpoints need polling now + # based on when we last received info from the device + if needs_poll := await sonos_player.check_poll(): + await sonos_player.poll_speaker() + # always update the attributes + sonos_player.update_player(signal_update=needs_poll) + except ConnectionResetError as err: + raise PlayerUnavailableError from err + + async def _run_discovery(self) -> None: + """Discover Sonos players on the network.""" + if self._discovery_running: + return + + allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN) + if not (household_id := self.config.get_value(CONF_HOUSEHOLD_ID)): + household_id = "Sonos" + + def do_discover() -> None: + """Run discovery and add players in executor thread.""" + self._discovery_running = True + try: + self.logger.debug("Sonos discovery started...") + discovered_devices: set[SoCo] = discover( + timeout=30, household_id=household_id, allow_network_scan=allow_network_scan + ) + if discovered_devices is None: + discovered_devices = set() + # process new players + for soco in discovered_devices: + try: + self._add_player(soco) + except RequestException as err: + # player is offline + self.logger.debug("Failed to add SonosPlayer %s: %s", soco, err) + except Exception as err: + self.logger.warning( + "Failed to add SonosPlayer %s: %s", + soco, + err, + exc_info=err if self.logger.isEnabledFor(10) else None, + ) + finally: + self._discovery_running = False + + await self.mass.create_task(do_discover) + + def reschedule() -> None: + self._discovery_reschedule_timer = None + self.mass.create_task(self._run_discovery()) + + # reschedule self once finished + self._discovery_reschedule_timer = self.mass.loop.call_later(1800, reschedule) + + def _add_player(self, soco: SoCo) -> None: + """Add discovered Sonos player.""" + player_id = soco.uid + # check if existing player changed IP + if existing := self.sonosplayers.get(player_id): + if existing.soco.ip_address != soco.ip_address: + existing.update_ip(soco.ip_address) + return + if not soco.is_visible: + return + enabled = self.mass.config.get_raw_player_config_value(player_id, "enabled", True) + if not enabled: + self.logger.debug("Ignoring disabled player: %s", player_id) + return + + speaker_info = soco.get_speaker_info(True, timeout=7) + if soco.uid not in self.boot_counts: + self.boot_counts[soco.uid] = soco.boot_seqnum + self.logger.debug("Adding new player: %s", speaker_info) + transport_info = soco.get_current_transport_info() + play_state = transport_info["current_transport_state"] + if not (mass_player := self.mass.players.get(soco.uid)): + mass_player = Player( + player_id=soco.uid, + provider=self.instance_id, + type=PlayerType.PLAYER, + name=soco.player_name, + available=True, + powered=play_state in ("PLAYING", "TRANSITIONING"), + supported_features=PLAYER_FEATURES, + device_info=DeviceInfo( + model=speaker_info["model_name"], + address=soco.ip_address, + manufacturer="SONOS", + ), + needs_poll=True, + poll_interval=30, + ) + self.sonosplayers[player_id] = sonos_player = SonosPlayer( + self, + soco=soco, + mass_player=mass_player, + ) + if not soco.fixed_volume: + mass_player.supported_features = ( + *mass_player.supported_features, + PlayerFeature.VOLUME_SET, + ) + + self.mass.loop.call_soon_threadsafe( + self.mass.players.register_or_update, sonos_player.mass_player + ) + + +async def discover_household_ids(mass: MusicAssistant, prefer_s1: bool = True) -> list[str]: + """Discover the HouseHold ID of S1 speaker(s) the network.""" + if cache := await mass.cache.get("sonos_household_ids"): + return cache + household_ids: list[str] = [] + + def get_all_sonos_ips() -> set[SoCo]: + """Run full network discovery and return IP's of all devices found on the network.""" + discovered_zones: set[SoCo] | None + if discovered_zones := scan_network(multi_household=True): + return {zone.ip_address for zone in discovered_zones} + return set() + + all_sonos_ips = await asyncio.to_thread(get_all_sonos_ips) + for ip_address in all_sonos_ips: + async with mass.http_session.get(f"http://{ip_address}:1400/status/zp") as resp: + if resp.status == 200: + data = await resp.text() + if prefer_s1 and "2" in data: + continue + if "HouseholdControlID" in data: + household_id = data.split("")[1].split( + "" + )[0] + household_ids.append(household_id) + await mass.cache.set("sonos_household_ids", household_ids, 3600) + return household_ids diff --git a/music_assistant/server/providers/sonos/helpers.py b/music_assistant/server/providers/sonos_s1/helpers.py similarity index 100% rename from music_assistant/server/providers/sonos/helpers.py rename to music_assistant/server/providers/sonos_s1/helpers.py diff --git a/music_assistant/server/providers/sonos_s1/icon.png b/music_assistant/server/providers/sonos_s1/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..be274bf02697aa0353231c9bb17562e70754facd GIT binary patch literal 17303 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

tqw7qu_& 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 literal 0 HcmV?d00001 diff --git a/music_assistant/server/providers/sonos_s1/icon.svg b/music_assistant/server/providers/sonos_s1/icon.svg new file mode 100644 index 000000000..60d9e6776 --- /dev/null +++ b/music_assistant/server/providers/sonos_s1/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/music_assistant/server/providers/sonos_s1/manifest.json b/music_assistant/server/providers/sonos_s1/manifest.json new file mode 100644 index 000000000..32be16cb4 --- /dev/null +++ b/music_assistant/server/providers/sonos_s1/manifest.json @@ -0,0 +1,16 @@ +{ + "type": "player", + "domain": "sonos_s1", + "name": "SONOS S1", + "description": "SONOS Player provider for Music Assistant for the S1 hardware, based on the Soco library. Select this provider if you have Sonos devices on the S1 operating system (with the S1 Controller app)", + "codeowners": [ + "@music-assistant" + ], + "requirements": [ + "soco==0.30.4", + "defusedxml==0.7.1" + ], + "documentation": "https://music-assistant.io/player-support/sonos/", + "multi_instance": false, + "builtin": false +} diff --git a/music_assistant/server/providers/sonos/player.py b/music_assistant/server/providers/sonos_s1/player.py similarity index 95% rename from music_assistant/server/providers/sonos/player.py rename to music_assistant/server/providers/sonos_s1/player.py index 2f51588a3..fcdf07030 100644 --- a/music_assistant/server/providers/sonos/player.py +++ b/music_assistant/server/providers/sonos_s1/player.py @@ -162,7 +162,7 @@ def should_poll(self) -> bool: """Return if this player should be polled/pinged.""" if not self.available: return True - return (time.monotonic() - self._last_activity) > 120 + return (time.monotonic() - self._last_activity) > self.mass_player.poll_interval def setup(self) -> None: """Run initial setup of the speaker (NOT async friendly).""" @@ -265,22 +265,23 @@ async def unsubscribe(self) -> None: self.log_subscription_result(result, "Unsubscribe") self._subscriptions = [] - async def check_poll(self) -> None: + async def check_poll(self) -> bool: """Validate availability of the speaker based on recent activity.""" if not self.should_poll: - return + return False self.logger.log(VERBOSE_LOG_LEVEL, "Polling player for availability...") try: await asyncio.to_thread(self.ping) self._speaker_activity("ping") except SonosUpdateError: if not self.available: - return # already offline + return False # already offline self.logger.warning( "No recent activity and cannot reach %s, marking unavailable", self.zone_name, ) await self.offline() + return True def update_ip(self, ip_address: str) -> None: """Handle updated IP of a Sonos player (NOT async friendly).""" @@ -331,16 +332,17 @@ def update_player(self, signal_update: bool = True) -> None: # will detect changes to the player object itself self.mass.loop.call_soon_threadsafe(self.sonos_prov.mass.players.update, self.player_id) - @soco_error() - def poll_track_info(self) -> dict[str, Any]: - """Poll the speaker for current track info. + async def poll_speaker(self) -> None: + """Poll the speaker for updates.""" - Add converted position values (NOT async fiendly). - """ - track_info: dict[str, Any] = self.soco.get_current_track_info() - track_info[DURATION_SECONDS] = _timespan_secs(track_info.get("duration")) - track_info[POSITION_SECONDS] = _timespan_secs(track_info.get("position")) - return track_info + def _poll(): + """Poll the speaker for updates (NOT async friendly).""" + self.update_groups() + self.poll_media() + self.mass_player.volume_level = self.soco.volume + self.mass_player.volume_muted = self.soco.mute + + await asyncio.to_thread(_poll) @soco_error() def poll_media(self) -> None: @@ -487,32 +489,6 @@ def _handle_rendering_control_event(self, event: SonosEvent) -> None: if mute := variables.get("mute"): self.mass_player.volume_muted = mute["Master"] == "1" - if loudness := variables.get("loudness"): - # TODO: handle this is a better way - self.loudness = loudness["Master"] == "1" - with contextlib.suppress(KeyError): - self.mass.loop.call_soon_threadsafe( - self.mass.config.set_raw_player_config_value, - self.player_id, - "sonos_loudness", - loudness["Master"] == "1", - ) - - for int_var in ( - "bass", - "treble", - ): - if int_var in variables: - # TODO: handle this is a better way - setattr(self, int_var, variables[int_var]) - with contextlib.suppress(KeyError): - self.mass.loop.call_soon_threadsafe( - self.mass.config.set_raw_player_config_value, - self.player_id, - f"sonos_{int_var}", - variables[int_var], - ) - self.update_player() def _handle_zone_group_topology_event(self, event: SonosEvent) -> None: @@ -691,7 +667,7 @@ def _update_attributes(self) -> None: if x.player_id != self.player_id ) if self.sync_coordinator: - # player is syned to another player + # player is synced to another player self.mass_player.synced_to = self.sync_coordinator.player_id self.mass_player.group_childs = set() self.mass_player.active_source = self.sync_coordinator.mass_player.active_source @@ -714,7 +690,7 @@ def _set_basic_track_info(self, update_position: bool = False) -> None: self.uri = None try: - track_info = self.poll_track_info() + track_info = self._poll_track_info() except SonosUpdateError as err: self.logger.warning("Fetching track info failed: %s", err) return @@ -825,6 +801,17 @@ def _unjoin(self) -> None: self.soco.unjoin() self.sync_coordinator = None + @soco_error() + def _poll_track_info(self) -> dict[str, Any]: + """Poll the speaker for current track info. + + Add converted position values (NOT async fiendly). + """ + track_info: dict[str, Any] = self.soco.get_current_track_info() + track_info[DURATION_SECONDS] = _timespan_secs(track_info.get("duration")) + track_info[POSITION_SECONDS] = _timespan_secs(track_info.get("position")) + return track_info + def _convert_state(sonos_state: str) -> PlayerState: """Convert Sonos state to PlayerState.""" diff --git a/music_assistant/server/providers/soundcloud/icon.svg b/music_assistant/server/providers/soundcloud/icon.svg deleted file mode 100644 index 446f2a1ce..000000000 --- a/music_assistant/server/providers/soundcloud/icon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/music_assistant/server/providers/spotify/__init__.py b/music_assistant/server/providers/spotify/__init__.py index 0c4197052..dfd9fedf1 100644 --- a/music_assistant/server/providers/spotify/__init__.py +++ b/music_assistant/server/providers/spotify/__init__.py @@ -115,7 +115,7 @@ async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" - if not config.get_value(CONF_REFRESH_TOKEN): + if config.get_value(CONF_REFRESH_TOKEN) in (None, ""): msg = "Re-Authentication required" raise SetupFailedError(msg) return SpotifyProvider(mass, manifest, config) diff --git a/requirements_all.txt b/requirements_all.txt index 313f7c248..5fab497bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,6 +7,7 @@ aiohttp==3.10.4 aiojellyfin==0.10.0 aiorun==2024.8.1 aioslimproto==3.0.1 +aiosonos==0.1.1 aiosqlite==0.20.0 async-upnp-client==0.40.0 bidict==0.23.1 @@ -36,7 +37,6 @@ radios==0.3.1 shortuuid==1.0.13 snapcast==2.3.6 soco==0.30.4 -sonos-websocket==0.1.3 soundcloudpy==0.1.0 tidalapi==0.7.6 unidecode==1.3.8