spotifywebapiPython 1.0.43__py3-none-any.whl → 1.0.45__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.

Potentially problematic release.


This version of spotifywebapiPython might be problematic. Click here for more details.

Files changed (33) hide show
  1. {spotifywebapiPython-1.0.43.dist-info → spotifywebapiPython-1.0.45.dist-info}/METADATA +1 -1
  2. {spotifywebapiPython-1.0.43.dist-info → spotifywebapiPython-1.0.45.dist-info}/RECORD +33 -26
  3. spotifywebapipython/const.py +1 -1
  4. spotifywebapipython/docs/__init__.html +23 -2
  5. spotifywebapipython/docs/index.html +2 -2
  6. spotifywebapipython/docs/search.js +1 -1
  7. spotifywebapipython/docs/spotifywebapiPython/const.html +3 -3
  8. spotifywebapipython/docs/spotifywebapiPython/saappmessages.html +2 -2
  9. spotifywebapipython/docs/spotifywebapiPython/sautils.html +2 -2
  10. spotifywebapipython/docs/spotifywebapiPython/spotifyapierror.html +2 -2
  11. spotifywebapipython/docs/spotifywebapiPython/spotifyapimessage.html +2 -2
  12. spotifywebapipython/docs/spotifywebapiPython/spotifyauthtoken.html +2 -2
  13. spotifywebapipython/docs/spotifywebapiPython/spotifyclient.html +200 -2
  14. spotifywebapipython/docs/spotifywebapiPython/spotifydiscovery.html +45 -2
  15. spotifywebapipython/docs/spotifywebapiPython/spotifywebapiauthenticationerror.html +2 -2
  16. spotifywebapipython/docs/spotifywebapiPython/spotifywebapierror.html +2 -2
  17. spotifywebapipython/models/__init__.py +14 -0
  18. spotifywebapipython/models/playlist.py +4 -1
  19. spotifywebapipython/models/playlistsimplified.py +4 -1
  20. spotifywebapipython/models/playlisttracksummary.py +3 -1
  21. spotifywebapipython/models/searchresponse.py +1 -1
  22. spotifywebapipython/models/zeroconfblobbuilder.py +144 -0
  23. spotifywebapipython/models/zeroconfdiscoveryresult.py +451 -0
  24. spotifywebapipython/models/zeroconfgetinfo.py +359 -0
  25. spotifywebapipython/models/zeroconfgetinfoalias.py +96 -0
  26. spotifywebapipython/models/zeroconfgetinfodrmmediaformat.py +90 -0
  27. spotifywebapipython/models/zeroconfproperty.py +93 -0
  28. spotifywebapipython/models/zeroconfresponse.py +108 -0
  29. spotifywebapipython/spotifyclient.py +293 -3
  30. spotifywebapipython/spotifydiscovery.py +98 -11
  31. {spotifywebapiPython-1.0.43.dist-info → spotifywebapiPython-1.0.45.dist-info}/LICENSE +0 -0
  32. {spotifywebapiPython-1.0.43.dist-info → spotifywebapiPython-1.0.45.dist-info}/WHEEL +0 -0
  33. {spotifywebapiPython-1.0.43.dist-info → spotifywebapiPython-1.0.45.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,108 @@
1
+ # external package imports.
2
+
3
+ # our package imports.
4
+ from ..sautils import export
5
+
6
+ @export
7
+ class ZeroconfResponse:
8
+ """
9
+ Spotify Web API Zeroconf basic response object.
10
+ """
11
+
12
+ def __init__(self, root:dict=None) -> None:
13
+ """
14
+ Initializes a new instance of the class.
15
+
16
+ Args:
17
+ root (dict):
18
+ Spotify Web API JSON response in dictionary format, used to load object
19
+ attributes; otherwise, None to not load attributes.
20
+ """
21
+ self._SpotifyError:int = None
22
+ self._Status:int = None
23
+ self._StatusString:str = None
24
+ self._ResponseSource:str = None
25
+
26
+ if (root is None):
27
+
28
+ pass
29
+
30
+ else:
31
+
32
+ self._ResponseSource = root.get('responseSource', None)
33
+ self._SpotifyError = root.get('spotifyError', None)
34
+ self._Status = root.get('status', None)
35
+ self._StatusString = root.get('statusString', None)
36
+
37
+
38
+ def __repr__(self) -> str:
39
+ return self.ToString()
40
+
41
+
42
+ def __str__(self) -> str:
43
+ return self.ToString()
44
+
45
+
46
+ @property
47
+ def ResponseSource(self) -> str:
48
+ """
49
+ Token type provided by the client (e.g. "accesstoken").
50
+ """
51
+ return self._ResponseSource
52
+
53
+
54
+ @property
55
+ def SpotifyError(self) -> int:
56
+ """
57
+ The last error code returned by a Spotify API call or the SpCallbackError() callback (e.g. 0, -119, etc).
58
+ """
59
+ return self._SpotifyError
60
+
61
+
62
+ @property
63
+ def Status(self) -> int:
64
+ """
65
+ A code indicating the result of the operation (e.g. 101, 402, etc).
66
+ """
67
+ return self._Status
68
+
69
+
70
+ @property
71
+ def StatusString(self) -> str:
72
+ """
73
+ The string describing the status code (e.g. "OK", "ERROR-SPOTIFY-ERROR", etc).
74
+ """
75
+ return self._StatusString
76
+
77
+
78
+ def ToDictionary(self) -> dict:
79
+ """
80
+ Returns a dictionary representation of the class.
81
+ """
82
+ result:dict = \
83
+ {
84
+ 'SpotifyError': self._SpotifyError,
85
+ 'Status': self._Status,
86
+ 'StatusString': self._StatusString,
87
+ 'ResponseSource': self._ResponseSource,
88
+ }
89
+ return result
90
+
91
+
92
+ def ToString(self, includeTitle:bool=False) -> str:
93
+ """
94
+ Returns a displayable string representation of the class.
95
+
96
+ Args:
97
+ includeTitle (str):
98
+ True to include the class name title prefix.
99
+ """
100
+ msg:str = ''
101
+ if includeTitle:
102
+ msg = 'ZeroconfResponse:'
103
+
104
+ if self._SpotifyError is not None: msg = '%s\n SpotifyError="%s"' % (msg, str(self._SpotifyError))
105
+ if self._Status is not None: msg = '%s\n Status="%s"' % (msg, str(self._Status))
106
+ if self._StatusString is not None: msg = '%s\n StatusString="%s"' % (msg, str(self._StatusString))
107
+ if self._ResponseSource is not None: msg = '%s\n ResponseSource="%s"' % (msg, str(self._ResponseSource))
108
+ return msg
@@ -14,6 +14,7 @@ from lxml.etree import fromstring, Element
14
14
  from .oauthcli import AuthClient
15
15
  from .models import *
16
16
  from .models import UserProfile as UserProfileCurrentUser
17
+ from .models.zeroconfblobbuilder import int_to_b64str
17
18
  from .sautils import export, GetUnixTimestampMSFromUtcNow, _xmlGetInnerText
18
19
  from .saappmessages import SAAppMessages
19
20
  from .spotifyapierror import SpotifyApiError
@@ -40,6 +41,8 @@ from .const import (
40
41
  CACHE_SOURCE_CACHED:str = "cached"
41
42
  CACHE_SOURCE_CURRENT:str = "current"
42
43
 
44
+ SPOTIFY_DJ_PLAYLIST_ID = "37i9dqzf1eykqdzj48dyyq"
45
+
43
46
  # get smartinspect logger reference; create a new session for this module name.
44
47
  from smartinspectpython.siauto import SIAuto, SILevel, SISession, SIMethodParmListContext, SISourceId
45
48
  import logging
@@ -454,6 +457,19 @@ class SpotifyClient:
454
457
 
455
458
  elif responseData is not None:
456
459
 
460
+ # if response is a string (e.g. html response) and is not in the 2xx range then
461
+ # we will assume it's an error, even though no json 'error' response body was returned!
462
+ # this is usually due to a '503 Server Error' response.
463
+ if (response.status > 299) and (isinstance(responseData, str)):
464
+
465
+ errCode = response.status
466
+ errMessage = response.reason
467
+
468
+ if responseUTF8 is not None:
469
+ errMessage = responseUTF8
470
+
471
+ raise SpotifyWebApiError(errCode, errMessage, msg.MethodName, response.reason, _logsi)
472
+
457
473
  # does json response contain error details?
458
474
  if CONST_ERROR in responseData:
459
475
 
@@ -669,7 +685,8 @@ class SpotifyClient:
669
685
  uri:str = msg.Uri
670
686
  if (uri == self.SpotifyApiTokenUrl) \
671
687
  or (uri == self.SpotifyApiAuthorizeUrl) \
672
- or (uri.startswith('https:')):
688
+ or (uri.startswith('https:')) \
689
+ or (uri.startswith('http:')):
673
690
  url = uri
674
691
  else:
675
692
  url = f'{self.SpotifyWebApiUrlBase}{uri}'
@@ -705,9 +722,13 @@ class SpotifyClient:
705
722
  # call the appropriate poolmanager request method.
706
723
  if msg.HasUrlParameters:
707
724
 
708
- # add querystring parameters to url.
725
+ # add querystring parameters to url; if url already has a partial parm
726
+ # string (e.g. has a '?xxx=...') then use the append separator (e.g. '...&xxx=...').
709
727
  urlQS:str = urlencode(msg.UrlParameters)
710
- url = url + '?' + urlQS
728
+ urlParmSep:str = '?'
729
+ if (url.find('?') > 0):
730
+ urlParmSep = '&'
731
+ url = url + urlParmSep + urlQS
711
732
 
712
733
  _logsi.LogDictionary(SILevel.Verbose, "SpotifyClient http request: '%s' (with urlparms)" % (url), msg.UrlParameters, prettyPrint=True)
713
734
  response = self._Manager.request_encode_url(method, url, headers=msg.RequestHeaders)
@@ -6551,6 +6572,27 @@ class SpotifyClient:
6551
6572
 
6552
6573
  # ensure market was either supplied or implied; default if neither.
6553
6574
  market = self._ValidateMarket(market)
6575
+
6576
+ # was the Spotify DJ playlist specified?
6577
+ # the DJ is not fully integrated with the playlist API, so the GET request will fail.
6578
+ # we will manually build a basic playlist object to return for the playlist.
6579
+ if (playlistId is not None) and (playlistId.lower() == SPOTIFY_DJ_PLAYLIST_ID):
6580
+
6581
+ # build basic playlist response.
6582
+ result = Playlist()
6583
+ result._Collaborative = False
6584
+ result._Description = 'Spotify DJ Playlist'
6585
+ result._ExternalUrls._Spotify = 'https://open.spotify.com/playlist/%s' % playlistId
6586
+ result._Id = playlistId
6587
+ result._Name = 'DJ'
6588
+ result._Public = False
6589
+ result._Type = 'playlist'
6590
+ result._Uri = 'spotify:playlist:%s' % playlistId
6591
+
6592
+ # trace.
6593
+ _logsi.LogVerbose("Spotify DJ Playlist detected; request will be bypassed, and a basic PlayList object returned")
6594
+ _logsi.LogObject(SILevel.Verbose, TRACE_METHOD_RESULT_TYPE % (apiMethodName, type(result).__name__), result, excludeNonPublic=True)
6595
+ return result
6554
6596
 
6555
6597
  # build spotify web api request parameters.
6556
6598
  urlParms:dict = {}
@@ -13777,3 +13819,251 @@ class SpotifyClient:
13777
13819
 
13778
13820
  # trace.
13779
13821
  _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
13822
+
13823
+
13824
+
13825
+ def ZeroconfGetInformation(self,
13826
+ actionUrl:str,
13827
+ ) -> ZeroconfGetInfo:
13828
+ """
13829
+ Retrieve Spotify Connect device information from the Spotify Zeroconf API `getInfo` endpoint.
13830
+
13831
+ Args:
13832
+ actionUrl (str):
13833
+ The Zeroconf action url to issue the request to.
13834
+ Example: `http://192.168.1.80:8200/zc?action=getInfo`
13835
+
13836
+ Returns:
13837
+ An `ZeroconfGetInfo` object that contains the response.
13838
+
13839
+ Raises:
13840
+ SpotifyWebApiError:
13841
+ If the Spotify Web API request was for a non-authorization service
13842
+ and the response contains error information.
13843
+ SpotifApiError:
13844
+ If the method fails for any other reason.
13845
+
13846
+ <details>
13847
+ <summary>Sample Code</summary>
13848
+ ```python
13849
+ .. include:: ../docs/include/samplecode/SpotifyClient/ZeroconfGetInformation.py
13850
+ ```
13851
+ </details>
13852
+ """
13853
+ apiMethodName:str = 'ZeroconfGetInformation'
13854
+ apiMethodParms:SIMethodParmListContext = None
13855
+ result:ZeroconfGetInfo = None
13856
+
13857
+ try:
13858
+
13859
+ # trace.
13860
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
13861
+ apiMethodParms.AppendKeyValue("actionUrl", actionUrl)
13862
+ _logsi.LogMethodParmList(SILevel.Verbose, "Get Spotify Zeroconf getInfo response", apiMethodParms)
13863
+
13864
+ # execute spotify zeroconf api request.
13865
+ msg:SpotifyApiMessage = SpotifyApiMessage(apiMethodName, actionUrl)
13866
+ self.MakeRequest('GET', msg)
13867
+
13868
+ # process results.
13869
+ result = ZeroconfGetInfo(root=msg.ResponseData)
13870
+
13871
+ # trace.
13872
+ _logsi.LogObject(SILevel.Verbose, TRACE_METHOD_RESULT_TYPE % (apiMethodName, type(result).__name__), result, excludeNonPublic=True)
13873
+ return result
13874
+
13875
+ except SpotifyWebApiError: raise # pass handled exceptions on thru
13876
+ except SpotifyWebApiAuthenticationError: raise # pass handled exceptions on thru
13877
+ except Exception as ex:
13878
+
13879
+ # format unhandled exception.
13880
+ raise SpotifyApiError(SAAppMessages.UNHANDLED_EXCEPTION.format(apiMethodName, str(ex)), ex, logsi=_logsi)
13881
+
13882
+ finally:
13883
+
13884
+ # trace.
13885
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
13886
+
13887
+
13888
+ def ZeroconfResetUsers(self,
13889
+ actionUrl:str,
13890
+ ) -> ZeroconfResponse:
13891
+ """
13892
+ Reset users for a Spotify Connect device by calling the Spotify Zeroconf API `resetUsers` endpoint.
13893
+
13894
+ Args:
13895
+ actionUrl (str):
13896
+ The Zeroconf action url to issue the request to.
13897
+ Example: `http://192.168.1.80:8200/zc?action=resetUsers`
13898
+
13899
+ Returns:
13900
+ An `ZeroconfResponse` object that contains the response.
13901
+
13902
+ Raises:
13903
+ SpotifyWebApiError:
13904
+ If the Spotify Web API request was for a non-authorization service
13905
+ and the response contains error information.
13906
+ SpotifApiError:
13907
+ If the method fails for any other reason.
13908
+
13909
+ <details>
13910
+ <summary>Sample Code</summary>
13911
+ ```python
13912
+ .. include:: ../docs/include/samplecode/SpotifyClient/ZeroconfResetUsers.py
13913
+ ```
13914
+ </details>
13915
+ """
13916
+ apiMethodName:str = 'ZeroconfResetUsers'
13917
+ apiMethodParms:SIMethodParmListContext = None
13918
+ result:ZeroconfResponse = None
13919
+
13920
+ try:
13921
+
13922
+ # trace.
13923
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
13924
+ apiMethodParms.AppendKeyValue("actionUrl", actionUrl)
13925
+ _logsi.LogMethodParmList(SILevel.Verbose, "Get Spotify Zeroconf ResetUsers response", apiMethodParms)
13926
+
13927
+ # execute spotify zeroconf api request.
13928
+ msg:SpotifyApiMessage = SpotifyApiMessage(apiMethodName, actionUrl)
13929
+ self.MakeRequest('GET', msg)
13930
+
13931
+ # process results.
13932
+ result = ZeroconfResponse(root=msg.ResponseData)
13933
+
13934
+ # trace.
13935
+ _logsi.LogObject(SILevel.Verbose, TRACE_METHOD_RESULT_TYPE % (apiMethodName, type(result).__name__), result, excludeNonPublic=True)
13936
+ return result
13937
+
13938
+ except SpotifyWebApiError: raise # pass handled exceptions on thru
13939
+ except SpotifyWebApiAuthenticationError: raise # pass handled exceptions on thru
13940
+ except Exception as ex:
13941
+
13942
+ # format unhandled exception.
13943
+ raise SpotifyApiError(SAAppMessages.UNHANDLED_EXCEPTION.format(apiMethodName, str(ex)), ex, logsi=_logsi)
13944
+
13945
+ finally:
13946
+
13947
+ # trace.
13948
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
13949
+
13950
+
13951
+ def ZeroconfAddUser(self,
13952
+ actionUrl:str,
13953
+ getInfoResponse:ZeroconfGetInfo,
13954
+ username:str,
13955
+ password:str,
13956
+ ) -> ZeroconfResponse:
13957
+ """
13958
+ Add a user to a Spotify Connect device by calling the Spotify Zeroconf API `addUser` endpoint.
13959
+
13960
+ Args:
13961
+ actionUrl (str):
13962
+ The Zeroconf action url to issue the request to.
13963
+ Example: `http://192.168.1.80:8200/zc?action=addUser&version=1.0`
13964
+
13965
+ Returns:
13966
+ An `ZeroconfResponse` object that contains the response.
13967
+
13968
+ Raises:
13969
+ SpotifyWebApiError:
13970
+ If the Spotify Web API request was for a non-authorization service
13971
+ and the response contains error information.
13972
+ SpotifApiError:
13973
+ If the method fails for any other reason.
13974
+
13975
+ <details>
13976
+ <summary>Sample Code</summary>
13977
+ ```python
13978
+ .. include:: ../docs/include/samplecode/SpotifyClient/ZeroconfGetInformation.py
13979
+ ```
13980
+ </details>
13981
+ """
13982
+ apiMethodName:str = 'ZeroconfAddUser'
13983
+ apiMethodParms:SIMethodParmListContext = None
13984
+ result:ZeroconfResponse = None
13985
+
13986
+ try:
13987
+
13988
+ # trace.
13989
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
13990
+ apiMethodParms.AppendKeyValue("actionUrl", actionUrl)
13991
+ apiMethodParms.AppendKeyValue("getInfoResponse", getInfoResponse)
13992
+ apiMethodParms.AppendKeyValue("username", username)
13993
+ apiMethodParms.AppendKeyValue("password", password)
13994
+ _logsi.LogMethodParmList(SILevel.Verbose, "Get Spotify Zeroconf addUser response", apiMethodParms)
13995
+
13996
+ raise NotImplemented()
13997
+
13998
+ # build credentials object.
13999
+ self.credentials = ZeroconfCredentials(username, password)
14000
+
14001
+ # build authentication blob.
14002
+ builder = ZeroconfBlobBuilder(self.credentials, getInfoResponse.DeviceId, getInfoResponse.PublicKey)
14003
+ blob = builder.build()
14004
+
14005
+ # # call zeroconf to add the user to the device.
14006
+ # r = self._add_user(
14007
+ # self.credentials.username.decode('ascii'),
14008
+ # int_to_b64str(builder.dh_keys.public_key),
14009
+ # blob)
14010
+
14011
+ # def _add_user(self, username: str, client_key: str, blob: str): # -> bool
14012
+ # respone = requests.post(
14013
+ # self.uri,
14014
+ # headers={
14015
+ # 'Content-Type': 'application/x-www-form-urlencoded'
14016
+ # },
14017
+ # params={
14018
+ # 'action': 'addUser',
14019
+ # 'userName': username,
14020
+ # 'clientKey': client_key,
14021
+ # 'blob': blob
14022
+ # }
14023
+ # )
14024
+ # return respone
14025
+
14026
+ # build spotify zeroconf addUser parameters.
14027
+ urlParms:dict = \
14028
+ {
14029
+ 'userName': self.credentials.username.decode('ascii'),
14030
+ 'clientKey': int_to_b64str(builder.dh_keys.public_key),
14031
+ 'blob': blob,
14032
+ 'deviceId': getInfoResponse.DeviceId,
14033
+ 'deviceName': getInfoResponse.RemoteName,
14034
+ 'loginId': 'xxxxxxxxxxx',
14035
+ }
14036
+
14037
+ # let params = [
14038
+ # 3 ("action", "addUser"),
14039
+ # 3 ("userName", username),
14040
+ # 3 ("blob", blob),
14041
+ # 3 ("clientKey", my_public_key),
14042
+ # ("deviceId", &device_id),
14043
+ # ("deviceName", "spotify-connect"),
14044
+ # ("loginId", &login_id),
14045
+
14046
+
14047
+ # execute spotify zeroconf api request.
14048
+ msg:SpotifyApiMessage = SpotifyApiMessage(apiMethodName, actionUrl)
14049
+ msg.UrlParameters = urlParms
14050
+ self.MakeRequest('POST', msg)
14051
+
14052
+ # process results.
14053
+ result = ZeroconfResponse(root=msg.ResponseData)
14054
+
14055
+ # trace.
14056
+ _logsi.LogObject(SILevel.Verbose, TRACE_METHOD_RESULT_TYPE % (apiMethodName, type(result).__name__), result, excludeNonPublic=True)
14057
+ return result
14058
+
14059
+ except SpotifyWebApiError: raise # pass handled exceptions on thru
14060
+ except SpotifyWebApiAuthenticationError: raise # pass handled exceptions on thru
14061
+ except Exception as ex:
14062
+
14063
+ # format unhandled exception.
14064
+ raise SpotifyApiError(SAAppMessages.UNHANDLED_EXCEPTION.format(apiMethodName, str(ex)), ex, logsi=_logsi)
14065
+
14066
+ finally:
14067
+
14068
+ # trace.
14069
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
@@ -3,6 +3,7 @@ try:
3
3
  from queue import Queue, Empty
4
4
  except ImportError:
5
5
  from Queue import Queue, Empty
6
+ from time import sleep
6
7
  from zeroconf import Zeroconf, ServiceBrowser, ServiceInfo, ServiceStateChange, IPVersion
7
8
 
8
9
  # our package imports.
@@ -10,6 +11,7 @@ from .saappmessages import SAAppMessages
10
11
  from .sautils import export
11
12
  from .models.device import Device
12
13
  from .spotifyclient import SpotifyClient, SpotifyApiError
14
+ from .models import ZeroconfDiscoveryResult, ZeroconfProperty
13
15
 
14
16
  # get smartinspect logger reference; create a new session for this module name.
15
17
  from smartinspectpython.siauto import SIAuto, SILevel, SISession
@@ -63,6 +65,7 @@ class SpotifyDiscovery:
63
65
  # initialize instance properties.
64
66
  self._AreDevicesVerified:bool = areDevicesVerified
65
67
  self._DiscoveredDeviceNames:dict = {}
68
+ self._DiscoveryResults:list[ZeroconfDiscoveryResult] = []
66
69
  self._PrintToConsole:bool = printToConsole
67
70
  self._SpotifyClient:SpotifyClient = spotifyClient
68
71
  self._VerifiedDevices:dict = {}
@@ -119,6 +122,15 @@ class SpotifyDiscovery:
119
122
  return self._DiscoveredDeviceNames
120
123
 
121
124
 
125
+ @property
126
+ def DiscoveryResults(self) -> list[ZeroconfDiscoveryResult]:
127
+ """
128
+ An array of `ZeroconfDiscoveryResult` items that contain discovery details for
129
+ each service that was discovered.
130
+ """
131
+ return self._DiscoveryResults
132
+
133
+
122
134
  @property
123
135
  def VerifiedDevices(self) -> dict:
124
136
  """
@@ -170,6 +182,23 @@ class SpotifyDiscovery:
170
182
  # create device instances for each ipv4 address found.
171
183
  for deviceIpAddress in ipAddressList:
172
184
 
185
+ # create new result instance.
186
+ result:ZeroconfDiscoveryResult = ZeroconfDiscoveryResult()
187
+ result.DeviceName = deviceName
188
+ result.Domain = '.local'
189
+ result.HostIpv4Address = deviceIpAddress
190
+ result.HostIpPort = serviceInfo.port
191
+ result.HostTTL = serviceInfo.host_ttl
192
+ result.Key = serviceInfo.key
193
+ result.Name = serviceInfo.name
194
+ result.OtherTTL = serviceInfo.other_ttl
195
+ result.Priority = serviceInfo.priority
196
+ result.Server = serviceInfo.server
197
+ result.ServerKey = serviceInfo.server_key
198
+ result.ServiceInfo = serviceInfo
199
+ result.ServiceType = serviceType
200
+ result.Weight = serviceInfo.weight
201
+
173
202
  devicePort:int = serviceInfo.port
174
203
  deviceKey:str = '%s:%i' % (deviceIpAddress, devicePort)
175
204
  _logsi.LogVerbose("Discovered Spotify Connect device: %s - %s" % (deviceKey, deviceName))
@@ -177,20 +206,30 @@ class SpotifyDiscovery:
177
206
  print("Discovered Spotify Connect device: %s - %s" % (deviceKey, deviceName))
178
207
  _logsi.LogObject(SILevel.Verbose, "Discovered Spotify Connect device ServiceInfo: %s - %s" % (deviceKey, deviceName), serviceInfo)
179
208
 
180
- # # trace service info propertys if desired.
181
- # # note that the property keys and values must first be decoded to utf-8 encoding.
182
- # if _logsi.IsOn(SILevel.Verbose):
183
- # if serviceInfo.properties is not None:
184
- # dspProperties:dict = {}
185
- # for key, value in serviceInfo.properties.items():
186
- # dspProperties[key.decode('utf-8')] = value.decode('utf-8')
187
- # _logsi.LogDictionary(SILevel.Verbose, "Discovered Spotify Connect device ServiceInfo.Properties: '%s' (%s:%i)" % (deviceName, deviceIpAddress, devicePort), dspProperties)
209
+ # process service info propertys.
210
+ # note that the property keys and values must first be decoded to utf-8 encoding.
211
+ if serviceInfo.properties is not None:
212
+ dspProperties:dict = {}
213
+ for key, value in serviceInfo.properties.items():
214
+ keyStr = key.decode('utf-8')
215
+ valueStr = value.decode('utf-8')
216
+ result.Properties.append(ZeroconfProperty(keyStr, valueStr))
217
+ dspProperties[keyStr] = valueStr
218
+ # check for Spotify Connect common properties.
219
+ if keyStr.lower() == 'cpath':
220
+ result.SpotifyConnectCPath = valueStr
221
+ elif keyStr.lower() == 'version':
222
+ result.SpotifyConnectVersion = valueStr
223
+ _logsi.LogDictionary(SILevel.Verbose, "Discovered Spotify Connect device ServiceInfo.Properties: '%s' (%s:%i)" % (deviceName, deviceIpAddress, devicePort), dspProperties)
188
224
 
189
225
  # add the device name to the list (if not already added) using its
190
226
  # ipv4 address and port number as the key.
191
227
  if deviceKey not in self._DiscoveredDeviceNames.keys():
192
228
  self._DiscoveredDeviceNames[deviceKey] = deviceName
193
229
 
230
+ # add the device result to the list.
231
+ self._DiscoveryResults.append(result)
232
+
194
233
  # are we verifying devices connections? if so, then create a SpotifyClient
195
234
  # object, which will verify the connection and gather basic capabilities of the device.
196
235
  # we will also add the SpotifyClient instance using the same key as the device name list.
@@ -198,12 +237,15 @@ class SpotifyDiscovery:
198
237
 
199
238
  if deviceKey not in self._VerifiedDevices.keys():
200
239
 
201
- #device:SpotifyClient = SpotifyClient(host=deviceIpAddress, port=devicePort)
202
-
203
240
  # get Spotify Connect player device by it's Name value.
204
241
  device:Device = self._SpotifyClient.GetPlayerDevice(deviceName)
205
242
  if device is not None:
206
243
  self._VerifiedDevices[deviceKey] = device
244
+ result.SpotifyConnectIsInDeviceList = True
245
+
246
+ # trace.
247
+ _logsi.LogObject(SILevel.Verbose, "Discovered Spotify Connect device result: '%s' (%s:%i)" % (deviceName, deviceIpAddress, devicePort), result, excludeNonPublic=True)
248
+ #_logsi.LogArray(SILevel.Verbose, "Discovered Spotify Connect device result.Properties: '%s' (%s:%i)" % (deviceName, deviceIpAddress, devicePort), result.Properties)
207
249
 
208
250
  elif (serviceStateChange is ServiceStateChange.Removed):
209
251
  _logsi.LogVerbose("Discovered Spotify Connect device removal (ignored): '%s' (%s:%s)", serviceName, serviceType, serviceStateChange)
@@ -258,6 +300,52 @@ class SpotifyDiscovery:
258
300
  return self._DiscoveredDeviceNames
259
301
 
260
302
 
303
+ def BroadcastService(self, serviceInfo:ServiceInfo, port:int=5353) -> None:
304
+ """
305
+ Generate a Zeroconf / mDNS broadcast to announce a service, and sends the broadcast out.
306
+
307
+ Args:
308
+ serviceInfo (ServiceInfo):
309
+ Service information to broadcast.
310
+ port (int):
311
+ mDNS port number to send the broadcast on.
312
+ Default is 5353.
313
+ """
314
+ try:
315
+
316
+ # create the zeroconf service.
317
+ zeroconf:Zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
318
+
319
+ # # TEST TODO - hardcoded serviceinfo properties for testing.
320
+ # serviceType:str = '_spotify-connect._tcp.local.'
321
+ # serviceName:str = 'Bose-ST10-95._spotify-connect._tcp.local.'
322
+ # parsedAddresses:list[str] = ['192.168.1.82']
323
+ # port:int = 8200
324
+ # hostTTL:int = 120
325
+ # priority:int = 0
326
+ # otherTTL:int = 4500
327
+ # server:str = 'Bose-SM2-987bf3244-95.local.'
328
+ # weight:int = 0
329
+ # properties:dict = {b'CPath': b'/zc', b'VERSION': b'1.0'}
330
+
331
+ # # create the zeroconf serviceinfo object.
332
+ # zcfServiceInfo:ServiceInfo = ServiceInfo(serviceType, serviceName, port, weight, priority, properties, server, hostTTL, otherTTL, parsed_addresses=parsedAddresses)
333
+
334
+ # generate a broadcast packet to announce the service.
335
+ _logsi.LogVerbose("Generating Zeroconf Service Broadcast for service: '%s'" % serviceInfo.name)
336
+ dnsOutgoing = zeroconf.generate_service_broadcast(serviceInfo, serviceInfo.host_ttl, True)
337
+
338
+ # send the broadcast packet.
339
+ _logsi.LogVerbose("Sending Zeroconf Service Broadcast for service: '%s'" % serviceInfo.name)
340
+ zeroconf.send(dnsOutgoing, addr=None, port=port)
341
+
342
+ except Exception as ex:
343
+
344
+ # this is not really an exception, but more of an indicator that
345
+ # the timeout has been reached.
346
+ _logsi.LogException("Zeroconf Broadcast Service exception: %s" % str(ex), ex, logToSystemLogger=False)
347
+
348
+
261
349
  def ToString(self, includeItems:bool=False) -> str:
262
350
  """
263
351
  Returns a displayable string representation of the class.
@@ -273,7 +361,6 @@ class SpotifyDiscovery:
273
361
  if includeItems == True:
274
362
 
275
363
  for key, deviceName in self._DiscoveredDeviceNames.items():
276
-
277
364
  device:Device
278
365
  verifiedStatus:str = ''
279
366
  device = self._VerifiedDevices.get(key, None)