/
/
/
1"""
2Sonos Player provider for Music Assistant for speakers running the S2 firmware.
3
4Based on the aiosonos library, which leverages the new websockets API of the Sonos S2 firmware.
5https://github.com/music-assistant/aiosonos
6"""
7
8from __future__ import annotations
9
10from typing import TYPE_CHECKING, Any
11
12from aiohttp import web
13from aiohttp.client_exceptions import ClientError
14from aiosonos.api.models import SonosCapability
15from aiosonos.utils import get_discovery_info
16from music_assistant_models.enums import IdentifierType
17from zeroconf import ServiceStateChange
18
19from music_assistant.constants import (
20 CONF_ENTRY_MANUAL_DISCOVERY_IPS,
21 MASS_LOGO_ONLINE,
22 VERBOSE_LOG_LEVEL,
23)
24from music_assistant.models.player_provider import PlayerProvider
25
26from .helpers import get_primary_ip_address
27from .player import SonosPlayer
28
29if TYPE_CHECKING:
30 from music_assistant_models.player import PlayerMedia
31 from zeroconf.asyncio import AsyncServiceInfo
32
33
34class SonosPlayerProvider(PlayerProvider):
35 """Sonos Player provider."""
36
37 async def handle_async_init(self) -> None:
38 """Handle async initialization of the provider."""
39 self.mass.streams.register_dynamic_route(
40 "/sonos_queue/*", self._handle_sonos_cloud_queue_request
41 )
42
43 async def loaded_in_mass(self) -> None:
44 """Call after the provider has been loaded."""
45 await super().loaded_in_mass()
46 # Handle config option for manual IP's
47 manual_ip_config: list[str] = self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
48 for ip_address in manual_ip_config:
49 try:
50 # get discovery info from SONOS speaker so we can provide an ID & other info
51 discovery_info = await get_discovery_info(self.mass.http_session_no_ssl, ip_address)
52 except ClientError as err:
53 self.logger.debug(
54 "Ignoring %s (manual IP) as it is not reachable: %s", ip_address, str(err)
55 )
56 continue
57 player_id = discovery_info["device"]["id"]
58 sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
59 sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
60 await sonos_player.setup()
61
62 async def unload(self, is_removed: bool = False) -> None:
63 """Handle close/cleanup of the provider."""
64 self.mass.streams.unregister_dynamic_route("/sonos_queue/*")
65
66 async def on_mdns_service_state_change(
67 self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
68 ) -> None:
69 """Handle MDNS service state callback."""
70 if state_change == ServiceStateChange.Removed:
71 # we don't listen for removed players here.
72 # instead we just wait for the player connection to fail
73 return
74 if "uuid" not in info.decoded_properties:
75 # not a S2 player
76 return
77 name = name.split("@", 1)[1] if "@" in name else name
78 player_id = info.decoded_properties["uuid"]
79 # handle update for existing device
80 if sonos_player := self.mass.players.get_player(player_id):
81 assert isinstance(sonos_player, SonosPlayer), (
82 "Player ID already exists but is not a SonosPlayer"
83 )
84 # if mass_player := sonos_player.mass_player:
85 cur_address = get_primary_ip_address(info)
86 if cur_address and cur_address != sonos_player.device_info.ip_address:
87 sonos_player.logger.debug(
88 "Address updated from %s to %s",
89 sonos_player.device_info.ip_address,
90 cur_address,
91 )
92 sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, cur_address)
93 if not sonos_player.connected:
94 self.logger.debug("Player back online: %s", sonos_player.display_name)
95 sonos_player.client.player_ip = cur_address
96 # schedule reconnect
97 sonos_player.reconnect()
98 self.mass.players.trigger_player_update(player_id)
99 return
100 # handle new player setup in a delayed task because mdns announcements
101 # can arrive in (duplicated) bursts
102 task_id = f"setup_sonos_{player_id}"
103 self.mass.call_later(5, self._setup_player, player_id, name, info, task_id=task_id)
104
105 async def _setup_player(self, player_id: str, name: str, info: AsyncServiceInfo) -> None:
106 """Handle setup of a new player that is discovered using mdns."""
107 assert not self.mass.players.get_player(player_id)
108 address = get_primary_ip_address(info)
109 if address is None:
110 return
111 if not self.mass.config.get_raw_player_config_value(player_id, "enabled", True):
112 self.logger.debug("Ignoring %s in discovery as it is disabled.", name)
113 return
114 try:
115 discovery_info = await get_discovery_info(self.mass.http_session_no_ssl, address)
116 except ClientError as err:
117 self.logger.debug("Ignoring %s in discovery as it is not reachable: %s", name, str(err))
118 return
119 display_name = discovery_info["device"].get("name") or name
120 if SonosCapability.PLAYBACK not in discovery_info["device"]["capabilities"]:
121 # this will happen for satellite speakers in a surround/stereo setup
122 self.logger.debug(
123 "Ignoring %s in discovery as it is a passive satellite.", display_name
124 )
125 return
126 self.logger.debug("Discovered Sonos device %s on %s", name, address)
127 sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
128 sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
129 await sonos_player.setup()
130
131 async def _handle_sonos_cloud_queue_request(self, request: web.Request) -> web.Response:
132 """
133 Handle the Sonos CloudQueue request.
134
135 https://docs.sonos.com/reference/itemwindow
136 """
137 self.logger.log(
138 VERBOSE_LOG_LEVEL,
139 "Cloud Queue request\n - path: %s\n - query: %s\n",
140 request.path,
141 request.query,
142 )
143 path_parts = request.path.strip("/").split("/")
144 if len(path_parts) != 4 or path_parts[0] != "sonos_queue":
145 return web.Response(status=404)
146 player_id = path_parts[1]
147 if not (sonos_player := self.mass.players.get_player(player_id)):
148 return web.Response(status=501)
149 if TYPE_CHECKING:
150 assert isinstance(sonos_player, SonosPlayer)
151 endpoint = path_parts[3]
152 if endpoint == "itemWindow":
153 return await self._handle_sonos_queue_itemwindow(sonos_player, request)
154 if endpoint == "version":
155 return await self._handle_sonos_queue_version(sonos_player, request)
156 if endpoint == "context":
157 return await self._handle_sonos_queue_context(sonos_player, request)
158 if endpoint == "timePlayed":
159 return await self._handle_sonos_queue_time_played(sonos_player, request)
160 return web.Response(status=404)
161
162 async def _handle_sonos_queue_itemwindow(
163 self, player: SonosPlayer, request: web.Request
164 ) -> web.Response:
165 """
166 Handle the Sonos CloudQueue ItemWindow endpoint.
167
168 https://docs.sonos.com/reference/itemwindow
169 """
170 context_version = request.query.get("contextVersion", "1")
171 queue_version = request.query.get("queueVersion", str(int(player.sonos_queue.last_updated)))
172 # because Sonos does not show our queue in the app anyways,
173 # we just return the previous, current and next item in the queue
174 items = list(player.sonos_queue.items)
175 result = {
176 "includesBeginningOfQueue": False,
177 "includesEndOfQueue": False,
178 "contextVersion": context_version,
179 "queueVersion": queue_version,
180 "items": [self._parse_sonos_queue_item(x) for x in items],
181 }
182 return web.json_response(result)
183
184 async def _handle_sonos_queue_version(
185 self, player: SonosPlayer, request: web.Request
186 ) -> web.Response:
187 """
188 Handle the Sonos CloudQueue Version endpoint.
189
190 https://docs.sonos.com/reference/version
191 """
192 context_version = request.query.get("contextVersion") or "1"
193 result = {
194 "contextVersion": context_version,
195 "queueVersion": str(int(player.sonos_queue.last_updated)),
196 }
197 return web.json_response(result)
198
199 async def _handle_sonos_queue_context(
200 self, player: SonosPlayer, request: web.Request
201 ) -> web.Response:
202 """
203 Handle the Sonos CloudQueue Context endpoint.
204
205 https://docs.sonos.com/reference/context
206 """
207 result = {
208 "contextVersion": "1",
209 "queueVersion": str(int(player.sonos_queue.last_updated)),
210 "container": {
211 "type": "trackList",
212 "name": "Music Assistant",
213 "imageUrl": MASS_LOGO_ONLINE,
214 "service": {"name": "Music Assistant", "id": "mass"},
215 "id": {
216 "serviceId": "mass",
217 "objectId": f"mass:{player.sonos_queue.items[-1].source_id}"
218 if player.sonos_queue.items
219 else "mass:unknown",
220 "accountId": "",
221 },
222 },
223 "reports": {
224 "sendUpdateAfterMillis": 1000,
225 "periodicIntervalMillis": 30000,
226 "sendPlaybackActions": True,
227 },
228 "playbackPolicies": {
229 "canSkip": True,
230 "limitedSkips": True,
231 "canSkipToItem": True, # unsure
232 "canSkipBack": True,
233 # seek needs to be disabled because we dont properly support range requests
234 "canSeek": False,
235 "canRepeat": False, # handled by MA queue controller
236 "canRepeatOne": False, # synced from MA queue controller
237 "canCrossfade": False, # handled by MA queue controller
238 "canShuffle": False, # handled by MA queue controller
239 },
240 }
241 return web.json_response(result)
242
243 async def _handle_sonos_queue_time_played(
244 self, player: SonosPlayer, request: web.Request
245 ) -> web.Response:
246 """
247 Handle the Sonos CloudQueue TimePlayed endpoint.
248
249 https://docs.sonos.com/reference/timeplayed
250 """
251 json_body = await request.json()
252 for item in json_body["items"]:
253 if item["type"] != "update":
254 continue
255 if "positionMillis" not in item:
256 continue
257 if player.current_media and player.current_media.queue_item_id == item["id"]:
258 player.update_elapsed_time(item["positionMillis"] / 1000)
259 break
260 return web.Response(status=204)
261
262 def _parse_sonos_queue_item(self, media: PlayerMedia) -> dict[str, Any]:
263 """Parse MusicAssistant PlayerMedia to a Sonos Media (queue) object."""
264 return {
265 "id": media.queue_item_id or media.uri,
266 "track": {
267 "type": "track",
268 "mediaUrl": media.uri,
269 "contentType": f"audio/{media.uri.split('.')[-1]}",
270 "service": {"name": "Music Assistant", "id": "mass"},
271 "name": media.title,
272 "imageUrl": media.image_url,
273 "durationMillis": int(media.duration * 1000) if media.duration else 0,
274 "artist": {
275 "name": media.artist,
276 }
277 if media.artist
278 else None,
279 "album": {
280 "name": media.album,
281 }
282 if media.album
283 else None,
284 },
285 }
286