From: Marcel van der Veldt Date: Mon, 26 Aug 2024 00:03:27 +0000 (+0200) Subject: More bugfixes for the new Sonos provider (#1612) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=193ed5f9156c50e3e7383207719eff88abb5d6a3;p=music-assistant-server.git More bugfixes for the new Sonos provider (#1612) --- diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 3739267e..720e7104 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -5,16 +5,16 @@ from __future__ import annotations import asyncio import logging import time -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING +import shortuuid from aiohttp import web +from aiosonos.api.models import ContainerType, MusicService, SonosCapability 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.exceptions import FailedCommand from aiosonos.utils import get_discovery_info from zeroconf import IPVersion, ServiceStateChange @@ -26,8 +26,6 @@ from music_assistant.common.models.config_entries import ( create_sample_rates_config_entry, ) from music_assistant.common.models.enums import ( - ConfigEntryType, - EventType, PlayerFeature, PlayerState, PlayerType, @@ -35,7 +33,6 @@ from music_assistant.common.models.enums import ( RepeatMode, ) from music_assistant.common.models.errors import PlayerCommandFailed -from music_assistant.common.models.event import MassEvent from music_assistant.common.models.player import DeviceInfo, Player, PlayerMedia from music_assistant.constants import ( CONF_CROSSFADE, @@ -74,8 +71,6 @@ SOURCE_SPOTIFY = "spotify" SOURCE_UNKNOWN = "unknown" SOURCE_RADIO = "radio" -CONF_SMART_AIRPLAY: Final[str] = "smart_airplay" - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig @@ -129,29 +124,11 @@ class SonosPlayer: 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) + # 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 we can just guess from the sonos player id (mac address). self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}" - self.stream_mode: str = "sonos" - - def get_linked_airplay_player(self, active_only: bool = True) -> Player | None: - """Return the linked airplay player if available/enabled.""" - if not self.mass.config.get_raw_player_config_value( - self.player_id, CONF_SMART_AIRPLAY, False - ): - return None - if not (airplay := self.mass.players.get(self.airplay_player_id)): - return None - if not airplay.available: - return None - if not active_only: - return airplay - if (airplay.powered and airplay.group_childs) or airplay.state == PlayerState.PLAYING: - # only when the airplay player has synced members, - # it makes sense to link it to the sonos player - return airplay - return None + self.queue_version: str = shortuuid.random(8) async def setup(self) -> None: """Handle setup of the player.""" @@ -198,12 +175,6 @@ class SonosPlayer: SonosEventType.PLAYER_UPDATED, ), ) - # register callback for airplay player state changes - self.mass.subscribe( - self._on_airplay_player_event, - (EventType.PLAYER_UPDATED, EventType.PLAYER_ADDED), - self.airplay_player_id, - ) async def connect(self) -> None: """Connect to the Sonos player.""" @@ -241,6 +212,39 @@ class SonosPlayer: await self.client.disconnect() self.logger.debug("Disconnected from player API") + async def cmd_stop(self) -> None: + """Send STOP command to given player.""" + if self.client.player.is_passive: + self.logger.debug("Ignore STOP command: Player is synced to another player.") + return + await self.client.player.group.stop() + + async def cmd_play(self) -> None: + """Send PLAY command to given player.""" + if self.client.player.is_passive: + self.logger.debug("Ignore STOP command: Player is synced to another player.") + return + await self.client.player.group.play() + + async def cmd_pause(self) -> None: + """Send PAUSE command to given player.""" + if self.client.player.is_passive: + self.logger.debug("Ignore STOP command: Player is synced to another player.") + return + await self.client.player.group.pause() + + async def cmd_volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + await self.client.player.set_volume(volume_level) + # sync volume level with airplay player if linked + if airplay := self.mass.players.get(self.airplay_player_id): + if airplay.state not in (PlayerState.PLAYING, PlayerState.PAUSED): + airplay.volume_level = volume_level + + async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: + """Send VOLUME MUTE command to given player.""" + await self.client.player.set_volume(muted=muted) + def update_attributes(self) -> None: # noqa: PLR0915 """Update the player attributes.""" if not self.mass_player: @@ -268,15 +272,11 @@ class SonosPlayer: # player is group coordinator active_group = self.client.player.group self.mass_player.group_childs = ( - self.client.player.group_members + set(self.client.player.group_members) if len(self.client.player.group_members) > 1 else set() ) self.mass_player.synced_to = None - if airplay := self.get_linked_airplay_player(False): - self.mass_player.group_childs.update( - x for x in airplay.group_childs if x != self.airplay_player_id - ) else: # player is group child (synced to another player) group_parent = self.prov.sonos_players.get(self.client.player.group.coordinator_id) @@ -289,15 +289,6 @@ class SonosPlayer: self.mass_player.active_source = active_group.coordinator_id self.mass_player.can_sync_with = () - if self.stream_mode == "airplay" and (airplay := self.get_linked_airplay_player(True)): - # linked airplay player is active, update media from there - self.mass_player.state = airplay.state - self.mass_player.powered = airplay.powered - self.mass_player.active_source = airplay.active_source - self.mass_player.elapsed_time = airplay.elapsed_time - self.mass_player.elapsed_time_last_updated = airplay.elapsed_time_last_updated - return - # map playback state self.mass_player.state = PLAYBACK_STATE_MAP[active_group.playback_state] if ( @@ -309,37 +300,33 @@ class SonosPlayer: self.mass_player.elapsed_time = active_group.position # figure out the active source based on the container - is_playing = active_group.playback_state in ( - SonosPlayBackState.PLAYBACK_STATE_PLAYING, - SonosPlayBackState.PLAYBACK_STATE_BUFFERING, - SonosPlayBackState.PLAYBACK_STATE_PAUSED, - ) - 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" - and active_group.playback_state == SonosPlayBackState.PLAYBACK_STATE_PLAYING + container_type = active_group.container_type + active_service = active_group.active_service + container = active_group.playback_metadata.get("container") + if container_type == ContainerType.LINEIN: + self.mass_player.active_source = SOURCE_LINE_IN + elif container_type == ContainerType.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.state in ( + PlayerState.PLAYING, + PlayerState.PAUSED, ): - self.mass_player.active_source = SOURCE_LINE_IN - elif is_playing and 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 is_playing and 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 is_playing and container.get("id", {}).get("serviceId") == "9": - self.mass_player.active_source = SOURCE_SPOTIFY - elif is_playing: - self.mass_player.active_source = container.get("type", SOURCE_UNKNOWN) + self.mass_player.active_source = airplay_player.active_source + else: + self.mass_player.active_source = SOURCE_AIRPLAY + elif container_type == ContainerType.STATION: + self.mass_player.active_source = SOURCE_RADIO + elif active_service == MusicService.SPOTIFY: + self.mass_player.active_source = SOURCE_SPOTIFY + elif active_service == MusicService.MUSIC_ASSISTANT: + if container and (object_id := container.get("id", {}).get("objectId")): + self.mass_player.active_source = object_id.split(":")[-1] else: self.mass_player.active_source = self.player_id + else: + # all our (known) options exhausted, fallback to unknown + self.mass_player.active_source = active_service if self.mass_player.active_source == self.player_id and active_group.active_session_id: # active source is the mass queue @@ -390,45 +377,6 @@ class SonosPlayer: self.update_attributes() self.mass.players.update(self.player_id) - def _on_airplay_player_event(self, event: MassEvent) -> None: - """Handle incoming event from linked airplay player.""" - if not self.mass.config.get_raw_player_config_value( - self.player_id, CONF_SMART_AIRPLAY, False - ): - return - if event.object_id != self.airplay_player_id: - return - airplay_player: Player = event.data - if ( - self.stream_mode == "sonos" - and airplay_player.group_childs - and self.mass_player.state == PlayerState.PLAYING - and airplay_player.state != PlayerState.PLAYING - and self.mass_player.active_source == self.player_id - and not self.mass_player.synced_to - ): - # linked airplay player became active, transfer playback to playback - self.logger.info( - "Linked Airplay player became active, transferring playback.", - ) - self.stream_mode = "airplay" - self.mass.create_task(self.mass.player_queues.resume(self.player_id)) - elif ( - self.stream_mode == "airplay" - and not airplay_player.group_childs - and airplay_player.state == PlayerState.PLAYING - ): - # linked airplay player became active, transfer playback to sonos - self.logger.info( - "Linked Airplay player became idle, transferring playback.", - ) - self.stream_mode = "sonos" - self.mass.create_task(self.mass.players.cmd_power(self.airplay_player_id, False)) - self.mass.create_task(self.mass.player_queues.resume(self.player_id)) - else: - self.update_attributes() - self.mass.players.update(self.player_id) - class SonosPlayerProvider(PlayerProvider): """Sonos Player provider.""" @@ -507,7 +455,7 @@ class SonosPlayerProvider(PlayerProvider): if not sonos_player.connected: 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) + await sonos_player.connect() self.mass.players.update(player_id) return # handle new player @@ -519,7 +467,7 @@ class SonosPlayerProvider(PlayerProvider): ) -> tuple[ConfigEntry, ...]: """Return Config Entries for the given player.""" base_entries = await super().get_player_config_entries(player_id) - if not (sonos_player := self.sonos_players.get(player_id)): + if not (self.sonos_players.get(player_id)): # most probably a syncgroup or the player is not yet discovered return (*base_entries, CONF_ENTRY_CROSSFADE, CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED) return ( @@ -527,88 +475,27 @@ class SonosPlayerProvider(PlayerProvider): CONF_ENTRY_CROSSFADE, CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, create_sample_rates_config_entry(48000, 24, 48000, 24, True), - ConfigEntry( - key=CONF_SMART_AIRPLAY, - type=ConfigEntryType.BOOLEAN, - label="Enable smart Airplay mode (experimental)", - description="Almost all newer Sonos speakers have Airplay support. " - "If you have the Airplay provider enabled in Music Assistant, " - "your Sonos speakers will also be detected as Airplay speakers, meaning " - "you can group them with other Airplay speakers." - "The downside of that is that you'll end up with a duplicated player. \n\n" - "This feature will try to circumvent that by linking the Sonos " - "player to the Airplay player: \n\nIf the AirPlay player is powered on, " - "and has other players grouped to it or is playing, any playback related commands " - "will be automatically redirected to the Airplay player. \n\n" - "Effectively this will mean that the Sonos player holds the queue and for " - "playback, Music Assistant will automatically pick either " - "the Sonos or the Airplay protocol.", - required=False, - default_value=False, - hidden=SonosCapability.AIRPLAY - not in sonos_player.discovery_info["device"]["capabilities"], - ), ) async def cmd_stop(self, player_id: str) -> None: """Send STOP command to given player.""" - 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.", - player_id, - ) - return - if airplay := sonos_player.get_linked_airplay_player(True): - # linked airplay player is active, redirect the command - self.logger.debug("Redirecting STOP command to linked airplay player.") - await self.mass.players.cmd_stop(airplay.player_id) - return - try: - await sonos_player.client.player.group.stop() - except FailedCommand: - # no session to stop, fallback to pause - await self.mass.players.cmd_pause(player_id) + if sonos_player := self.sonos_players[player_id]: + await sonos_player.cmd_stop() async def cmd_play(self, player_id: str) -> None: """Send PLAY command to given player.""" - 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 airplay := sonos_player.get_linked_airplay_player(True): - # linked airplay player is active, redirect the command - self.logger.debug("Redirecting PLAY command to linked airplay player.") - await self.mass.players.cmd_play(airplay.player_id) - return - await sonos_player.client.player.group.play() + if sonos_player := self.sonos_players[player_id]: + await sonos_player.cmd_play() async def cmd_pause(self, player_id: str) -> None: """Send PAUSE command to given player.""" - 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 airplay := sonos_player.get_linked_airplay_player(True): - # linked airplay player is active, redirect the command - self.logger.debug("Redirecting PAUSE command to linked airplay player.") - await self.mass.players.cmd_pause(airplay.player_id) - return - await sonos_player.client.player.group.pause() + if sonos_player := self.sonos_players[player_id]: + await sonos_player.cmd_pause() async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: """Send VOLUME_SET command to given player.""" - sonos_player = self.sonos_players[player_id] - await sonos_player.client.player.set_volume(volume_level) - # sync volume level with airplay player if linked - if airplay := sonos_player.get_linked_airplay_player(False): - airplay.volume_level = volume_level + if sonos_player := self.sonos_players[player_id]: + await sonos_player.cmd_volume_set(volume_level) async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: """Send VOLUME MUTE command to given player.""" @@ -628,13 +515,9 @@ class SonosPlayerProvider(PlayerProvider): 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] - if airplay_child_ids := [x for x in child_player_ids if x.startswith("ap")]: - # handle airplay players - await self.mass.players.cmd_sync_many(sonos_player.airplay_player_id, airplay_child_ids) - if sonos_child_ids := [x for x in child_player_ids if not x.startswith("ap")]: - await sonos_player.client.player.group.modify_group_members( - player_ids_to_add=sonos_child_ids, player_ids_to_remove=[] - ) + 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. @@ -653,6 +536,7 @@ class SonosPlayerProvider(PlayerProvider): ) -> None: """Handle PLAY MEDIA on given player.""" sonos_player = self.sonos_players[player_id] + sonos_player.queue_version = shortuuid.random(8) mass_player = self.mass.players.get(player_id) if sonos_player.client.player.is_passive: # this should be already handled by the player manager, but just in case... @@ -662,32 +546,19 @@ class SonosPlayerProvider(PlayerProvider): ) raise PlayerCommandFailed(msg) - if airplay := sonos_player.get_linked_airplay_player(True): - # linked airplay player is active, redirect the command - self.logger.debug("Redirecting PLAY_MEDIA command to linked airplay player.") - self.stream_mode = "airplay" - mass_player.active_source = airplay.player_id - # TODO: sonos has an annoying bug where it looses its sync childs - # when airplay playback is started - await self.mass.players.play_media(airplay.player_id, media) - return - if media.queue_id: # create a sonos cloud queue and load it - self.stream_mode = "sonos" await sonos_player.client.player.group.create_playback_session() - mass_queue = self.mass.player_queues.get(media.queue_id) 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=mass_queue.queue_version, + queue_version=sonos_player.queue_version, ) return # play a single uri/url - self.stream_mode = "sonos" await sonos_player.client.player.group.play_stream_url( media.uri, {"name": media.title, "type": "track"} ) @@ -695,12 +566,6 @@ class SonosPlayerProvider(PlayerProvider): 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.sonos_players[player_id] - if airplay := sonos_player.get_linked_airplay_player(True): - # linked airplay player is active, redirect the command - self.logger.debug("Redirecting ENQUEUE_NEXT command to linked airplay player.") - await self.mass.players.enqueue_next_media(airplay.player_id, media) - return - 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 @@ -736,10 +601,12 @@ class SonosPlayerProvider(PlayerProvider): self.logger.debug( "Playing announcement %s using websocket audioclip on %s", announcement.uri, - sonos_player.zone_name, + sonos_player.mass_player.display_name, ) volume_level = self.mass.players.get_announcement_volume(player_id, volume_level) - await sonos_player.client.player.play_audio_clip(announcement.uri, volume_level) + await sonos_player.client.player.play_audio_clip( + announcement.uri, volume_level, name="Announcement" + ) async def _setup_player(self, player_id: str, name: str, info: AsyncServiceInfo) -> None: """Handle setup of a new player that is discovered using mdns.""" @@ -788,7 +655,9 @@ class SonosPlayerProvider(PlayerProvider): if item_id := request.query.get("itemId"): queue_index = self.mass.player_queues.index_by_id(mass_queue.queue_id, item_id) else: - queue_index = mass_queue.current_index or 0 + queue_index = mass_queue.current_index + if queue_index is None: + return web.Response(status=501) offset = max(queue_index - previous_window_size, 0) queue_items = self.mass.player_queues.items( sonos_player_id, @@ -813,14 +682,15 @@ class SonosPlayerProvider(PlayerProvider): else None, "durationMillis": item.duration * 1000 if item.duration else None, "artist": { - "name": item.media_item.artist_str, + "name": artist_str, } - if item.media_item and hasattr(item.media_item, "artist_str") + if item.media_item + and (artist_str := getattr(item.media_item, "artist_str", None)) else None, "album": { - "name": item.media_item.album.name, + "name": album.name, } - if item.media_item and hasattr(item.media_item, "album") + if item.media_item and (album := getattr(item.media_item, "album", None)) else None, "quality": { "bitDepth": item.streamdetails.audio_format.bit_depth, @@ -852,10 +722,10 @@ class SonosPlayerProvider(PlayerProvider): self.logger.log(VERBOSE_LOG_LEVEL, "Cloud Queue Version request: %s", request.query) sonos_playback_id = request.headers["X-Sonos-Playback-Id"] sonos_player_id = sonos_playback_id.split(":")[0] - if not (mass_queue := self.mass.player_queues.get_active_queue(sonos_player_id)): + if not (sonos_player := self.sonos_players.get(sonos_player_id)): return web.Response(status=501) context_version = request.query.get("contextVersion") or "1" - queue_version = mass_queue.queue_version + queue_version = sonos_player.queue_version result = {"contextVersion": context_version, "queueVersion": queue_version} return web.json_response(result) @@ -870,9 +740,11 @@ class SonosPlayerProvider(PlayerProvider): 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 (sonos_player := self.sonos_players.get(sonos_player_id)): + return web.Response(status=501) result = { "contextVersion": "1", - "queueVersion": mass_queue.queue_version, + "queueVersion": sonos_player.queue_version, "container": { "type": "playlist", "name": "Music Assistant", @@ -917,10 +789,10 @@ class SonosPlayerProvider(PlayerProvider): 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 (mass_queue := self.mass.player_queues.get(mass_player.active_source)): + if not (sonos_player := self.sonos_players.get(sonos_player_id)): return web.Response(status=501) for item in json_body["items"]: - if item["queueVersion"] != mass_queue.queue_version: + if item["queueVersion"] != sonos_player.queue_version: continue if "positionMillis" not in item: continue diff --git a/music_assistant/server/providers/sonos/manifest.json b/music_assistant/server/providers/sonos/manifest.json index 06762142..d952d53e 100644 --- a/music_assistant/server/providers/sonos/manifest.json +++ b/music_assistant/server/providers/sonos/manifest.json @@ -4,7 +4,7 @@ "name": "SONOS", "description": "SONOS Player provider for Music Assistant.", "codeowners": ["@music-assistant"], - "requirements": ["aiosonos==0.1.2"], + "requirements": ["aiosonos==0.1.3"], "documentation": "https://music-assistant.io/player-support/sonos/", "multi_instance": false, "builtin": false, diff --git a/requirements_all.txt b/requirements_all.txt index b2cbb183..190356eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ aiohttp==3.10.4 aiojellyfin==0.10.1 aiorun==2024.8.1 aioslimproto==3.0.1 -aiosonos==0.1.2 +aiosonos==0.1.3 aiosqlite==0.20.0 async-upnp-client==0.40.0 bidict==0.23.1