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.
- spotifywebapipython/__init__.py +35 -0
- spotifywebapipython/const.py +114 -0
- spotifywebapipython/docs/__init__.html +2156 -0
- spotifywebapipython/docs/codecopy.js +70 -0
- spotifywebapipython/docs/index.html +514 -0
- spotifywebapipython/docs/search.js +46 -0
- spotifywebapipython/docs/spotifywebapipython/const.html +636 -0
- spotifywebapipython/docs/spotifywebapipython/saappmessages.html +540 -0
- spotifywebapipython/docs/spotifywebapipython/sautils.html +620 -0
- spotifywebapipython/docs/spotifywebapipython/spotifyapierror.html +341 -0
- spotifywebapipython/docs/spotifywebapipython/spotifyapimessage.html +598 -0
- spotifywebapipython/docs/spotifywebapipython/spotifyauthtoken.html +612 -0
- spotifywebapipython/docs/spotifywebapipython/spotifyclient.html +16557 -0
- spotifywebapipython/docs/spotifywebapipython/spotifydiscovery.html +518 -0
- spotifywebapipython/docs/spotifywebapipython/spotifywebapiauthenticationerror.html +421 -0
- spotifywebapipython/docs/spotifywebapipython/spotifywebapierror.html +400 -0
- spotifywebapipython/docs/spotifywebapipython/spotifywebplayertoken.html +635 -0
- spotifywebapipython/docs/spotifywebapipython.ico +0 -0
- spotifywebapipython/docs/spotifywebapipython_OLD.ico +0 -0
- spotifywebapipython/models/__init__.py +121 -0
- spotifywebapipython/models/album.py +170 -0
- spotifywebapipython/models/albumpagesaved.py +85 -0
- spotifywebapipython/models/albumpagesimplified.py +88 -0
- spotifywebapipython/models/albumsaved.py +102 -0
- spotifywebapipython/models/albumsimplified.py +308 -0
- spotifywebapipython/models/artist.py +146 -0
- spotifywebapipython/models/artistinfo.py +282 -0
- spotifywebapipython/models/artistinfotourevent.py +127 -0
- spotifywebapipython/models/artistpage.py +70 -0
- spotifywebapipython/models/artistsimplified.py +163 -0
- spotifywebapipython/models/audiobook.py +87 -0
- spotifywebapipython/models/audiobookpagesimplified.py +88 -0
- spotifywebapipython/models/audiobooksimplified.py +362 -0
- spotifywebapipython/models/audiofeatures.py +333 -0
- spotifywebapipython/models/author.py +67 -0
- spotifywebapipython/models/category.py +168 -0
- spotifywebapipython/models/categorypage.py +70 -0
- spotifywebapipython/models/chapter.py +84 -0
- spotifywebapipython/models/chapterpagesimplified.py +88 -0
- spotifywebapipython/models/chaptersimplified.py +387 -0
- spotifywebapipython/models/context.py +113 -0
- spotifywebapipython/models/copyright.py +79 -0
- spotifywebapipython/models/device.py +301 -0
- spotifywebapipython/models/episode.py +84 -0
- spotifywebapipython/models/episodepagesaved.py +85 -0
- spotifywebapipython/models/episodepagesimplified.py +88 -0
- spotifywebapipython/models/episodesaved.py +99 -0
- spotifywebapipython/models/episodesimplified.py +370 -0
- spotifywebapipython/models/explicitcontent.py +80 -0
- spotifywebapipython/models/externalids.py +91 -0
- spotifywebapipython/models/externalurls.py +104 -0
- spotifywebapipython/models/followers.py +81 -0
- spotifywebapipython/models/imageobject.py +193 -0
- spotifywebapipython/models/imagevibrantcolors.py +182 -0
- spotifywebapipython/models/linkedfrom.py +165 -0
- spotifywebapipython/models/narrator.py +67 -0
- spotifywebapipython/models/owner.py +162 -0
- spotifywebapipython/models/pageobject.py +343 -0
- spotifywebapipython/models/playeractions.py +196 -0
- spotifywebapipython/models/playerlastplayedinfo.py +334 -0
- spotifywebapipython/models/playerplaystate.py +467 -0
- spotifywebapipython/models/playerqueueinfo.py +207 -0
- spotifywebapipython/models/playhistory.py +136 -0
- spotifywebapipython/models/playhistorypage.py +85 -0
- spotifywebapipython/models/playlist.py +125 -0
- spotifywebapipython/models/playlistpage.py +85 -0
- spotifywebapipython/models/playlistpagesimplified.py +107 -0
- spotifywebapipython/models/playlistsimplified.py +334 -0
- spotifywebapipython/models/playlisttrack.py +143 -0
- spotifywebapipython/models/playlisttracksummary.py +80 -0
- spotifywebapipython/models/recommendationseed.py +135 -0
- spotifywebapipython/models/restrictions.py +86 -0
- spotifywebapipython/models/resumepoint.py +79 -0
- spotifywebapipython/models/searchresponse.py +391 -0
- spotifywebapipython/models/searchresultbase.py +299 -0
- spotifywebapipython/models/show.py +87 -0
- spotifywebapipython/models/showpagesaved.py +85 -0
- spotifywebapipython/models/showpagesimplified.py +88 -0
- spotifywebapipython/models/showsaved.py +99 -0
- spotifywebapipython/models/showsimplified.py +320 -0
- spotifywebapipython/models/spotifyconnectdevice.py +321 -0
- spotifywebapipython/models/spotifyconnectdevices.py +441 -0
- spotifywebapipython/models/track.py +142 -0
- spotifywebapipython/models/trackpage.py +110 -0
- spotifywebapipython/models/trackpagesaved.py +85 -0
- spotifywebapipython/models/trackpagesimplified.py +88 -0
- spotifywebapipython/models/trackrecommendations.py +88 -0
- spotifywebapipython/models/tracksaved.py +99 -0
- spotifywebapipython/models/tracksimplified.py +398 -0
- spotifywebapipython/models/userprofile.py +184 -0
- spotifywebapipython/models/userprofilesimplified.py +206 -0
- spotifywebapipython/models/zeroconfdiscoveryresult.py +592 -0
- spotifywebapipython/models/zeroconfproperty.py +93 -0
- spotifywebapipython/oauthcli/__init__.py +7 -0
- spotifywebapipython/oauthcli/authclient.py +1229 -0
- spotifywebapipython/saappmessages.py +86 -0
- spotifywebapipython/sautils.py +285 -0
- spotifywebapipython/spotifyapierror.py +66 -0
- spotifywebapipython/spotifyapimessage.py +269 -0
- spotifywebapipython/spotifyauthtoken.py +264 -0
- spotifywebapipython/spotifyclient.py +17885 -0
- spotifywebapipython/spotifyconnect/__init__.py +19 -0
- spotifywebapipython/spotifyconnect/spotifyconnectdeviceeventargs.py +67 -0
- spotifywebapipython/spotifyconnect/spotifyconnectdirectorytask.py +2564 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastapptask.py +467 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastcontroller.py +701 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastlistener.py +359 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastmultizonelistener.py +212 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconfexceptions.py +20 -0
- spotifywebapipython/spotifyconnect/spotifyconnectzeroconflistener.py +335 -0
- spotifywebapipython/spotifydiscovery.py +473 -0
- spotifywebapipython/spotifymediatypes.py +53 -0
- spotifywebapipython/spotifywebapiauthenticationerror.py +122 -0
- spotifywebapipython/spotifywebapierror.py +106 -0
- spotifywebapipython/spotifywebplayertoken.py +743 -0
- spotifywebapipython/vibrant/__init__.py +17 -0
- spotifywebapipython/vibrant/generator.py +261 -0
- spotifywebapipython/vibrant/image.py +100 -0
- spotifywebapipython/vibrant/main.py +34 -0
- spotifywebapipython/vibrant/models.py +93 -0
- spotifywebapipython/vibrant/utils.py +56 -0
- spotifywebapipython/zeroconfapi/__init__.py +23 -0
- spotifywebapipython/zeroconfapi/authenticationtypes.py +38 -0
- spotifywebapipython/zeroconfapi/blobbuilder.py +370 -0
- spotifywebapipython/zeroconfapi/credentials.py +37 -0
- spotifywebapipython/zeroconfapi/cryptodiffiehellman.py +89 -0
- spotifywebapipython/zeroconfapi/helpers.py +64 -0
- spotifywebapipython/zeroconfapi/spotifyzeroconfapierror.py +108 -0
- spotifywebapipython/zeroconfapi/zeroconfconnect.py +1327 -0
- spotifywebapipython/zeroconfapi/zeroconfgetinfo.py +572 -0
- spotifywebapipython/zeroconfapi/zeroconfgetinfoalias.py +109 -0
- spotifywebapipython/zeroconfapi/zeroconfgetinfodrmmediaformat.py +90 -0
- spotifywebapipython/zeroconfapi/zeroconfresponse.py +195 -0
- spotifywebapipython-1.0.242.dist-info/LICENSE +22 -0
- spotifywebapipython-1.0.242.dist-info/METADATA +219 -0
- spotifywebapipython-1.0.242.dist-info/RECORD +138 -0
- spotifywebapipython-1.0.242.dist-info/WHEEL +5 -0
- 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
|