spotifywebapiPython 1.0.242__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. spotifywebapipython/__init__.py +35 -0
  2. spotifywebapipython/const.py +114 -0
  3. spotifywebapipython/docs/__init__.html +2156 -0
  4. spotifywebapipython/docs/codecopy.js +70 -0
  5. spotifywebapipython/docs/index.html +514 -0
  6. spotifywebapipython/docs/search.js +46 -0
  7. spotifywebapipython/docs/spotifywebapipython/const.html +636 -0
  8. spotifywebapipython/docs/spotifywebapipython/saappmessages.html +540 -0
  9. spotifywebapipython/docs/spotifywebapipython/sautils.html +620 -0
  10. spotifywebapipython/docs/spotifywebapipython/spotifyapierror.html +341 -0
  11. spotifywebapipython/docs/spotifywebapipython/spotifyapimessage.html +598 -0
  12. spotifywebapipython/docs/spotifywebapipython/spotifyauthtoken.html +612 -0
  13. spotifywebapipython/docs/spotifywebapipython/spotifyclient.html +16557 -0
  14. spotifywebapipython/docs/spotifywebapipython/spotifydiscovery.html +518 -0
  15. spotifywebapipython/docs/spotifywebapipython/spotifywebapiauthenticationerror.html +421 -0
  16. spotifywebapipython/docs/spotifywebapipython/spotifywebapierror.html +400 -0
  17. spotifywebapipython/docs/spotifywebapipython/spotifywebplayertoken.html +635 -0
  18. spotifywebapipython/docs/spotifywebapipython.ico +0 -0
  19. spotifywebapipython/docs/spotifywebapipython_OLD.ico +0 -0
  20. spotifywebapipython/models/__init__.py +121 -0
  21. spotifywebapipython/models/album.py +170 -0
  22. spotifywebapipython/models/albumpagesaved.py +85 -0
  23. spotifywebapipython/models/albumpagesimplified.py +88 -0
  24. spotifywebapipython/models/albumsaved.py +102 -0
  25. spotifywebapipython/models/albumsimplified.py +308 -0
  26. spotifywebapipython/models/artist.py +146 -0
  27. spotifywebapipython/models/artistinfo.py +282 -0
  28. spotifywebapipython/models/artistinfotourevent.py +127 -0
  29. spotifywebapipython/models/artistpage.py +70 -0
  30. spotifywebapipython/models/artistsimplified.py +163 -0
  31. spotifywebapipython/models/audiobook.py +87 -0
  32. spotifywebapipython/models/audiobookpagesimplified.py +88 -0
  33. spotifywebapipython/models/audiobooksimplified.py +362 -0
  34. spotifywebapipython/models/audiofeatures.py +333 -0
  35. spotifywebapipython/models/author.py +67 -0
  36. spotifywebapipython/models/category.py +168 -0
  37. spotifywebapipython/models/categorypage.py +70 -0
  38. spotifywebapipython/models/chapter.py +84 -0
  39. spotifywebapipython/models/chapterpagesimplified.py +88 -0
  40. spotifywebapipython/models/chaptersimplified.py +387 -0
  41. spotifywebapipython/models/context.py +113 -0
  42. spotifywebapipython/models/copyright.py +79 -0
  43. spotifywebapipython/models/device.py +301 -0
  44. spotifywebapipython/models/episode.py +84 -0
  45. spotifywebapipython/models/episodepagesaved.py +85 -0
  46. spotifywebapipython/models/episodepagesimplified.py +88 -0
  47. spotifywebapipython/models/episodesaved.py +99 -0
  48. spotifywebapipython/models/episodesimplified.py +370 -0
  49. spotifywebapipython/models/explicitcontent.py +80 -0
  50. spotifywebapipython/models/externalids.py +91 -0
  51. spotifywebapipython/models/externalurls.py +104 -0
  52. spotifywebapipython/models/followers.py +81 -0
  53. spotifywebapipython/models/imageobject.py +193 -0
  54. spotifywebapipython/models/imagevibrantcolors.py +182 -0
  55. spotifywebapipython/models/linkedfrom.py +165 -0
  56. spotifywebapipython/models/narrator.py +67 -0
  57. spotifywebapipython/models/owner.py +162 -0
  58. spotifywebapipython/models/pageobject.py +343 -0
  59. spotifywebapipython/models/playeractions.py +196 -0
  60. spotifywebapipython/models/playerlastplayedinfo.py +334 -0
  61. spotifywebapipython/models/playerplaystate.py +467 -0
  62. spotifywebapipython/models/playerqueueinfo.py +207 -0
  63. spotifywebapipython/models/playhistory.py +136 -0
  64. spotifywebapipython/models/playhistorypage.py +85 -0
  65. spotifywebapipython/models/playlist.py +125 -0
  66. spotifywebapipython/models/playlistpage.py +85 -0
  67. spotifywebapipython/models/playlistpagesimplified.py +107 -0
  68. spotifywebapipython/models/playlistsimplified.py +334 -0
  69. spotifywebapipython/models/playlisttrack.py +143 -0
  70. spotifywebapipython/models/playlisttracksummary.py +80 -0
  71. spotifywebapipython/models/recommendationseed.py +135 -0
  72. spotifywebapipython/models/restrictions.py +86 -0
  73. spotifywebapipython/models/resumepoint.py +79 -0
  74. spotifywebapipython/models/searchresponse.py +391 -0
  75. spotifywebapipython/models/searchresultbase.py +299 -0
  76. spotifywebapipython/models/show.py +87 -0
  77. spotifywebapipython/models/showpagesaved.py +85 -0
  78. spotifywebapipython/models/showpagesimplified.py +88 -0
  79. spotifywebapipython/models/showsaved.py +99 -0
  80. spotifywebapipython/models/showsimplified.py +320 -0
  81. spotifywebapipython/models/spotifyconnectdevice.py +321 -0
  82. spotifywebapipython/models/spotifyconnectdevices.py +441 -0
  83. spotifywebapipython/models/track.py +142 -0
  84. spotifywebapipython/models/trackpage.py +110 -0
  85. spotifywebapipython/models/trackpagesaved.py +85 -0
  86. spotifywebapipython/models/trackpagesimplified.py +88 -0
  87. spotifywebapipython/models/trackrecommendations.py +88 -0
  88. spotifywebapipython/models/tracksaved.py +99 -0
  89. spotifywebapipython/models/tracksimplified.py +398 -0
  90. spotifywebapipython/models/userprofile.py +184 -0
  91. spotifywebapipython/models/userprofilesimplified.py +206 -0
  92. spotifywebapipython/models/zeroconfdiscoveryresult.py +592 -0
  93. spotifywebapipython/models/zeroconfproperty.py +93 -0
  94. spotifywebapipython/oauthcli/__init__.py +7 -0
  95. spotifywebapipython/oauthcli/authclient.py +1229 -0
  96. spotifywebapipython/saappmessages.py +86 -0
  97. spotifywebapipython/sautils.py +285 -0
  98. spotifywebapipython/spotifyapierror.py +66 -0
  99. spotifywebapipython/spotifyapimessage.py +269 -0
  100. spotifywebapipython/spotifyauthtoken.py +264 -0
  101. spotifywebapipython/spotifyclient.py +17885 -0
  102. spotifywebapipython/spotifyconnect/__init__.py +19 -0
  103. spotifywebapipython/spotifyconnect/spotifyconnectdeviceeventargs.py +67 -0
  104. spotifywebapipython/spotifyconnect/spotifyconnectdirectorytask.py +2564 -0
  105. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastapptask.py +467 -0
  106. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastcontroller.py +701 -0
  107. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastlistener.py +359 -0
  108. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastmultizonelistener.py +212 -0
  109. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfexceptions.py +20 -0
  110. spotifywebapipython/spotifyconnect/spotifyconnectzeroconflistener.py +335 -0
  111. spotifywebapipython/spotifydiscovery.py +473 -0
  112. spotifywebapipython/spotifymediatypes.py +53 -0
  113. spotifywebapipython/spotifywebapiauthenticationerror.py +122 -0
  114. spotifywebapipython/spotifywebapierror.py +106 -0
  115. spotifywebapipython/spotifywebplayertoken.py +743 -0
  116. spotifywebapipython/vibrant/__init__.py +17 -0
  117. spotifywebapipython/vibrant/generator.py +261 -0
  118. spotifywebapipython/vibrant/image.py +100 -0
  119. spotifywebapipython/vibrant/main.py +34 -0
  120. spotifywebapipython/vibrant/models.py +93 -0
  121. spotifywebapipython/vibrant/utils.py +56 -0
  122. spotifywebapipython/zeroconfapi/__init__.py +23 -0
  123. spotifywebapipython/zeroconfapi/authenticationtypes.py +38 -0
  124. spotifywebapipython/zeroconfapi/blobbuilder.py +370 -0
  125. spotifywebapipython/zeroconfapi/credentials.py +37 -0
  126. spotifywebapipython/zeroconfapi/cryptodiffiehellman.py +89 -0
  127. spotifywebapipython/zeroconfapi/helpers.py +64 -0
  128. spotifywebapipython/zeroconfapi/spotifyzeroconfapierror.py +108 -0
  129. spotifywebapipython/zeroconfapi/zeroconfconnect.py +1327 -0
  130. spotifywebapipython/zeroconfapi/zeroconfgetinfo.py +572 -0
  131. spotifywebapipython/zeroconfapi/zeroconfgetinfoalias.py +109 -0
  132. spotifywebapipython/zeroconfapi/zeroconfgetinfodrmmediaformat.py +90 -0
  133. spotifywebapipython/zeroconfapi/zeroconfresponse.py +195 -0
  134. spotifywebapipython-1.0.242.dist-info/LICENSE +22 -0
  135. spotifywebapipython-1.0.242.dist-info/METADATA +219 -0
  136. spotifywebapipython-1.0.242.dist-info/RECORD +138 -0
  137. spotifywebapipython-1.0.242.dist-info/WHEEL +5 -0
  138. spotifywebapipython-1.0.242.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1327 @@
1
+ # external package imports.
2
+ import json
3
+ import os.path
4
+ import requests
5
+ from requests.models import Response
6
+ import time
7
+
8
+ # our package imports.
9
+ from .blobbuilder import BlobBuilder
10
+ from .credentials import Credentials, AuthenticationTypes
11
+ from .helpers import int_to_b64str, b64str_to_bytes
12
+ from .spotifyzeroconfapierror import SpotifyZeroconfApiError
13
+ from .zeroconfresponse import ZeroconfResponse
14
+ from .zeroconfgetinfo import ZeroconfGetInfo
15
+ from ..saappmessages import SAAppMessages
16
+ from ..sautils import export, validateDelay
17
+ from ..spotifyauthtoken import SpotifyAuthToken
18
+ from ..spotifyapierror import SpotifyApiError
19
+ from ..spotifywebapierror import SpotifyWebApiError
20
+ from ..oauthcli.authclient import AuthClient
21
+ from ..const import (
22
+ SPOTIFY_API_AUTHORIZE_URL,
23
+ SPOTIFY_API_TOKEN_URL,
24
+ SPOTIFY_DESKTOP_APP_CLIENT_ID,
25
+ SPOTIFYWEBAPIPYTHON_TOKEN_CACHE_FILE,
26
+ TRACE_METHOD_RESULT,
27
+ TRACE_MSG_DELAY_DEVICE,
28
+ )
29
+ from ..sautils import passwordMaskString
30
+
31
+ # get smartinspect logger reference; create a new session for this module name.
32
+ from smartinspectpython.siauto import SIAuto, SILevel, SISession, SIMethodParmListContext
33
+ import logging
34
+ _logsi:SISession = SIAuto.Si.GetSession(__name__)
35
+ if (_logsi == None):
36
+ _logsi = SIAuto.Si.AddSession(__name__, True)
37
+ _logsi.SystemLogger = logging.getLogger(__name__)
38
+
39
+
40
+ @export
41
+ class ZeroconfConnect:
42
+ """
43
+ Interfaces with the Spotify Connect Zeroconf API to add and remove a device to / from the
44
+ Spotify Connect active device list.
45
+
46
+ More information about Spotify Connect Zeroconf API can be found here:
47
+ https://developer.spotify.com/documentation/commercial-hardware/implementation/guides/zeroconf
48
+
49
+ Inspiration and some of the blob encryption encoding was derived from the [zerospot](https://github.com/maraid/zerospot)
50
+ GitHub repository.
51
+ """
52
+
53
+ SPOTIFY_CONNECT_CLIENT_VERSION_DEFAULT:str = '2.7.1'
54
+ """
55
+ Spotify Connect default version string ('2.7.1').
56
+ """
57
+
58
+
59
+ def __init__(self,
60
+ hostIpAddress:str,
61
+ hostIpPort:int,
62
+ cpath:str,
63
+ version:str=None,
64
+ useSSL:bool=False,
65
+ tokenStorageDir:str=None,
66
+ tokenStorageFile:str=None,
67
+ tokenAuthInBrowser:bool=False,
68
+ ) -> None:
69
+ """
70
+ Initializes a new instance of the class.
71
+
72
+ Args:
73
+ hostIpAddress (str):
74
+ IP address or alias at which the Spotify Connect Zeroconf API can be reached
75
+ on the Spotify Connect device (e.g. "192.168.1.81", "Bose-SM2-341513fbeeae.local.", etc).
76
+ hostIpPort (int):
77
+ Port number (as an integer) at which the Spotify Connect Zeroconf API can be reached
78
+ on the Spotify Connect device (e.g. "8200").
79
+ cpath (str):
80
+ Spotify Connect Zeroconf API CPath property value (e.g. "/zc").
81
+ version (str):
82
+ Spotify Connect Zeroconf API version number that the device supports (e.g. "2.10.0").
83
+ Default is null.
84
+ useSSL (bool):
85
+ True if the host device utilizes HTTPS Secure Sockets Layer (SSL) support;
86
+ otherwise, False to utilize HTTP.
87
+ Default is False (HTTP).
88
+ tokenStorageDir (str):
89
+ The directory path that will contain the Token Cache file.
90
+ This is used for Spotify Connect devices that utilize the `authorization_code` token type.
91
+ A null value will default to the platform specific storage location:
92
+ Example for Windows OS = `C:\\ProgramData\\SpotifyWebApiPython`
93
+ tokenStorageFile (str):
94
+ The filename and extension of the Token Cache file.
95
+ Default is `SpotifyWebApiPython_tokens.json`.
96
+ tokenAuthInBrowser (bool):
97
+ True to allow authorization access token to be authorized if necessary via an
98
+ interactive browser; otherwise, False.
99
+ Default is False.
100
+
101
+ Set the `tokenAuthInBrowser` argument to False if your process does not allow user interaction
102
+ with the local default browser to approve authorization requests (e.g. if your process runs on
103
+ a server for instance). Set to True if you have a desktop / console application that allows
104
+ interactive access to the local default browser.
105
+ """
106
+ # validations.
107
+ if (useSSL is None) or (not isinstance(useSSL,bool)):
108
+ useSSL = False
109
+ if (version is None) or (not isinstance(version,str)):
110
+ version = ZeroconfConnect.SPOTIFY_CONNECT_CLIENT_VERSION_DEFAULT
111
+
112
+ # verify token storage filename was specified.
113
+ if tokenStorageFile is None:
114
+ tokenStorageFile = SPOTIFYWEBAPIPYTHON_TOKEN_CACHE_FILE
115
+
116
+ # initialize storage.
117
+ self._CPath:str = cpath
118
+ self._HostIpPort:int = hostIpPort
119
+ self._HostIpAddress:str = hostIpAddress
120
+ self._TokenStorageDir:str = tokenStorageDir
121
+ self._TokenStorageFile:str = tokenStorageFile
122
+ self._TokenAuthInBrowser:str = tokenAuthInBrowser
123
+ self._UseSSL:bool = useSSL
124
+ self._Version:str = version
125
+
126
+ # get uri used to access the Spotify Zeroconf API for the device.
127
+ self._Uri:str = self.GetEndpointUri()
128
+
129
+
130
+ @property
131
+ def CPath(self) -> str:
132
+ """
133
+ Path that points to the ZeroConf implementation on the server (e.g. "/zc").
134
+ """
135
+ return self._CPath
136
+
137
+
138
+ @property
139
+ def HostIpAddress(self) -> str:
140
+ """
141
+ IP address or alias at which the Spotify Connect Zeroconf API can be reached
142
+ on the Spotify Connect device (e.g. "192.168.1.81", "Bose-SM2-341513fbeeae.local.", etc).
143
+ """
144
+ return self._HostIpAddress
145
+
146
+
147
+ @property
148
+ def HostIpPort(self) -> int:
149
+ """
150
+ Port number (as an integer) at which the Spotify Connect Zeroconf API can be reached
151
+ on the Spotify Connect device (e.g. "8200").
152
+ """
153
+ return self._HostIpPort
154
+
155
+
156
+ @property
157
+ def TokenAuthInBrowser(self) -> bool:
158
+ """
159
+ True to allow authorization access token to be authorized if necessary via an
160
+ interactive browser; otherwise, False.
161
+ """
162
+ return self._TokenAuthInBrowser
163
+
164
+
165
+ @property
166
+ def TokenStorageDir(self) -> str:
167
+ """
168
+ The directory path that will contain the authorization token cache file.
169
+ """
170
+ return self._TokenStorageDir
171
+
172
+
173
+ @property
174
+ def TokenStorageFile(self) -> str:
175
+ """
176
+ The filename and extension of the authorization token cache file.
177
+ """
178
+ return self._TokenStorageFile
179
+
180
+
181
+ @property
182
+ def Uri(self) -> str:
183
+ """
184
+ Spotify Connect Zeroconf API URI that will be used to access the device (e.g. "http://192.168.1.81:8200/zc").
185
+ """
186
+ return self._Uri
187
+
188
+
189
+ @property
190
+ def UseSSL(self) -> str:
191
+ """
192
+ True if the host device utilizes HTTPS Secure Sockets Layer (SSL) support;
193
+ otherwise, False to utilize HTTP.
194
+
195
+ Default is False (HTTP).
196
+ """
197
+ return self._UseSSL
198
+
199
+
200
+ @property
201
+ def Version(self) -> str:
202
+ """
203
+ Spotify Connect Zeroconf API version number that the device supports (e.g. "", "1.0", "2.10.0", etc.).
204
+ """
205
+ return self._Version or ''
206
+
207
+
208
+ def _CheckResponseForErrors(self, response:Response, methodName:str, endpoint:str, tracePrefix:str='ZeroconfConnect') -> None:
209
+ """
210
+ Checks the Spotify Zeroconf API response for errors.
211
+
212
+ If no errors were found, then the request response data is converted to a JSON dictionary
213
+ and returned to the caller.
214
+
215
+ If an error result is found, then a `SpotifyZeroconfApiError` is raised to inform the
216
+ user of the error.
217
+
218
+ Args:
219
+ response (Response):
220
+ Spotify Web API http response object.
221
+ methodName (str):
222
+ method name that made the request (for trace purposes).
223
+ endpoint (str):
224
+ Endpoint that was requested (for trace purposes).
225
+ tracePrefix (str):
226
+ Prefix text used in trace log messages.
227
+ Default is "ZeroconfConnect".
228
+
229
+ Raises:
230
+ SpotifyZeroconfApiError:
231
+ If the Spotify Zeroconf API request response contains error information.
232
+ """
233
+ responseData:dict = None
234
+ responseUTF8:str = None
235
+
236
+ try:
237
+
238
+ # trace.
239
+ if _logsi.IsOn(SILevel.Debug):
240
+ _logsi.LogObject(SILevel.Debug, '%s http response object - type="%s", module="%s"' % (tracePrefix, type(response).__name__, type(response).__module__), response)
241
+
242
+ # trace.
243
+ if _logsi.IsOn(SILevel.Debug):
244
+ _logsi.LogObject(SILevel.Debug, "%s http response [%s-%s]: '%s' (response)" % (tracePrefix, response.status_code, response.reason, endpoint), response)
245
+ if (response.headers):
246
+ _logsi.LogCollection(SILevel.Debug, "%s http response [%s-%s]: '%s' (headers)" % (tracePrefix, response.status_code, response.reason, endpoint), response.headers.items())
247
+
248
+ # do we have response data?
249
+ if (response.content is not None) and (len(response.content) > 0):
250
+
251
+ # convert response to JSON object.
252
+ # ALL Spotify Connect Zeroconf API responses SHOULD be JSON format!
253
+ # do not use the "response.json()" method to parse JSON responses, as it is unreliable!
254
+ data = response.content.decode('utf-8')
255
+ responseData = json.loads(data)
256
+
257
+ if _logsi.IsOn(SILevel.Verbose):
258
+ if isinstance(responseData, dict):
259
+ _logsi.LogDictionary(SILevel.Verbose, "%s http response [%s-%s]: '%s' (json dict)" % (tracePrefix, response.status_code, response.reason, endpoint), responseData, prettyPrint=True)
260
+ elif isinstance(responseData, list):
261
+ _logsi.LogArray(SILevel.Verbose, "%s http response [%s-%s]: '%s' (json array)" % (tracePrefix, response.status_code, response.reason, endpoint), responseData)
262
+ else:
263
+ _logsi.LogObject(SILevel.Verbose, "%s http response [%s-%s]: '%s' (json object)" % (tracePrefix, response.status_code, response.reason, endpoint), responseData)
264
+
265
+ else:
266
+
267
+ # raise an exception, as we are expecting a JSON-formatted response!
268
+ errMessage = response.content.decode('utf-8')
269
+ raise SpotifyWebApiError(response.status_code, errMessage, methodName, response.reason, _logsi)
270
+
271
+ except SpotifyZeroconfApiError: raise # pass handled exceptions on thru
272
+ except SpotifyApiError: raise # pass handled exceptions on thru
273
+ except Exception as ex:
274
+
275
+ _logsi.LogException("{tracePrefix} http response could not be converted to JSON and will be converted to utf-8.\nConversion exception returned was:\n{ex}".format(tracePrefix=tracePrefix, ex=str(ex)), ex, logToSystemLogger=False)
276
+
277
+ # if json conversion failed, then convert to utf-8 response.
278
+ if response.content is not None:
279
+ responseUTF8 = response.content.decode('utf-8')
280
+ _logsi.LogText(SILevel.Error, "%s http response [%s-%s]: '%s' (utf-8)" % (tracePrefix, response.status_code, response.reason, endpoint), responseUTF8)
281
+
282
+ # at this point we don't know what Spotify Web Api returned!
283
+ # it's been found that some devices (Sonos, etc) do not return a proper JSON response.
284
+ # raise a new exception with the non-JSON response data if the http status is not 200 (OK).
285
+ if (response.status_code != 200):
286
+ raise SpotifyZeroconfApiError(response.status_code, responseUTF8, methodName, response.reason, _logsi)
287
+
288
+ # trace.
289
+ _logsi.LogVerbose("Spotify Connect Device (ip=%s) returned an invalid JSON response, but the http status code was 200 so we will ignore it" % (self._HostIpAddress))
290
+
291
+ # at this point we know that the response contains JSON data, as
292
+ # we would have raised an exception before this if we could not
293
+ # convert the response to JSON!
294
+
295
+ # now it's a matter of interpreting the JSON `Status` response to determine
296
+ # if the request failed or not. the following documents the Spotify
297
+ # Zeroconf API http status codes and JSON status codes.
298
+
299
+ # taken from: https://developer.spotify.com/documentation/commercial-hardware/implementation/guides/zeroconf
300
+ # HTTP Status codes
301
+ # Note It is permissible to send HTTP status code 200 in all cases, as long as a valid JSON reply is returned that contains the status, statusString, and spotifyError strings.
302
+ # The following table shows the different responses returned by the HTTP server to a client's request:
303
+ # Status name Status HTTP Status Status string Response to Usage
304
+ # Ok 101 200 OK or ERROR-OK All Successful operation
305
+ # Bad 102 400 ERROR-BAD-REQUEST All Web server problem or critically malformed request
306
+ # Unknown 103 500 ERROR-UNKNOWN All Fallback when no other error applies
307
+ # NotImplemented 104 501 ERROR-NOT-IMPLEMENTED All Server does not implement this feature
308
+ # LoginFailed 202 200 ERROR-LOGIN-FAILED addUser Spotify returned error when trying to login
309
+ # InvalidPublicKey 203 xxx ERROR-INVALID-PUBLICKEY addUser ZeroConf login failed due to an invalid public key
310
+ # MissingAction 301 400 ERROR-MISSING-ACTION All Web request has no action parameter
311
+ # InvalidAction 302 400 ERROR-INVALID-ACTION All Web request has unrecognized action parameter
312
+ # InvalidArguments 303 400 ERROR-INVALID-ARGUMENTS All Incorrect or insufficient arguments supplied for requested action
313
+ # SpotifyError 402 200 ERROR-SPOTIFY-ERROR All A Spotify API call returned an error not covered by other error messages
314
+
315
+ # let the calling function process the returned status.
316
+ return responseData
317
+
318
+
319
+ def Connect(
320
+ self,
321
+ username:str,
322
+ password:str,
323
+ loginId:str=None,
324
+ delay:float=0.50
325
+ ) -> ZeroconfResponse:
326
+ """
327
+ Calls the `addUser` Spotify Zeroconf API endpoint to issue a call to SpConnectionLoginBlob. If successful,
328
+ the associated device id is added to the Spotify Connect active device list for the specified user account.
329
+
330
+
331
+ Args:
332
+ username (str):
333
+ Spotify Connect user name to login with (e.g. "yourspotifyusername").
334
+ This MUST match the account name (or one of them) that was used to configure Spotify Connect
335
+ on the manufacturer device.
336
+ password (str):
337
+ Spotify Connect user password to login with.
338
+ loginId (str):
339
+ Spotify Connect login id to login with (e.g. "31l77fd87g8h9j00k89f07jf87ge").
340
+ This is also known as the canonical user id value.
341
+ This MUST be the value that relates to the `username` argument.
342
+ delay (float):
343
+ Time delay (in seconds) to wait AFTER issuing the command to the device.
344
+ This delay will give the spotify zeroconf api time to process the change before
345
+ another command is issued.
346
+ Default is 0.50; value range is 0 - 10.
347
+
348
+ Returns:
349
+ A `ZeroconfResponse` object that indicates success or failure (see notes below).
350
+
351
+ Raises:
352
+ SpotifyWebApiError:
353
+ If the Spotify Zeroconf API request response contains error information.
354
+
355
+ This will first issue a call to the `getInfo` Spotify Zeroconf API endpoint to retrieve the Spotify
356
+ Connect device id associated with the device. It will then issue a call to the `addUser` Spotify Zeroconf
357
+ API endpoint to add the user to the device.
358
+
359
+ Some Spotify Connect device types will be "woken up" with the initial `addUser` request; when this happens,
360
+ the initial request will return a 203 status (ERROR-INVALID-PUBLICKEY), and return a valid public key in the
361
+ response. This public key is then used to submit another `addUser` request to connect to the device with the
362
+ newly returned public key. When this happens, a `ZeroconfResponse` object is returned with the result of
363
+ the final `addUser` request. If only one `addUser` request is processed, then a `ZeroconfGetInfo` object is
364
+ returned with the result of the `addUser` request. Note that `ZeroconfGetInfo` inherits from `ZeroconfResponse`,
365
+ so you can always treat the result of this method as a `ZeroconfResponse` object.
366
+
367
+ The login (on the device) is performed asynchronously, so the return result only indicates whether the library
368
+ is able to perform the login attempt. You should issue a call to the Spotify Web API `Get Available Devices`
369
+ endpoint to check the current device list to ensure that the device id was successfully added or not.
370
+
371
+ Note that if you don't have a password setup for your Spotify account (e.g. you utilize the "Continue with Google"
372
+ or other non-password methods for login), then you will need to define a "device password" in order to use the
373
+ ZeroConf Connect service; use the [Spotify Set Device Password](https://www.spotify.com/uk/account/set-device-password/)
374
+ page to define a device password. You will then use your Spotify username and the device password to Connect
375
+ to the device.
376
+
377
+ <details>
378
+ <summary>Sample Code</summary>
379
+ ```python
380
+ .. include:: ../../docs/include/samplecode/ZeroconfConnect/Connect.py
381
+ ```
382
+ </details>
383
+ <details>
384
+ <summary>Sample Code - spotifyD</summary>
385
+ ```python
386
+ .. include:: ../../docs/include/samplecode/ZeroconfConnect/Connect_SPOTIFYD.py
387
+ ```
388
+ </details>
389
+ """
390
+ apiMethodName:str = 'Connect'
391
+ apiMethodParms:SIMethodParmListContext = None
392
+ result:ZeroconfResponse = None
393
+ response:Response = None
394
+
395
+ try:
396
+
397
+ # trace.
398
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
399
+ apiMethodParms.AppendKeyValue("HostIpAddress", self._HostIpAddress)
400
+ apiMethodParms.AppendKeyValue("HostIpPort", self._HostIpPort)
401
+ apiMethodParms.AppendKeyValue("CPath", self._CPath)
402
+ apiMethodParms.AppendKeyValue("Version", self._Version)
403
+ apiMethodParms.AppendKeyValue("Uri", self._Uri)
404
+ apiMethodParms.AppendKeyValue("loginId", loginId)
405
+ apiMethodParms.AppendKeyValue("username", username)
406
+ apiMethodParms.AppendKeyValue("password (with mask)", passwordMaskString(password))
407
+ apiMethodParms.AppendKeyValue("delay", delay)
408
+ _logsi.LogMethodParmList(SILevel.Verbose, "Connecting device to Spotify Connect (ip=%s:%s)" % (self._HostIpAddress, self._HostIpPort), apiMethodParms)
409
+
410
+ # validations.
411
+ delay = validateDelay(delay, 0.50, 10)
412
+
413
+ # get the current device id from the device via Spotify ZeroConf API `getInfo` endpoint.
414
+ info:ZeroconfGetInfo = self.GetInformation()
415
+
416
+ # execute the Spotify Zeroconf API addUser request.
417
+ responseData:dict = self._ConnectAddUser(
418
+ info,
419
+ username,
420
+ password,
421
+ loginId,
422
+ apiMethodName,
423
+ )
424
+
425
+ # process results.
426
+ # we will process the results with a `ZeroconfGetInfo` object, in case a publicKey was returned.
427
+ result = ZeroconfGetInfo(root=responseData)
428
+
429
+ # is the device fully available? The info.Availability property can be used to determine this.
430
+ # if the device is fully available (Availability=""), then the addUser request should return
431
+ # immediately - hopefully with a 0 status (OK) to denote the request was successul.
432
+ # if the device is not fully available (Availability="NOT-LOADED"), then the addUser request will
433
+ # probably return a 203 status (ERROR-INVALID-PUBLICKEY) along with a new PublicKey value.
434
+ # when this happens, we need to wait for the device to become fully available before we retry the
435
+ # addUser request again with the new public key.
436
+
437
+ # did we get a new PublicKey value?
438
+ if (result.Status == 203) and (result.StatusString == "ERROR-INVALID-PUBLICKEY"):
439
+
440
+ # trace.
441
+ _logsi.LogObject(SILevel.Verbose, '%s result (%s)' % (apiMethodName, type(result).__name__), result, excludeNonPublic=True)
442
+ _logsi.LogVerbose("Spotify Connect addUser request returned an ERROR-INVALID-PUBLICKEY response for Device id '%s'; addUser will be retried with the returned publicKey" % (info.DeviceId))
443
+
444
+ # wait for the device to be fully loaded.
445
+ # once this happens, the device Availability state will change from "NOT-LOADED" to "".
446
+ loopTotalDelay:float = 0
447
+ LOOP_DELAY:float = 0.25
448
+ LOOP_TIMEOUT:float = 5.0
449
+ while True:
450
+
451
+ # wait just a bit between device info queries.
452
+ _logsi.LogVerbose("Delaying for %s seconds to allow Spotify Connect device id '%s' to become available (currently '%s')" % (LOOP_DELAY, info.DeviceId, info.Availability))
453
+ time.sleep(LOOP_DELAY)
454
+ loopTotalDelay = loopTotalDelay + LOOP_DELAY
455
+
456
+ # get device information; if availability status changes then we are done.
457
+ info = self.GetInformation()
458
+ if info.Availability != "NOT-LOADED":
459
+ _logsi.LogVerbose("Spotify Connect Device id '%s' availability status changed to '%s' within %f seconds of initial addUser request" % (info.DeviceId, info.Availability, loopTotalDelay))
460
+ break
461
+
462
+ # only check so many times before we give up;
463
+ if (loopTotalDelay > LOOP_TIMEOUT):
464
+ _logsi.LogWarning("Timed out waiting for Spotify Connect device id '%s' availability to change from '%s'; gave up after %f seconds from initial addUser request" % (info.DeviceId, info.Availability, loopTotalDelay))
465
+ break
466
+
467
+ # now that the device is (hopefully) fully available, try the addUser request again.
468
+ responseData = self._ConnectAddUser(
469
+ info,
470
+ username,
471
+ password,
472
+ loginId,
473
+ apiMethodName,
474
+ )
475
+
476
+ # process results.
477
+ result = ZeroconfResponse(root=responseData)
478
+
479
+ # trace.
480
+ _logsi.LogObject(SILevel.Verbose, '%s result (%s)' % (apiMethodName, type(result).__name__), result, excludeNonPublic=True)
481
+
482
+ # if result status is not ok, then raise an exception.
483
+ if (result.Status != 101):
484
+ raise SpotifyZeroconfApiError(result.Status, result.ToString(), apiMethodName, result.StatusString, _logsi)
485
+
486
+ # give spotify zeroconf api time to process the change.
487
+ if delay > 0:
488
+ _logsi.LogVerbose(TRACE_MSG_DELAY_DEVICE % delay)
489
+ time.sleep(delay)
490
+
491
+ # return result to caller.
492
+ return result
493
+
494
+ except SpotifyZeroconfApiError: raise # pass handled exceptions on thru
495
+ except SpotifyApiError: raise # pass handled exceptions on thru
496
+ except Exception as ex:
497
+
498
+ # format unhandled exception.
499
+ raise SpotifyApiError(SAAppMessages.UNHANDLED_EXCEPTION.format(apiMethodName, str(ex)), ex, logsi=_logsi)
500
+
501
+ finally:
502
+
503
+ # close the response (if needed).
504
+ if response is not None:
505
+ response.close()
506
+
507
+ # trace.
508
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
509
+
510
+
511
+ def _ConnectAddUser(
512
+ self,
513
+ info:ZeroconfGetInfo,
514
+ username:str,
515
+ password:str,
516
+ loginId:str,
517
+ apiMethodName:str,
518
+ ) -> dict:
519
+ """
520
+ Execute the `addUser` request.
521
+ """
522
+ apiMethodName:str = '_ConnectAddUser'
523
+ apiMethodParms:SIMethodParmListContext = None
524
+
525
+ try:
526
+
527
+ # trace.
528
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
529
+ apiMethodParms.AppendKeyValue("username", username)
530
+ apiMethodParms.AppendKeyValue("password (with mask)", passwordMaskString(password))
531
+ apiMethodParms.AppendKeyValue("loginId", loginId)
532
+ apiMethodParms.AppendKeyValue("info.Availability", info.Availability)
533
+ apiMethodParms.AppendKeyValue("info.PublicKey", info.PublicKey)
534
+ apiMethodParms.AppendKeyValue("info.DeviceId", info.DeviceId)
535
+ apiMethodParms.AppendKeyValue("info.RemoteName", info.RemoteName)
536
+ apiMethodParms.AppendKeyValue("info.TokenType", info.TokenType)
537
+ apiMethodParms.AppendKeyValue("info.BrandDisplayName", info.BrandDisplayName)
538
+ apiMethodParms.AppendKeyValue("info.ModelDisplayName", info.ModelDisplayName)
539
+ _logsi.LogMethodParmList(SILevel.Verbose, "Issuing Spotify Connect Zeroconf addUser request (ip=%s:%s)" % (self._HostIpAddress, self._HostIpPort), apiMethodParms)
540
+
541
+ # set request endpoint.
542
+ endpoint:str = self.GetEndpoint('addUser')
543
+
544
+ # set request headers.
545
+ reqHeaders:dict = {
546
+ "Content-Type": "application/x-www-form-urlencoded",
547
+ "Connection": "close"
548
+ }
549
+
550
+ # prepare values for parsing.
551
+ infoBrandDisplayName:str = (info.BrandDisplayName or "").lower()
552
+ infoModelDisplayName:str = (info.ModelDisplayName or "").lower()
553
+
554
+ # set defaults used by most manufacturers - we will tailor these in the
555
+ # logic below for specific manufacturers / devices as required.
556
+ # - exclude origin device information from the addUser request.
557
+ # - tokenType should be set to 'default'.
558
+ includeOriginDeviceInfo:bool = False
559
+ tokenType:str = "default"
560
+
561
+ # include the origin device information if PublicKey="INVALID" (base64 encoded) and availability="NOT-LOADED".
562
+ if (info.PublicKey == "SU5WQUxJRA==") and (info.Availability == "NOT-LOADED"):
563
+ includeOriginDeviceInfo = True
564
+
565
+ # special processing for remotes that utilize librespot (raspotify, spotifyd, go-librespot, etc)
566
+ if (infoModelDisplayName in ["librespot","go-librespot"]):
567
+
568
+ # trace.
569
+ _logsi.LogVerbose("Spotify Connect token type is '%s' - using librespot authorization credentials to connect: '%s' (ip=%s)" % (info.TokenType, info.RemoteName, self._HostIpAddress))
570
+
571
+ # validations.
572
+ # note that userid and password are not needed by librespot, as both of those
573
+ # are contained in the librespot credentials object. we only need the loginId
574
+ # value for the credentials lookup.
575
+ if (loginId is None) or (not isinstance(loginId,str)):
576
+ raise SpotifyApiError(SAAppMessages.MSG_SPOTIFY_ACTIVATE_CREDENTIAL_REQUIRED % ("SpotifyConnectLoginId"), logsi=_logsi)
577
+
578
+ # we will use credentials from a librespot credentials.json file.
579
+ # this requires that the user copy the "credentials.json" file from the
580
+ # librespot cache location (e.g. "/home/user/.cache/spotifyd/credentials.json", etc)
581
+ # and place it in the same folder as the token storage file with a new name
582
+ # of "SpotifyWebApiPython_librespot_credentials.json".
583
+
584
+ # get credentials from librespot credentials file.
585
+ credentials:dict = self._LoadLibrespotCredentials(loginId)
586
+
587
+ # create credentials and builder objects.
588
+ librespot_username = credentials.get('username','')
589
+ librespot_password = b64str_to_bytes(credentials.get('auth_data',''))
590
+ credentials:Credentials = Credentials(librespot_username, librespot_password, AuthenticationTypes.STORED_SPOTIFY_CREDENTIALS)
591
+ builder = BlobBuilder(credentials, info.DeviceId, info.PublicKey)
592
+
593
+ # set defaults used by this token type:
594
+ # - clientKey should be the getInfo response PublicKey (base64 encoded).
595
+ # - include origin device information in the addUser request.
596
+ # - tokenType should be set to '' (not used by librespot).
597
+ clientKey:str = builder.dh_keys.PublicKeyBase64String
598
+ includeOriginDeviceInfo = True
599
+ tokenType = ''
600
+
601
+ # formulate the blob.
602
+ blob = builder.build()
603
+
604
+ # special processing for tokenType "authorization_code":
605
+ elif info.TokenType == 'authorization_code':
606
+
607
+ # trace.
608
+ _logsi.LogVerbose("Spotify Connect token type is '%s' - using Spotify Desktop Client OAuth2 token to connect: '%s' (ip=%s)" % (info.TokenType, info.RemoteName, self._HostIpAddress))
609
+
610
+ # validations.
611
+ if (loginId is None) or (not isinstance(loginId,str)):
612
+ raise SpotifyApiError(SAAppMessages.MSG_SPOTIFY_ACTIVATE_CREDENTIAL_REQUIRED % ("SpotifyConnectLoginId"), logsi=_logsi)
613
+ if (username is None) or (not isinstance(username,str)):
614
+ raise SpotifyApiError(SAAppMessages.MSG_SPOTIFY_ACTIVATE_CREDENTIAL_REQUIRED % ("SpotifyConnectUsername"), logsi=_logsi)
615
+ if (password is None) or (not isinstance(password,str)):
616
+ raise SpotifyApiError(SAAppMessages.MSG_SPOTIFY_ACTIVATE_CREDENTIAL_REQUIRED % ("SpotifyConnectPassword"), logsi=_logsi)
617
+
618
+ # create credentials and builder objects.
619
+ credentials:Credentials = Credentials(username, password, AuthenticationTypes.USER_PASS)
620
+ builder = BlobBuilder(credentials, info.DeviceId, info.PublicKey)
621
+
622
+ # set defaults used by token type "authorization_code".
623
+ # - set the clientKey value to null, as it is not used to encrypt / decrypt anything.
624
+ # - include origin device information in the addUser request.
625
+ # - set the tokenType to what is returned from the getInfo response.
626
+ clientKey = ''
627
+ includeOriginDeviceInfo = True
628
+ tokenType = info.TokenType
629
+
630
+ # get authorization_code access token.
631
+ blob:str = self._GetSpotifyConnectAuthorizationCodeToken(info, loginId=loginId)
632
+
633
+ else:
634
+
635
+ # trace.
636
+ _logsi.LogVerbose("Spotify Connect token type is '%s' - using Spotify username and password to connect: '%s' (ip=%s)" % (info.TokenType, info.RemoteName, self._HostIpAddress))
637
+
638
+ # validations.
639
+ if (loginId is None) or (not isinstance(loginId,str)):
640
+ raise SpotifyApiError(SAAppMessages.MSG_SPOTIFY_ACTIVATE_CREDENTIAL_REQUIRED % ("SpotifyConnectLoginId"), logsi=_logsi)
641
+ if (username is None) or (not isinstance(username,str)):
642
+ raise SpotifyApiError(SAAppMessages.MSG_SPOTIFY_ACTIVATE_CREDENTIAL_REQUIRED % ("SpotifyConnectUsername"), logsi=_logsi)
643
+ if (password is None) or (not isinstance(password,str)):
644
+ raise SpotifyApiError(SAAppMessages.MSG_SPOTIFY_ACTIVATE_CREDENTIAL_REQUIRED % ("SpotifyConnectPassword"), logsi=_logsi)
645
+
646
+ # create credentials and builder objects.
647
+ credentials:Credentials = Credentials(username, password, AuthenticationTypes.USER_PASS)
648
+ builder = BlobBuilder(credentials, info.DeviceId, info.PublicKey)
649
+
650
+ # set defaults used by this token type:
651
+ # - clientKey should be the getInfo response PublicKey (base64 encoded).
652
+ # - exclude origin device information from the addUser request.
653
+ # - tokenType should be set to 'default'.
654
+ clientKey:str = builder.dh_keys.PublicKeyBase64String
655
+ includeOriginDeviceInfo = False
656
+ tokenType = "default"
657
+
658
+ # include the origin device information for specified manufacturers.
659
+ if (infoBrandDisplayName == 'bluesound' and infoModelDisplayName == 'powernode'):
660
+ includeOriginDeviceInfo = True
661
+ tokenType = info.TokenType
662
+
663
+ # formulate the blob.
664
+ blob = builder.build()
665
+
666
+ # set request parameters.
667
+ reqData={
668
+ "action": "addUser",
669
+ "version": self.Version, # info.Version,
670
+ "tokenType": tokenType,
671
+ "clientKey": clientKey,
672
+ "loginId": loginId or '', # canonical login id (e.g. "31l77fd87g8h9j00k89f07jf87ge")
673
+ "userName": credentials.username.decode("ascii"), # canonical user name (e.g. "31l77fd87g8h9j00k89f07jf87ge")
674
+ "blob": blob,
675
+ }
676
+
677
+ # are we including origin device information (e.g. deviceName, deviceId)?
678
+ # the `deviceName` and `deviceId` keys are specified by some manufacturers for the
679
+ # `addUser` action. I am not sure if they are required or not, as they are not part
680
+ # of the Spotify Zeroconf API specification for the `addUser` action.
681
+ if (includeOriginDeviceInfo):
682
+ _logsi.LogVerbose("Origin deviceName and deviceId will be included with the addUser request for deviceId '%s'" % (info.DeviceId))
683
+ reqData['deviceName'] = builder._OriginDeviceName
684
+ reqData['deviceId'] = builder._OriginDeviceId
685
+
686
+ # if PublicKey="INVALID" and availability="NOT-LOADED" then adjust the addUser
687
+ # request to remove the blob and clientKey values. this will inform the device to
688
+ # send us back a PublicKey value, which can be used on a subsequent addUser request.
689
+ if (info.PublicKey == 'SU5WQUxJRA==') and (info.Availability == 'NOT-LOADED'):
690
+ _logsi.LogVerbose("Spotify Connect PublicKey='INVALID' and Availability='NOT-LOADED' detected for Device id '%s'; removing blob and clientKey values" % (info.DeviceId))
691
+ reqData['blob'] = ''
692
+ reqData['clientKey'] = ''
693
+
694
+ # trace.
695
+ _logsi.LogDictionary(SILevel.Debug, "ZeroconfConnect http request: '%s' (headers)" % (endpoint), reqHeaders)
696
+ _logsi.LogDictionary(SILevel.Verbose, "ZeroconfConnect http request: '%s' (data)" % (endpoint), reqData)
697
+
698
+ # execute spotify zeroconf api request.
699
+ response = requests.post(
700
+ self._Uri,
701
+ timeout=10,
702
+ headers=reqHeaders,
703
+ data=reqData # send data in POST request body
704
+ )
705
+
706
+ # check response for initial errors, and return json response.
707
+ responseData:dict = self._CheckResponseForErrors(response, apiMethodName, endpoint)
708
+ return responseData
709
+
710
+ finally:
711
+
712
+ # trace.
713
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
714
+
715
+
716
+ def _GetSpotifyConnectAuthorizationCodeToken(
717
+ self,
718
+ info:ZeroconfGetInfo,
719
+ loginId:str,
720
+ ) -> str:
721
+ """
722
+ Exchange a Spotify access token for an authorization_code access token.
723
+ The resulting token will be used as the `blob` argument for the Spotify Connect Zeroconf `addUser` request.
724
+ """
725
+ apiMethodName:str = '_GetSpotifyConnectAuthorizationCodeToken'
726
+ apiMethodParms:SIMethodParmListContext = None
727
+ tracePrefix:str = 'SpotifyConnectAuthorizationCodeToken exchange'
728
+
729
+ # based upon Fiddler trace, the tokenType "authorization_code" flow exchanges an
730
+ # access-token for an "authorization_code" token. it does this by calling
731
+ # "accounts.spotify.com/api/token" to exchange the spotify "login5" "client-token" value
732
+ # to an "authorization_code" token value, which is then passed on the Spotify Connect
733
+ # Zeroconf "adduser" call as the "blob" parameter.
734
+
735
+ try:
736
+
737
+ # trace.
738
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
739
+ apiMethodParms.AppendKeyValue("loginId", loginId)
740
+ apiMethodParms.AppendKeyValue("info.DeviceId", info.DeviceId)
741
+ apiMethodParms.AppendKeyValue("info.RemoteName", info.RemoteName)
742
+ apiMethodParms.AppendKeyValue("info.BrandDisplayName", info.BrandDisplayName)
743
+ apiMethodParms.AppendKeyValue("info.ModelDisplayName", info.ModelDisplayName)
744
+ apiMethodParms.AppendKeyValue("self.TokenAuthInBrowser", self.TokenAuthInBrowser)
745
+ _logsi.LogMethodParmList(SILevel.Verbose, "Preparing to generate a new authorization_code access token for Spotify Connect device: '%s' (%s)" % (info.RemoteName, info.DeviceId), apiMethodParms)
746
+
747
+ # set variables for spotify authorization_code token generation.
748
+ # the redirecy uri variables were obtained via Fiddler trace using Spotify Desktop App to initiate Spotify Connect to device.
749
+ redirectUriHost:str = '127.0.0.1'
750
+ redirectUriPort:str = 4381
751
+ redirectUriPath:str = '/login'
752
+ authorizationType:str = 'Authorization Code PKCE'
753
+
754
+ # Spotify Desktop App scopes requested for Spotify Connect (streaming only as of 2024/08/13)
755
+ SPOTIFY_SCOPES:list = \
756
+ [
757
+ 'streaming',
758
+ ]
759
+
760
+ # create oauth provider for spotify authentication code with pkce.
761
+ _logsi.LogVerbose('creating OAuth2 provider for Spotify Authentication Code with PKCE')
762
+ authClient:AuthClient = AuthClient(
763
+ authorizationType=authorizationType,
764
+ authorizationUrl=SPOTIFY_API_AUTHORIZE_URL,
765
+ tokenUrl=SPOTIFY_API_TOKEN_URL,
766
+ scope=SPOTIFY_SCOPES,
767
+ clientId=SPOTIFY_DESKTOP_APP_CLIENT_ID,
768
+ tokenStorageDir=self.TokenStorageDir,
769
+ tokenStorageFile=self.TokenStorageFile,
770
+ tokenProviderId='SpotifyWebApiAuthCodePkce',
771
+ tokenProfileId=loginId,
772
+ )
773
+
774
+ # raise an exception if the authorization token is not present, or the scope has changed.
775
+ # the user must create the authorization token outside of this process, as the Spotify
776
+ # authorization token is driven by responding to a web request for access using OAuth.
777
+ # as this process is running on a server, there is no way for the user to respond to the
778
+ # request (e.g. via browser nor command-line).
779
+ isAuthorized = authClient.IsAuthorized
780
+ _logsi.LogVerbose('Checking OAuth2 authorization status: IsAuthorized=%s' % isAuthorized)
781
+
782
+ if (isAuthorized == False):
783
+
784
+ # can the user interact with a local default browser to authorize the request?
785
+ if self.TokenAuthInBrowser:
786
+
787
+ # at this point, we need a new authorization token.
788
+ # the user has up to 2 minutes to respond to the request by copying / pasting the
789
+ # message / auth approval url from the system log into a browser window that is
790
+ # running on the local machine.
791
+ _logsi.LogVerbose('Preparing to retrieve a new OAuth2 authorization access token')
792
+ authClient.AuthorizeWithServer(
793
+ host=redirectUriHost,
794
+ port=redirectUriPort,
795
+ redirect_uri_path=redirectUriPath,
796
+ open_browser=True,
797
+ timeout_seconds=120
798
+ )
799
+
800
+ else:
801
+
802
+ # user cannot approve the request - raise an exception.
803
+ raise SpotifyZeroconfApiError(401, 'Spotify Desktop Client Application access token was not authorized', apiMethodName, 'Token Not Authorized', _logsi)
804
+
805
+ else:
806
+
807
+ _logsi.LogVerbose('OAuth2 authorization token has previously been authorized')
808
+
809
+ # process results.
810
+ oauth2token:dict = authClient.Session.token
811
+ authToken = SpotifyAuthToken(authClient.AuthorizationType, authClient.TokenProfileId, root=oauth2token)
812
+
813
+ # does token need to be refreshed?
814
+ if authToken.IsExpired:
815
+
816
+ # refresh the token.
817
+ # this will also store the refreshed token to disk to be used later if required.
818
+ _logsi.LogVerbose('OAuth2 authorization token has expired, or is about to; token will be refreshed')
819
+ oauth2token:dict = authClient.RefreshToken()
820
+ authToken = SpotifyAuthToken(authClient.AuthorizationType, authClient.TokenProfileId, root=oauth2token)
821
+ _logsi.LogObject(SILevel.Verbose, TRACE_METHOD_RESULT % apiMethodName, authToken, excludeNonPublic=True)
822
+
823
+ else:
824
+
825
+ _logsi.LogVerbose('OAuth2 authorization token has not expired')
826
+
827
+ # set spotify token request headers.
828
+ tokHeaders:dict = {}
829
+ tokHeaders['User-Agent'] = 'Spotify/124300420 Win32_x86_64/0 (PC desktop)'
830
+ tokHeaders['Accept-Language'] = 'en-Latn-US,en-US;q=0.9,en-Latn;q=0.8,en;q=0.7'
831
+ tokHeaders['Accept-Encoding'] = 'gzip, deflate, br, zstd'
832
+
833
+ # TEST TODO set client-token (for testing purposes).
834
+ #tokHeaders['client-token'] = 'AADEtsyn61 ... redacted ...'
835
+
836
+ # TEST TODO set authorization token (for testing purposes).
837
+ # tokHeaders['authorization'] = 'Bearer BQCYWYv266 ... redacted ...'
838
+ # tokHeaders['authorization'] = authToken.HeaderValue
839
+ # tokHeaders['client-token'] = ''
840
+
841
+ # set audience based upon the device info.
842
+ audience:str = info.ClientId
843
+
844
+ # reset audience based upon specific device brand / model info.
845
+ if info.IsBrandSonos:
846
+ audience = '9b377073ea334637b1406f329ce005de', # Sonos Spotify application id
847
+
848
+ # set spotify token request parameters.
849
+ tokData={
850
+ 'audience': audience, # Brand-specific client id that can use the generated token
851
+ 'client_id': SPOTIFY_DESKTOP_APP_CLIENT_ID, # Spotify desktop app client id
852
+ 'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
853
+ 'requested_token_type': 'urn:spotify:params:oauth:authorization_code',
854
+ 'resource': 'urn:spotify:resources:connect',
855
+ 'scope': 'streaming',
856
+ 'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
857
+ 'subject_token': authToken.AccessToken,
858
+ }
859
+
860
+ # trace.
861
+ _logsi.LogVerbose('Exchanging OAuth2 authorization access token for an authorization_code access token')
862
+ _logsi.LogDictionary(SILevel.Debug, "%s http request: '%s' (headers)" % (tracePrefix, SPOTIFY_API_TOKEN_URL), tokHeaders)
863
+ _logsi.LogDictionary(SILevel.Verbose, "%s http request: '%s' (data)" % (tracePrefix, SPOTIFY_API_TOKEN_URL), tokData)
864
+
865
+ # execute spotify access token request.
866
+ response = requests.post(
867
+ SPOTIFY_API_TOKEN_URL,
868
+ timeout=10,
869
+ headers=tokHeaders,
870
+ data=tokData
871
+ )
872
+
873
+ # check response for initial errors, and return json response.
874
+ responseData:dict = self._CheckResponseForErrors(response, apiMethodName, SPOTIFY_API_TOKEN_URL, tracePrefix)
875
+ result = responseData.get('access_token', None)
876
+ errorMsg = responseData.get('error', None)
877
+
878
+ # check for request errors.
879
+ if response.status_code != 200:
880
+ raise SpotifyZeroconfApiError(response.status_code, 'Spotify OAuth2 Token Exchange failed: %s' % (errorMsg or responseData), apiMethodName, response.reason, _logsi)
881
+
882
+ # return authorization_code access token to caller.
883
+ return result
884
+
885
+ finally:
886
+
887
+ # trace.
888
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
889
+
890
+
891
+ def _LoadLibrespotCredentials(self, loginId:str) -> dict:
892
+ """
893
+ Loads spotify user credentials from a librespot credentials.json file.
894
+
895
+ Args:
896
+ loginId (str):
897
+ Spotify canonical login id of the authenticating user.
898
+ Example = `3758dfdsfjk435hjk6k79lm0n3c4`
899
+
900
+ Returns:
901
+ A credentials dictionary, if one was found in the credentials storage file;
902
+ otherwise, null.
903
+ """
904
+ apiMethodName:str = '_LoadLibrespotCredentials'
905
+ apiMethodParms:SIMethodParmListContext = None
906
+
907
+ try:
908
+
909
+ # trace.
910
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
911
+ _logsi.LogMethodParmList(SILevel.Verbose, "Loading librespot credentials from storage", apiMethodParms)
912
+
913
+ # librespot credntials.json file format as of 2024/09/28
914
+ # {
915
+ # "username": "31l77xxxxxxxxxxxxxxxxxxxxxxx",
916
+ # "auth_type": 1,
917
+ # "auth_data": "QVFET2h6 ... aFp3OHg="
918
+ # }
919
+
920
+ # formulate the credentials file path.
921
+ filePath:str = os.path.join(self._TokenStorageDir, "SpotifyWebApiPython_librespot_credentials.json")
922
+
923
+ # does the file exist? if not, then it's an error as we are trying to authenticate!
924
+ if (not os.path.exists(filePath)):
925
+ raise SpotifyApiError("Could not find librespot authorization credentials file (%s). This file is required in order to authenticate to a device or service that utilizes the librespot application." % (filePath), logsi=_logsi)
926
+
927
+ # open the credentials file, and load its contents.
928
+ _logsi.LogVerbose('Opening librespot credentials file: "%s"' % (filePath))
929
+ with open(filePath, 'r') as f:
930
+ credentials:dict = json.load(f)
931
+ _logsi.LogDictionary(SILevel.Verbose, 'librespot credentials were loaded successfully', credentials, prettyPrint=True)
932
+
933
+ # prepare for comparison.
934
+ loginId = loginId.lower()
935
+ credential:dict = None
936
+
937
+ # check for multiple credential format.
938
+ if (isinstance(credentials, list)):
939
+
940
+ # file contents are an array of librespot credentials.
941
+ for credential in credentials:
942
+ if (credential.get('username','').lower() == loginId):
943
+ break
944
+
945
+ else:
946
+
947
+ # file contents are a single librespot credential.
948
+ credential = credentials
949
+
950
+ # does the found credential match the requested login? if not, then reset the credential.
951
+ if (credential is not None):
952
+ if (credential.get('username','').lower() != loginId):
953
+ credential = None
954
+
955
+ # if no credentials then it's an error.
956
+ if (credential is None):
957
+ raise SpotifyApiError("librespot authorization credentials file (%s) did not contain credentials for canonical loginid (%s)." % (filePath, loginId), logsi=_logsi)
958
+
959
+ # return credentials to caller.
960
+ return credential
961
+
962
+ except SpotifyApiError: raise # pass handled exceptions on thru
963
+ except Exception as ex:
964
+
965
+ # trace.
966
+ _logsi.LogException('Could not load librespot authorization credentials file (%s).' % (filePath), ex)
967
+ raise
968
+
969
+ finally:
970
+
971
+ # trace.
972
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
973
+
974
+
975
+ def Disconnect(
976
+ self,
977
+ delay:float=0.50,
978
+ ignoreStatusResult:bool=False
979
+ ) -> ZeroconfResponse:
980
+ """
981
+ Calls the `resetUsers` Spotify Zeroconf API endpoint to issue a call to SpConnectionLogout.
982
+
983
+ Args:
984
+ delay (float):
985
+ Time delay (in seconds) to wait AFTER issuing the command to the device.
986
+ This delay will give the spotify zeroconf api time to process the change before
987
+ another command is issued.
988
+ Default is 0.50; value range is 0 - 10.
989
+ ignoreStatusResult (bool):
990
+ True to ignore processing of result status code;
991
+ otherwise, False to check the result status code for errors and raise an
992
+ exception if needed.
993
+ Default is
994
+
995
+ Returns:
996
+ A `ZeroconfResponse` object that indicates success or failure (see notes below).
997
+
998
+ Raises:
999
+ SpotifyWebApiError:
1000
+ If the Spotify Zeroconf API request response contains error information.
1001
+
1002
+ The currently logged in user (if any) will be logged out of Spotify Connect, and the
1003
+ device id removed from the active Spotify Connect device list.
1004
+
1005
+ This method should not be attempted for devices or services that utilize `librespot`,
1006
+ such as `spotifyd`. The `librespot` library does not implement the `resetUsers`
1007
+ Spotify Connect Zeroconf endpoint, and will fail with a 404 error.
1008
+
1009
+ <details>
1010
+ <summary>Sample Code</summary>
1011
+ ```python
1012
+ .. include:: ../../docs/include/samplecode/ZeroconfConnect/Disconnect.py
1013
+ ```
1014
+ </details>
1015
+ """
1016
+ apiMethodName:str = 'Disconnect'
1017
+ apiMethodParms:SIMethodParmListContext = None
1018
+ result:ZeroconfResponse = None
1019
+ response:Response = None
1020
+
1021
+ try:
1022
+
1023
+ # trace.
1024
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
1025
+ apiMethodParms.AppendKeyValue("HostIpAddress", self._HostIpAddress)
1026
+ apiMethodParms.AppendKeyValue("HostIpPort", self._HostIpPort)
1027
+ apiMethodParms.AppendKeyValue("CPath", self._CPath)
1028
+ apiMethodParms.AppendKeyValue("Version", self._Version)
1029
+ apiMethodParms.AppendKeyValue("Uri", self._Uri)
1030
+ apiMethodParms.AppendKeyValue("delay", delay)
1031
+ apiMethodParms.AppendKeyValue("ignoreStatusResult", ignoreStatusResult)
1032
+ _logsi.LogMethodParmList(SILevel.Verbose, "Disconnecting device from Spotify Connect (ip=%s:%s)" % (self._HostIpAddress, self._HostIpPort), apiMethodParms)
1033
+
1034
+ # validations.
1035
+ delay = validateDelay(delay, 0.50, 10)
1036
+
1037
+ # set request endpoint.
1038
+ endpoint:str = self.GetEndpoint("resetUsers")
1039
+ if ignoreStatusResult is None:
1040
+ ignoreStatusResult = False
1041
+
1042
+ # set request headers.
1043
+ reqHeaders:dict = {
1044
+ "Content-Type": "application/x-www-form-urlencoded",
1045
+ "Connection": "close"
1046
+ }
1047
+ _logsi.LogDictionary(SILevel.Debug, "ZeroconfConnect http request: '%s' (headers)" % (endpoint), reqHeaders)
1048
+
1049
+ # set request parameters.
1050
+ reqData={
1051
+ "action": "resetUsers",
1052
+ "version": self.Version,
1053
+ }
1054
+ _logsi.LogDictionary(SILevel.Verbose, "ZeroconfConnect http request: '%s' (data)" % (endpoint), reqData)
1055
+
1056
+ # execute spotify zeroconf api request.
1057
+ response = requests.post(
1058
+ self._Uri,
1059
+ timeout=10,
1060
+ headers=reqHeaders,
1061
+ data=reqData # send data in POST request body
1062
+ )
1063
+
1064
+ # check response for initial errors, and return json response.
1065
+ responseData:dict = self._CheckResponseForErrors(response, apiMethodName, endpoint)
1066
+
1067
+ # process results.
1068
+ result = ZeroconfResponse(root=responseData)
1069
+
1070
+ # trace.
1071
+ _logsi.LogObject(SILevel.Verbose, '%s result (%s)' % (apiMethodName, type(result).__name__), result, excludeNonPublic=True)
1072
+
1073
+ # was a status value returned?
1074
+ # it's been found that some devices (Sonos, etc) do not return a proper JSON response.
1075
+ # for these cases, we will rely on the http status code for the response code.
1076
+ if (result.Status is None):
1077
+
1078
+ # was a good http status code returned?
1079
+ if response.status_code == 200:
1080
+
1081
+ # yes - default status results if they are not set.
1082
+ result.Status = 101
1083
+ if result.StatusString is None:
1084
+ result.StatusString = "OK"
1085
+ if result.SpotifyError is None:
1086
+ result.SpotifyError = 0
1087
+
1088
+ # are we processing result status?
1089
+ if (not ignoreStatusResult):
1090
+
1091
+ if (result.Status is None) and (response.status_code != 200):
1092
+ # if status value was not returned and http status is not 200, then
1093
+ # raise an exception with http status details.
1094
+ raise SpotifyZeroconfApiError(response.status_code, responseData, apiMethodName, response.reason, _logsi)
1095
+
1096
+ elif (result.Status != 101):
1097
+ # if status was returned and it's not ok, then raise an exception
1098
+ # with JSON response details.
1099
+ raise SpotifyZeroconfApiError(result.Status, result.ToString(), apiMethodName, result.StatusString, _logsi)
1100
+
1101
+ # give spotify zeroconf api time to process the change.
1102
+ if delay > 0:
1103
+ _logsi.LogVerbose(TRACE_MSG_DELAY_DEVICE % delay)
1104
+ time.sleep(delay)
1105
+
1106
+ # return result to caller.
1107
+ return result
1108
+
1109
+ except SpotifyZeroconfApiError: raise # pass handled exceptions on thru
1110
+ except SpotifyApiError: raise # pass handled exceptions on thru
1111
+ except Exception as ex:
1112
+
1113
+ # format unhandled exception.
1114
+ raise SpotifyApiError(SAAppMessages.UNHANDLED_EXCEPTION.format(apiMethodName, str(ex)), ex, logsi=_logsi)
1115
+
1116
+ finally:
1117
+
1118
+ # close the response (if needed).
1119
+ if response is not None:
1120
+ response.close()
1121
+
1122
+ # trace.
1123
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
1124
+
1125
+
1126
+ def GetEndpointUri(self) -> str:
1127
+ """
1128
+ Gets a Spotify Zeroconf API endpoint uri.
1129
+
1130
+ Returns:
1131
+ A string containing the endpoint uri.
1132
+ """
1133
+ protocol:str = 'http'
1134
+ if (self._UseSSL):
1135
+ protocol = 'https'
1136
+
1137
+ return "{protocol}://{ip}:{port}{cpath}".format(
1138
+ protocol=protocol,
1139
+ ip=self._HostIpAddress,
1140
+ port=self._HostIpPort,
1141
+ cpath=self._CPath,
1142
+ )
1143
+
1144
+
1145
+ def GetEndpoint(
1146
+ self,
1147
+ action:str
1148
+ ) -> str:
1149
+ """
1150
+ Gets a Spotify Zeroconf API endpoint uri.
1151
+
1152
+ Args:
1153
+ action (str):
1154
+ Action parameter value (e.g. "getInfo", "addUser", "resetUsers", etc).
1155
+
1156
+ Returns:
1157
+ A string containing the endpoint.
1158
+ """
1159
+ return "{uri}?action={action}&version={version}".format(
1160
+ uri=self._Uri,
1161
+ action=action,
1162
+ version=self.Version,
1163
+ )
1164
+
1165
+
1166
+ def GetInformation(self) -> ZeroconfGetInfo:
1167
+ """
1168
+ Calls the `getInfo` Spotify Zeroconf API endpoint to return information about the device.
1169
+
1170
+ Returns:
1171
+ A `ZeroconfGetInfo` object that indicates success or failure (see notes below), as well
1172
+ as the device information.
1173
+
1174
+ Raises:
1175
+ SpotifyWebApiError:
1176
+ If the Spotify Zeroconf API request response contains error information.
1177
+
1178
+ The request will timeout after 5 seconds and an exception raised if the device cannot be reached
1179
+ or does not respond within that time frame.
1180
+
1181
+ <details>
1182
+ <summary>Sample Code</summary>
1183
+ ```python
1184
+ .. include:: ../../docs/include/samplecode/ZeroconfConnect/GetInformation.py
1185
+ ```
1186
+ </details>
1187
+ """
1188
+ apiMethodName:str = 'GetInformation'
1189
+ apiMethodParms:SIMethodParmListContext = None
1190
+ result:ZeroconfGetInfo = None
1191
+ response:Response = None
1192
+
1193
+ try:
1194
+
1195
+ # trace.
1196
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
1197
+ apiMethodParms.AppendKeyValue("HostIpAddress", self._HostIpAddress)
1198
+ apiMethodParms.AppendKeyValue("HostIpPort", self._HostIpPort)
1199
+ apiMethodParms.AppendKeyValue("CPath", self._CPath)
1200
+ apiMethodParms.AppendKeyValue("Version", self._Version)
1201
+ apiMethodParms.AppendKeyValue("Uri", self._Uri)
1202
+ _logsi.LogMethodParmList(SILevel.Verbose, "Get information from Spotify Connect Zeroconf API (ip=%s:%s)" % (self._HostIpAddress, self._HostIpPort), apiMethodParms)
1203
+
1204
+ # set request endpoint.
1205
+ endpoint:str = self.GetEndpoint("getInfo")
1206
+
1207
+ # set request headers.
1208
+ reqHeaders:dict = {
1209
+ "Content-Type": "application/x-www-form-urlencoded",
1210
+ "Connection": "close"
1211
+ }
1212
+ _logsi.LogDictionary(SILevel.Debug, "ZeroconfConnect http request: '%s' (headers)" % (endpoint), reqHeaders)
1213
+
1214
+ # set request parameters.
1215
+ reqParams={
1216
+ "action": "getInfo",
1217
+ "version": self.Version,
1218
+ }
1219
+ _logsi.LogDictionary(SILevel.Verbose, "ZeroconfConnect http request: '%s' (params)" % (endpoint), reqParams)
1220
+
1221
+ # try connecting to the device until we timeout or there are no more "connection refused" errors.
1222
+ loopTotalDelay:float = 0
1223
+ LOOP_DELAY:float = 0.25
1224
+ LOOP_TIMEOUT:float = 1.0 # this is the number of tries (not seconds to wait); 1.0 = 4 tries, 2.0 = 8 tries, etc.
1225
+ while True:
1226
+
1227
+ try:
1228
+
1229
+ # if device connection was previously delayed, then log the info
1230
+ # as it may be helpful in debugging other issues.
1231
+ if (loopTotalDelay > 0):
1232
+ _logsi.LogVerbose("Re-trying to reach Spotify Connect device (ip=%s:%s) after %f seconds from initial request" % (self._HostIpAddress, self._HostIpPort, loopTotalDelay))
1233
+
1234
+ # execute spotify zeroconf api request.
1235
+ response = requests.get(
1236
+ self._Uri,
1237
+ timeout=4,
1238
+ headers=reqHeaders,
1239
+ params=reqParams
1240
+ )
1241
+
1242
+ # TEST TODO - force a connection refused error for testing.
1243
+ # if (self._HostIpAddress == "192.168.1.82"):
1244
+ # if loopTotalDelay > 1:
1245
+ # pass
1246
+ # else:
1247
+ # raise Exception("Failed to establish a new connection: [Errno 111] Connection refused")
1248
+ # #raise ValueError("Some other request exception test")
1249
+
1250
+ # if device connection was previously delayed, then log the info
1251
+ # as it may be helpful in debugging other issues.
1252
+ if (loopTotalDelay > 0):
1253
+ _logsi.LogVerbose("Spotify Connect device (ip=%s:%s) started accepting connections after %f seconds from initial request" % (self._HostIpAddress, self._HostIpPort, loopTotalDelay))
1254
+
1255
+ # no exception; break out of retry loop.
1256
+ break
1257
+
1258
+ except Exception as ex:
1259
+
1260
+ # check for connection refused error.
1261
+ exMsg:str = str(ex).lower()
1262
+ if (exMsg.find("connection refused") > -1):
1263
+
1264
+ # retry after a slight delay.
1265
+ _logsi.LogVerbose("Spotify Connect device (ip=%s:%s) connection refused exception; delaying for %s seconds to allow the device to start accepting connections" % (self._HostIpAddress, self._HostIpPort, LOOP_DELAY))
1266
+ time.sleep(LOOP_DELAY)
1267
+ loopTotalDelay = loopTotalDelay + LOOP_DELAY
1268
+
1269
+ # only check so many times before we give up.
1270
+ if (loopTotalDelay >= LOOP_TIMEOUT):
1271
+ _logsi.LogWarning("Spotify Connect device (ip=%s:%s) wait time exceeded for device to start accepting connections; gave up after %f seconds from initial request" % (self._HostIpAddress, self._HostIpPort, loopTotalDelay))
1272
+ raise
1273
+
1274
+ else:
1275
+
1276
+ # some other exception occured - stop retrying the connection.
1277
+ raise
1278
+
1279
+ # check response for initial errors, and return json response.
1280
+ responseData:dict = self._CheckResponseForErrors(response, apiMethodName, endpoint)
1281
+
1282
+ # process results.
1283
+ result = ZeroconfGetInfo(root=responseData)
1284
+
1285
+ # trace.
1286
+ _logsi.LogObject(SILevel.Verbose, '%s result (%s) - "%s" (%s)' % (apiMethodName, type(result).__name__, result.RemoteName, result.DeviceId), result, excludeNonPublic=True)
1287
+
1288
+ # if result status is not ok, then raise an exception.
1289
+ if (result.Status != 101):
1290
+ raise SpotifyZeroconfApiError(result.Status, result.ToString(), apiMethodName, result.StatusString, _logsi)
1291
+
1292
+ # return result to caller.
1293
+ return result
1294
+
1295
+ except SpotifyZeroconfApiError: raise # pass handled exceptions on thru
1296
+ except SpotifyApiError: raise # pass handled exceptions on thru
1297
+ except Exception as ex:
1298
+
1299
+ # format unhandled exception.
1300
+ raise SpotifyApiError(SAAppMessages.UNHANDLED_EXCEPTION.format(apiMethodName, str(ex)), ex, logsi=_logsi)
1301
+
1302
+ finally:
1303
+
1304
+ # close the response (if needed).
1305
+ if response is not None:
1306
+ response.close()
1307
+
1308
+ # trace.
1309
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
1310
+
1311
+
1312
+ def ToString(self, includeTitle:bool=False) -> str:
1313
+ """
1314
+ Returns a displayable string representation of the class.
1315
+
1316
+ Args:
1317
+ includeTitle (str):
1318
+ True to include the class name title prefix.
1319
+ """
1320
+ msg:str = ''
1321
+ if includeTitle:
1322
+ msg = 'ZeroconfConnect:'
1323
+
1324
+ msg = '%s\n URI="%s"' % (msg, str(self._Uri))
1325
+ if self._CPath is not None: msg = '%s\n CPath="%s"' % (msg, str(self._CPath))
1326
+ if self._Version is not None: msg = '%s\n Version="%s"' % (msg, str(self.Version))
1327
+ return msg