spotifywebapiPython 1.0.242__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. spotifywebapipython/__init__.py +35 -0
  2. spotifywebapipython/const.py +114 -0
  3. spotifywebapipython/docs/__init__.html +2156 -0
  4. spotifywebapipython/docs/codecopy.js +70 -0
  5. spotifywebapipython/docs/index.html +514 -0
  6. spotifywebapipython/docs/search.js +46 -0
  7. spotifywebapipython/docs/spotifywebapipython/const.html +636 -0
  8. spotifywebapipython/docs/spotifywebapipython/saappmessages.html +540 -0
  9. spotifywebapipython/docs/spotifywebapipython/sautils.html +620 -0
  10. spotifywebapipython/docs/spotifywebapipython/spotifyapierror.html +341 -0
  11. spotifywebapipython/docs/spotifywebapipython/spotifyapimessage.html +598 -0
  12. spotifywebapipython/docs/spotifywebapipython/spotifyauthtoken.html +612 -0
  13. spotifywebapipython/docs/spotifywebapipython/spotifyclient.html +16557 -0
  14. spotifywebapipython/docs/spotifywebapipython/spotifydiscovery.html +518 -0
  15. spotifywebapipython/docs/spotifywebapipython/spotifywebapiauthenticationerror.html +421 -0
  16. spotifywebapipython/docs/spotifywebapipython/spotifywebapierror.html +400 -0
  17. spotifywebapipython/docs/spotifywebapipython/spotifywebplayertoken.html +635 -0
  18. spotifywebapipython/docs/spotifywebapipython.ico +0 -0
  19. spotifywebapipython/docs/spotifywebapipython_OLD.ico +0 -0
  20. spotifywebapipython/models/__init__.py +121 -0
  21. spotifywebapipython/models/album.py +170 -0
  22. spotifywebapipython/models/albumpagesaved.py +85 -0
  23. spotifywebapipython/models/albumpagesimplified.py +88 -0
  24. spotifywebapipython/models/albumsaved.py +102 -0
  25. spotifywebapipython/models/albumsimplified.py +308 -0
  26. spotifywebapipython/models/artist.py +146 -0
  27. spotifywebapipython/models/artistinfo.py +282 -0
  28. spotifywebapipython/models/artistinfotourevent.py +127 -0
  29. spotifywebapipython/models/artistpage.py +70 -0
  30. spotifywebapipython/models/artistsimplified.py +163 -0
  31. spotifywebapipython/models/audiobook.py +87 -0
  32. spotifywebapipython/models/audiobookpagesimplified.py +88 -0
  33. spotifywebapipython/models/audiobooksimplified.py +362 -0
  34. spotifywebapipython/models/audiofeatures.py +333 -0
  35. spotifywebapipython/models/author.py +67 -0
  36. spotifywebapipython/models/category.py +168 -0
  37. spotifywebapipython/models/categorypage.py +70 -0
  38. spotifywebapipython/models/chapter.py +84 -0
  39. spotifywebapipython/models/chapterpagesimplified.py +88 -0
  40. spotifywebapipython/models/chaptersimplified.py +387 -0
  41. spotifywebapipython/models/context.py +113 -0
  42. spotifywebapipython/models/copyright.py +79 -0
  43. spotifywebapipython/models/device.py +301 -0
  44. spotifywebapipython/models/episode.py +84 -0
  45. spotifywebapipython/models/episodepagesaved.py +85 -0
  46. spotifywebapipython/models/episodepagesimplified.py +88 -0
  47. spotifywebapipython/models/episodesaved.py +99 -0
  48. spotifywebapipython/models/episodesimplified.py +370 -0
  49. spotifywebapipython/models/explicitcontent.py +80 -0
  50. spotifywebapipython/models/externalids.py +91 -0
  51. spotifywebapipython/models/externalurls.py +104 -0
  52. spotifywebapipython/models/followers.py +81 -0
  53. spotifywebapipython/models/imageobject.py +193 -0
  54. spotifywebapipython/models/imagevibrantcolors.py +182 -0
  55. spotifywebapipython/models/linkedfrom.py +165 -0
  56. spotifywebapipython/models/narrator.py +67 -0
  57. spotifywebapipython/models/owner.py +162 -0
  58. spotifywebapipython/models/pageobject.py +343 -0
  59. spotifywebapipython/models/playeractions.py +196 -0
  60. spotifywebapipython/models/playerlastplayedinfo.py +334 -0
  61. spotifywebapipython/models/playerplaystate.py +467 -0
  62. spotifywebapipython/models/playerqueueinfo.py +207 -0
  63. spotifywebapipython/models/playhistory.py +136 -0
  64. spotifywebapipython/models/playhistorypage.py +85 -0
  65. spotifywebapipython/models/playlist.py +125 -0
  66. spotifywebapipython/models/playlistpage.py +85 -0
  67. spotifywebapipython/models/playlistpagesimplified.py +107 -0
  68. spotifywebapipython/models/playlistsimplified.py +334 -0
  69. spotifywebapipython/models/playlisttrack.py +143 -0
  70. spotifywebapipython/models/playlisttracksummary.py +80 -0
  71. spotifywebapipython/models/recommendationseed.py +135 -0
  72. spotifywebapipython/models/restrictions.py +86 -0
  73. spotifywebapipython/models/resumepoint.py +79 -0
  74. spotifywebapipython/models/searchresponse.py +391 -0
  75. spotifywebapipython/models/searchresultbase.py +299 -0
  76. spotifywebapipython/models/show.py +87 -0
  77. spotifywebapipython/models/showpagesaved.py +85 -0
  78. spotifywebapipython/models/showpagesimplified.py +88 -0
  79. spotifywebapipython/models/showsaved.py +99 -0
  80. spotifywebapipython/models/showsimplified.py +320 -0
  81. spotifywebapipython/models/spotifyconnectdevice.py +321 -0
  82. spotifywebapipython/models/spotifyconnectdevices.py +441 -0
  83. spotifywebapipython/models/track.py +142 -0
  84. spotifywebapipython/models/trackpage.py +110 -0
  85. spotifywebapipython/models/trackpagesaved.py +85 -0
  86. spotifywebapipython/models/trackpagesimplified.py +88 -0
  87. spotifywebapipython/models/trackrecommendations.py +88 -0
  88. spotifywebapipython/models/tracksaved.py +99 -0
  89. spotifywebapipython/models/tracksimplified.py +398 -0
  90. spotifywebapipython/models/userprofile.py +184 -0
  91. spotifywebapipython/models/userprofilesimplified.py +206 -0
  92. spotifywebapipython/models/zeroconfdiscoveryresult.py +592 -0
  93. spotifywebapipython/models/zeroconfproperty.py +93 -0
  94. spotifywebapipython/oauthcli/__init__.py +7 -0
  95. spotifywebapipython/oauthcli/authclient.py +1229 -0
  96. spotifywebapipython/saappmessages.py +86 -0
  97. spotifywebapipython/sautils.py +285 -0
  98. spotifywebapipython/spotifyapierror.py +66 -0
  99. spotifywebapipython/spotifyapimessage.py +269 -0
  100. spotifywebapipython/spotifyauthtoken.py +264 -0
  101. spotifywebapipython/spotifyclient.py +17885 -0
  102. spotifywebapipython/spotifyconnect/__init__.py +19 -0
  103. spotifywebapipython/spotifyconnect/spotifyconnectdeviceeventargs.py +67 -0
  104. spotifywebapipython/spotifyconnect/spotifyconnectdirectorytask.py +2564 -0
  105. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastapptask.py +467 -0
  106. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastcontroller.py +701 -0
  107. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastlistener.py +359 -0
  108. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastmultizonelistener.py +212 -0
  109. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfexceptions.py +20 -0
  110. spotifywebapipython/spotifyconnect/spotifyconnectzeroconflistener.py +335 -0
  111. spotifywebapipython/spotifydiscovery.py +473 -0
  112. spotifywebapipython/spotifymediatypes.py +53 -0
  113. spotifywebapipython/spotifywebapiauthenticationerror.py +122 -0
  114. spotifywebapipython/spotifywebapierror.py +106 -0
  115. spotifywebapipython/spotifywebplayertoken.py +743 -0
  116. spotifywebapipython/vibrant/__init__.py +17 -0
  117. spotifywebapipython/vibrant/generator.py +261 -0
  118. spotifywebapipython/vibrant/image.py +100 -0
  119. spotifywebapipython/vibrant/main.py +34 -0
  120. spotifywebapipython/vibrant/models.py +93 -0
  121. spotifywebapipython/vibrant/utils.py +56 -0
  122. spotifywebapipython/zeroconfapi/__init__.py +23 -0
  123. spotifywebapipython/zeroconfapi/authenticationtypes.py +38 -0
  124. spotifywebapipython/zeroconfapi/blobbuilder.py +370 -0
  125. spotifywebapipython/zeroconfapi/credentials.py +37 -0
  126. spotifywebapipython/zeroconfapi/cryptodiffiehellman.py +89 -0
  127. spotifywebapipython/zeroconfapi/helpers.py +64 -0
  128. spotifywebapipython/zeroconfapi/spotifyzeroconfapierror.py +108 -0
  129. spotifywebapipython/zeroconfapi/zeroconfconnect.py +1327 -0
  130. spotifywebapipython/zeroconfapi/zeroconfgetinfo.py +572 -0
  131. spotifywebapipython/zeroconfapi/zeroconfgetinfoalias.py +109 -0
  132. spotifywebapipython/zeroconfapi/zeroconfgetinfodrmmediaformat.py +90 -0
  133. spotifywebapipython/zeroconfapi/zeroconfresponse.py +195 -0
  134. spotifywebapipython-1.0.242.dist-info/LICENSE +22 -0
  135. spotifywebapipython-1.0.242.dist-info/METADATA +219 -0
  136. spotifywebapipython-1.0.242.dist-info/RECORD +138 -0
  137. spotifywebapipython-1.0.242.dist-info/WHEEL +5 -0
  138. spotifywebapipython-1.0.242.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2564 @@
1
+ # external package imports.
2
+ from abc import abstractmethod
3
+ from collections.abc import Mapping
4
+ import copy
5
+ from datetime import datetime
6
+ import hashlib
7
+ from pychromecast import CastBrowser, CastInfo, Chromecast, get_chromecast_from_cast_info, get_chromecast_from_host
8
+ from pychromecast.dial import MultizoneInfo, MultizoneStatus, get_multizone_status
9
+ from pychromecast.controllers.multizone import MultizoneController
10
+ from pychromecast.const import CAST_TYPE_GROUP
11
+ import socket
12
+ from soco import SoCo
13
+ import threading
14
+ import time
15
+ from uuid import UUID
16
+ from zeroconf import Zeroconf, ServiceBrowser
17
+
18
+ # our package imports.
19
+ from .spotifyconnectzeroconfexceptions import SpotifyConnectDeviceNotFound
20
+ from .spotifyconnectzeroconfcastapptask import SpotifyConnectZeroconfCastAppTask
21
+ from .spotifyconnectzeroconfcastlistener import SpotifyConnectZeroconfCastListener
22
+ from .spotifyconnectzeroconfcastmultizonelistener import SpotifyConnectZeroconfCastMultiZoneListener
23
+ from .spotifyconnectdeviceeventargs import SpotifyConnectDeviceEventArgs
24
+ from .spotifyconnectzeroconflistener import ZEROCONF_SERVICETYPE_SPOTIFYCONNECT, SpotifyConnectZeroconfListener
25
+ from .spotifyconnectzeroconfcastcontroller import (
26
+ TYPE_ADD_USER_RESPONSE,
27
+ TYPE_ADD_USER_ERROR,
28
+ TYPE_GET_INFO_ERROR,
29
+ TYPE_LAUNCH_ERROR,
30
+ TYPE_TRANSFER_ERROR,
31
+ TYPE_TRANSFER_SUCCESS
32
+ )
33
+ # note - cannot reference `SpotifyClient` due to circular import!
34
+ # from spotifywebapipython import SpotifyClient
35
+ from spotifywebapipython import SpotifyApiError
36
+ from spotifywebapipython.models import Device, PlayerPlayState, SpotifyConnectDevices, SpotifyConnectDevice, ZeroconfDiscoveryResult
37
+ from spotifywebapipython.saappmessages import SAAppMessages
38
+ from spotifywebapipython.sautils import Event, validateDelay
39
+ from spotifywebapipython.spotifymediatypes import SpotifyMediaTypes
40
+ from spotifywebapipython.zeroconfapi import ZeroconfGetInfo, ZeroconfConnect, ZeroconfResponse
41
+
42
+ # get smartinspect logger reference; create a new session for this module name.
43
+ from smartinspectpython.siauto import SIAuto, SILevel, SISession, SIMethodParmListContext, SIColors
44
+ import logging
45
+
46
+ _logsi:SISession = SIAuto.Si.GetSession(__name__)
47
+ if (_logsi == None):
48
+ _logsi = SIAuto.Si.AddSession(__name__, True)
49
+ _logsi.SystemLogger = logging.getLogger(__name__)
50
+
51
+
52
+ class SpotifyConnectDirectoryTask(threading.Thread):
53
+ """
54
+ Spotify Connect Directory thread task.
55
+
56
+ This class discovers available Spotify Connect devices on the local network.
57
+ Discovered devices are then added to an internal list, which can be referenced
58
+ by clients.
59
+
60
+ The directory task is started immediately after the authorization token is
61
+ established in the SpotifyClient object.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ spotifyClientInstance, # :SpotifyClient,
67
+ zeroconfInstance:Zeroconf=None,
68
+ initialDiscoveryTimeout:float=3.0,
69
+ ) -> None:
70
+ """
71
+ Initializes a new instance of the class.
72
+
73
+ Args:
74
+ spotifyClientInstance (SpotifyClient):
75
+ SpotifyClient instance used to interface with the Spotify Web API.
76
+ zeroconfInstance (Zeroconf):
77
+ Zeroconf instance used to query zeroconf; otherwise, None to
78
+ create a new zeroconf instance.
79
+ initialDiscoveryTimeout (float):
80
+ Time (in seconds) to give child Zeroconf discovery threads time to process
81
+ initial Zeroconf service information changes.
82
+ Default is 3.
83
+
84
+ The `initialDiscoveryTimeout` argument gives the child Zeroconf discovery threads
85
+ time to process initial Zeroconf service information changes prior to setting the
86
+ `WaitForInitComplete` event as complete. This gives the directory thread task
87
+ time to discover all of the Spotify Connect devices currently attached to the
88
+ local network.
89
+ """
90
+ # invoke base class method.
91
+ super().__init__()
92
+
93
+ # note that the initialization method executes in the parent thread;
94
+ # the code in the `run` method executes on a child (daemon) thread.
95
+
96
+ # set thread name before we start logging.
97
+ self.name = "Spotify Connect Directory Task"
98
+
99
+ # validations.
100
+ if (spotifyClientInstance is None):
101
+ raise SpotifyApiError(SAAppMessages.ARGUMENT_REQUIRED_ERROR % ("__init__", 'spotifyClientInstance'), logsi=_logsi)
102
+ if (spotifyClientInstance.SpotifyConnectLoginId is None) or (spotifyClientInstance.SpotifyConnectLoginId.strip() == ""):
103
+ raise SpotifyApiError(SAAppMessages.ARGUMENT_REQUIRED_ERROR % ("__init__", 'spotifyClientInstance.SpotifyConnectLoginId'), logsi=_logsi)
104
+ if (not isinstance(initialDiscoveryTimeout, float)) and (not isinstance(initialDiscoveryTimeout, int)):
105
+ initialDiscoveryTimeout = 3.0
106
+
107
+ # initialize storage.
108
+ self._CastAppTasks:dict[str, SpotifyConnectZeroconfCastAppTask] = {}
109
+ self._CastBrowser:CastBrowser = None
110
+ self._CastMultiZoneControllers:dict[str, MultizoneController] = {}
111
+ self._InitialDiscoveryTimeout = initialDiscoveryTimeout
112
+ self._IsStopRequested:bool = False
113
+ self._SonosPlayers:dict = {}
114
+ self._SpotifyClientInstance = spotifyClientInstance
115
+ self._SpotifyConnectBrowser:ServiceBrowser = None
116
+ self._SpotifyConnectDevices:SpotifyConnectDevices = SpotifyConnectDevices()
117
+ self._SpotifyConnectDevices_RLock = threading.RLock() # re-entrant lock to sync access to devices collection.
118
+ self._ZeroconfInstance:Zeroconf = zeroconfInstance
119
+ self._Zeroconf_RLock = threading.RLock() # re-entrant lock to sync access to zeroconf service state changes.
120
+
121
+ # define all events raised by this class.
122
+ self.DeviceAdded = Event()
123
+ """
124
+ Event raised when a Spotify Connect device entry has been added.
125
+ """
126
+
127
+ self.DeviceRemoved = Event()
128
+ """
129
+ Event raised when a Spotify Connect device entry has been removed.
130
+ """
131
+
132
+ self.DeviceUpdated = Event()
133
+ """
134
+ Event raised when a Spotify Connect device entry has been updated.
135
+ """
136
+
137
+ # define all threading events raised by this class.
138
+ self.WaitForInitComplete = threading.Event()
139
+ """
140
+ Event that will be posted when the task has been initialized, and is
141
+ waiting for commands or device notifications.
142
+ """
143
+
144
+ self.WaitForActivationComplete = threading.Event()
145
+ """
146
+ Event that will be posted when the task has completed the activation sequence for
147
+ the device, which is either a `addUserResponse` or `addUserError` notification.
148
+ """
149
+
150
+ self.WaitForTransferComplete = threading.Event()
151
+ """
152
+ Event that will be posted when the task has completed the playback transfer sequence for
153
+ the device, which is either a `transferSuccess` or `transferError` notification.
154
+ """
155
+
156
+ # wire up event handlers.
157
+ self.DeviceAdded += self.OnDeviceAdded
158
+ self.DeviceRemoved += self.OnDeviceRemoved
159
+ self.DeviceUpdated += self.OnDeviceUpdated
160
+
161
+ # clear event notifiers.
162
+ self.WaitForInitComplete.clear()
163
+ self.WaitForActivationComplete.set() # indicate activation is complete (initial state).
164
+ self.WaitForTransferComplete.set() # indicate transfer is complete (initial state).
165
+
166
+
167
+ @property
168
+ def InitialDiscoveryTimeout(self) -> float:
169
+ """
170
+ Time to wait (in seconds) after starting the Zeroconf discovery thread
171
+ tasks before marking the directory task initialization as complete.
172
+ """
173
+ return self._InitialDiscoveryTimeout
174
+
175
+
176
+ @property
177
+ def IsStopRequested(self) -> bool:
178
+ """
179
+ Indicator used to denote the task has been asked to stop by the main thread.
180
+ """
181
+ return self._IsStopRequested
182
+
183
+ @IsStopRequested.setter
184
+ def IsStopRequested(self, value:bool):
185
+ """
186
+ Sets the IsStopRequested property value.
187
+ """
188
+ if isinstance(value, bool):
189
+ self._IsStopRequested = value
190
+
191
+
192
+ @property
193
+ def CastAppTasks(self) -> dict[str, SpotifyConnectZeroconfCastAppTask]:
194
+ """
195
+ Collection of SpotifyConnectZeroconfCastAppTask objects that have been created
196
+ when transferring playback.
197
+ """
198
+ return self._CastAppTasks
199
+
200
+
201
+ @property
202
+ def CastBrowser(self) -> CastBrowser:
203
+ """
204
+ Returns the internal CastBrowser object.
205
+ """
206
+ return self._CastBrowser
207
+
208
+
209
+ @property
210
+ def IsZeroconDiscoveryEnabled(self) -> bool:
211
+ """
212
+ Returns True if Zeroconf discovery is enabled; otherwise, False.
213
+ """
214
+ # is discovery enabled?
215
+ # is zeroconf discovery enabled?
216
+ if (not self.is_alive()):
217
+ return False
218
+ if (self._InitialDiscoveryTimeout == 0):
219
+ return False
220
+ return True
221
+
222
+
223
+ @property
224
+ def SpotifyClientInstance(self) -> object: # SpotifyClient:
225
+ """
226
+ Returns the SpotifyClient instance.
227
+ """
228
+ return self._SpotifyClientInstance
229
+
230
+
231
+ @property
232
+ def ZeroconfInstance(self) -> Zeroconf:
233
+ """
234
+ Returns the zeroconf instance.
235
+ """
236
+ return self._ZeroconfInstance
237
+
238
+
239
+ def run(self):
240
+ """
241
+ The task to perform on a seperate thread.
242
+ """
243
+ try:
244
+
245
+ # trace.
246
+ _logsi.LogVerbose("%s - Starting thread RUN / TASK method" % (self.name))
247
+ _logsi.LogThread(SILevel.Debug, "%s - Thread information" % (self.name), self)
248
+
249
+ # refresh dynamic device list.
250
+ self.RefreshDynamicDevices()
251
+
252
+ # is zeroconf discovery enabled?
253
+ if (not self.IsZeroconDiscoveryEnabled):
254
+
255
+ _logsi.LogVerbose("%s - Spotify Connect Zeroconf discovery is disabled; only Spotify Web API player devices will be recognized" % (self.name), colorValue=SIColors.Coral)
256
+
257
+ else:
258
+
259
+ # create new spotify connect zeroconf discovery browser instance.
260
+ # this will discover spotify connect devices on the local network.
261
+ # when a device is found, cast_listener.add_cast is called.
262
+ # when a deviceost, the cast_listener.remove_cast is called
263
+ # when a devicepdated, cast_listener.update_cast is called.
264
+ _logsi.LogVerbose("%s - Starting Spotify Connect Zeroconf discovery browser" % (self.name))
265
+ handler:SpotifyConnectZeroconfListener = SpotifyConnectZeroconfListener(self, self._Zeroconf_RLock)
266
+ self._SpotifyConnectBrowser = ServiceBrowser(
267
+ self._ZeroconfInstance,
268
+ ZEROCONF_SERVICETYPE_SPOTIFYCONNECT,
269
+ handlers=[handler.OnServiceStateChange]
270
+ )
271
+
272
+ # create new chromecast zeroconf discovery browser instance.
273
+ # this will discover Chromecasts on the local network.
274
+ # when a Chromecast is found, cast_listener.add_cast is called.
275
+ # when a Chromecast is lost, the cast_listener.remove_cast is called
276
+ # when a Chromecast is updated, cast_listener.update_cast is called.
277
+ _logsi.LogVerbose("%s - Starting Chromecast Zeroconf discovery browser" % (self.name))
278
+ self._CastBrowser = CastBrowser(
279
+ zeroconf_instance=self._ZeroconfInstance,
280
+ cast_listener=SpotifyConnectZeroconfCastListener(self, self._Zeroconf_RLock),
281
+ known_hosts=None
282
+ )
283
+ self._CastBrowser.start_discovery()
284
+
285
+ # give child Zeroconf discovery threads time to process initial service information discovery.
286
+ time.sleep(self._InitialDiscoveryTimeout)
287
+
288
+ # trace - dump devices discovered initially.
289
+ if (_logsi.IsOn(SILevel.Verbose)):
290
+
291
+ # use lock, as zeroconf tasks could be updating the device list.
292
+ with self._Zeroconf_RLock:
293
+
294
+ # trace.
295
+ _logsi.LogVerbose("%s - Spotify Connect devices discovered at initialization (%s items)" % (self.name, len(self._SpotifyConnectDevices)), colorValue=SIColors.Coral)
296
+
297
+ # trace - log all devices that were discovered initially to SmartInspect console.
298
+ for scDevice in self._SpotifyConnectDevices.Items:
299
+ isActive:str = " (active)" if (scDevice.IsActiveDevice) else ""
300
+ _logsi.LogObject(SILevel.Verbose, "Spotify Connect device: %s [%s]%s - object" % (scDevice.Title, scDevice.DiscoveryResult.Description, isActive), scDevice, excludeNonPublic=True, colorValue=SIColors.Coral)
301
+ _logsi.LogObject(SILevel.Verbose, "Spotify Connect device: %s [%s]%s - DiscoveryResult" % (scDevice.Title, scDevice.DiscoveryResult.Description, isActive), scDevice.DiscoveryResult, excludeNonPublic=True, colorValue=SIColors.Coral)
302
+ _logsi.LogObject(SILevel.Verbose, "Spotify Connect device: %s [%s]%s - DeviceInfo / getInfo" % (scDevice.Title, scDevice.DiscoveryResult.Description, isActive), scDevice.DeviceInfo, excludeNonPublic=True, colorValue=SIColors.Coral)
303
+
304
+ # trace - log all devices that were discovered initially to system logger.
305
+ # (since LogObject does not log to system logger).
306
+ for scDevice in self._SpotifyConnectDevices.Items:
307
+ isActive:str = " (active)" if (scDevice.IsActiveDevice) else ""
308
+ _logsi.LogVerbose("Spotify Connect device: %s [%s]%s" % (scDevice.Title, scDevice.DiscoveryResult.Description, isActive))
309
+
310
+ # indicate we are ready for commands.
311
+ self.WaitForInitComplete.set()
312
+
313
+ # event loop.
314
+ # note that the directory task must be kept running in order to control Chromecast devices!
315
+ while True:
316
+
317
+ try:
318
+
319
+ # keep going until we are asked to stop.
320
+ if (self.IsStopRequested):
321
+ _logsi.LogVerbose("%s - Thread task stop requested" % (self.name))
322
+ break
323
+ time.sleep(0.50)
324
+
325
+ except Exception as ex:
326
+
327
+ # trace.
328
+ _logsi.LogException("%s - Exception: %s" % (str(ex)), ex, logToSystemLogger=False)
329
+ # ignore exceptions, as we can't do anything about them.
330
+
331
+ # at this point we have been requested to stop;
332
+ # loop through all active cast app tasks and unregister handlers.
333
+ # note that this will not stop the Spotify App Task that is running
334
+ # on the device; we don't stop the app because it would remove the
335
+ # Spotify Connect registration.
336
+ castAppTask:SpotifyConnectZeroconfCastAppTask = None
337
+ for castAppTask in self._CastAppTasks.values():
338
+ if (castAppTask.is_alive()):
339
+ _logsi.LogVerbose("%s - Stopping %s" % (self.name, castAppTask.name))
340
+ castAppTask.IsStopRequested = True
341
+ castAppTask.join()
342
+
343
+ # trace.
344
+ _logsi.LogVerbose("%s - Thread task was stopped" % (self.name))
345
+
346
+ except Exception as ex:
347
+
348
+ # trace.
349
+ _logsi.LogException("%s - Exception: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
350
+ _logsi.LogVerbose("%s - Thread task is ending due to exception" % (self.name))
351
+ # ignore exceptions, since we are shutting down.
352
+
353
+ # indicate we are ready for commands.
354
+ self.WaitForInitComplete.set()
355
+
356
+ finally:
357
+
358
+ try:
359
+
360
+ # unwire event handlers.
361
+ _logsi.LogVerbose("%s - Unwiring event handlers" % (self.name))
362
+ if (self.DeviceAdded is not None):
363
+ self.DeviceAdded -= self.OnDeviceAdded
364
+ if (self.DeviceRemoved is not None):
365
+ self.DeviceRemoved -= self.OnDeviceRemoved
366
+ if (self.DeviceUpdated is not None):
367
+ self.DeviceUpdated -= self.OnDeviceUpdated
368
+
369
+ except:
370
+
371
+ # trace.
372
+ _logsi.LogException("%s - An unhandled exception occured while unwiring event handlers: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
373
+ # ignore exceptions, since we are shutting down.
374
+
375
+ try:
376
+
377
+ # stop spotify connect zeroconf discovery browser.
378
+ if (self._SpotifyConnectBrowser is not None):
379
+ _logsi.LogVerbose("%s - Stopping Spotify Connect Zeroconf discovery" % (self.name))
380
+ self._SpotifyConnectBrowser.cancel()
381
+ _logsi.LogObject(SILevel.Verbose, "%s - ZeroconfInstance object after ServiceBrowser resources released" % (self.name), self._ZeroconfInstance)
382
+
383
+ except:
384
+
385
+ # trace.
386
+ _logsi.LogException("%s - An unhandled exception occured while stopping Spotify Connect Zeroconf discovery browser: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
387
+ # ignore exceptions, since we are shutting down.
388
+
389
+ try:
390
+
391
+ # stop chromecast zeroconf discovery browser.
392
+ if (self._CastBrowser is not None):
393
+ _logsi.LogVerbose("%s - Stopping Chromecast Zeroconf discovery" % (self.name))
394
+ self._CastBrowser.stop_discovery()
395
+ _logsi.LogObject(SILevel.Verbose, "%s - ZeroconfInstance object after CastBrowser resources released" % (self.name), self._ZeroconfInstance)
396
+
397
+ except:
398
+
399
+ # trace.
400
+ _logsi.LogException("%s - An unhandled exception occured while stopping Chromecast Zeroconf discovery browser: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
401
+ # ignore exceptions, since we are shutting down.
402
+
403
+
404
+ def ActivateCastAppSpotify(
405
+ self,
406
+ deviceName:str,
407
+ transferPlayback:bool=False,
408
+ timeoutActivation:float=15.0,
409
+ timeoutTransfer:float=10.0,
410
+ ) -> str:
411
+ """
412
+ Activates the Spotify Cast Application on the specified Chromecast device.
413
+
414
+ It will also call the `OnCastGetInfoResponseReceived` method when the Spotify Cast App
415
+ receives a `GetInfoResponse` block that contains Spotify Connect Zeroconf GetInformation
416
+ data about the device.
417
+
418
+ It will also call the `OnCastZeroconfResponseReceived` method when the Spotify Cast App
419
+ receives any of the following message that contain zeroconf response data: `addUserResponse`,
420
+ `addUserError`, `transferError`, `transferSuccess`.
421
+
422
+ Once activated, you have about 20 seconds to transfer playback to the device.
423
+ The Spotify Cast application will automatically end after 20 seconds if playback
424
+ is not transferred to it within the timeout period. Note that any application can
425
+ be used to transfer playback to the device (Spotify Mobile / Desktop / Web, spotipy,
426
+ spotifywebapipython, etc).
427
+
428
+ Args:
429
+ deviceName (str):
430
+ Chromecast device friendly name (or device id) to activate.
431
+ transferPlayback (bool):
432
+ True to transfer playback to the device; otherwise, False to just activate
433
+ the Spotify Cast App on the device.
434
+ timeoutActivation (float):
435
+ Amount of time to wait (in seconds) for the Spotify Cast App to be fully activated,
436
+ the user logged in, and ready for transfer of playback.
437
+ Default is 15 seconds.
438
+ timeoutTransfer (float):
439
+ If `transferPlayback` argument is True, the amount of time to wait (in seconds)
440
+ for the transfer to complete; otherwise, argument is ignored.
441
+ Default is 10 seconds.
442
+
443
+ Returns:
444
+ The deviceId that was activated.
445
+ """
446
+ apiMethodName:str = "ActivateCastAppSpotify"
447
+ apiMethodParms:SIMethodParmListContext = None
448
+
449
+ try:
450
+
451
+ # trace.
452
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
453
+ apiMethodParms.AppendKeyValue("deviceName", deviceName)
454
+ apiMethodParms.AppendKeyValue("transferPlayback", transferPlayback)
455
+ apiMethodParms.AppendKeyValue("timeoutActivation", timeoutActivation)
456
+ apiMethodParms.AppendKeyValue("timeoutTransfer", timeoutTransfer)
457
+ _logsi.LogMethodParmList(SILevel.Verbose, "Activating Spotify App on Chromecast device: \"%s\"" % (deviceName), apiMethodParms)
458
+
459
+ # validations.
460
+ timeoutActivation = validateDelay(timeoutActivation, 15.0, 30.0)
461
+ timeoutTransfer = validateDelay(timeoutTransfer, 10.0, 20.0)
462
+ if (deviceName is None) or (deviceName.strip() == ""):
463
+ raise SpotifyApiError(SAAppMessages.ARGUMENT_REQUIRED_ERROR % (apiMethodName, 'deviceName'), logsi=_logsi)
464
+ if (transferPlayback is None):
465
+ transferPlayback = False
466
+
467
+ # validation - Spotify Connect device name / id.
468
+ # syncronize access via lock, as we are accessing the collection.
469
+ with self._SpotifyConnectDevices_RLock:
470
+
471
+ # search for device id first; if no matches, then search for name.
472
+ scDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceById(deviceName)
473
+ if (scDevice is None):
474
+ scDevice = self._SpotifyConnectDevices.GetDeviceByName(deviceName)
475
+ if (scDevice is None):
476
+ raise SpotifyApiError("Chromecast device \"%s\" could not be found in the SpotifyConnectDevices collection." % (deviceName), logsi=_logsi)
477
+
478
+ # match was found; reset device id activated before we activate a device.
479
+ scDevice.DeviceIdActivated = None
480
+
481
+ # is this a chromecast device?
482
+ if (not scDevice.IsChromeCast):
483
+ raise SpotifyApiError("Device %s is not a Chromecast device; use TransferPlayback instead." % (scDevice.Title), logsi=_logsi)
484
+
485
+ # is zeroconf discovery disabled?
486
+ if (not self.IsZeroconDiscoveryEnabled):
487
+ raise SpotifyApiError("Zeroconf discovery is disabled; cannot activate Chromecast devices.", logsi=_logsi)
488
+
489
+ # has discovery browser been started yet?
490
+ if (self._CastBrowser is None):
491
+ raise SpotifyApiError("Chromecast discovery browser reference not set; the Browser must be started prior to calling this method.", logsi=_logsi)
492
+
493
+ # any devices available?
494
+ if (len(self._CastBrowser.devices) == 0):
495
+ raise SpotifyApiError("No available Chromecast devices detected.", logsi=_logsi)
496
+
497
+ # are we waiting on a previous request to finish?
498
+ if (not self.WaitForActivationComplete.is_set):
499
+ raise SpotifyApiError("Waiting for previous Chromecast device activation to complete; try again in a few seconds.", logsi=_logsi)
500
+ if (not self.WaitForTransferComplete.is_set):
501
+ raise SpotifyApiError("Waiting for previous Chromecast device playback transfer to complete; try again in a few seconds.", logsi=_logsi)
502
+
503
+ # clear event notifiers.
504
+ self.WaitForActivationComplete.clear()
505
+ self.WaitForTransferComplete.clear()
506
+
507
+ # if Spotify Cast App is active on a cast device then request that
508
+ # it stop, as we will add a new one below.
509
+ # note that this will not stop the Spotify App Task that is running
510
+ # on the device; we don't stop the app because it would remove the
511
+ # Spotify Connect registration.
512
+ castAppTask:SpotifyConnectZeroconfCastAppTask = self._CastAppTasks.get(scDevice.DiscoveryResult.Key, None)
513
+ if (castAppTask is not None):
514
+ if (not castAppTask.is_alive()):
515
+ _logsi.LogVerbose("%s - Stopping Spotify Cast App task" % (self.name))
516
+ castAppTask.IsStopRequested = True
517
+ castAppTask.join()
518
+ _logsi.LogVerbose("%s - Spotify Cast App task was stopped successfully" % (self.name))
519
+ self._CastAppTasks.pop(scDevice.DiscoveryResult.Key, None)
520
+
521
+ # get CastInfo details of the specified device.
522
+ _logsi.LogVerbose("%s - Getting Chromecast information for device: \"%s\" [%s]" % (self.name, deviceName, scDevice.DiscoveryResult.HostIpTitle))
523
+ castInfo:CastInfo = self._CastBrowser.devices[UUID(scDevice.DiscoveryResult.Key)]
524
+ _logsi.LogObject(SILevel.Verbose, "%s - Chromecast CastInfo details: \"%s\" (ip=%s:%s)" % (self.name, castInfo.friendly_name, castInfo.host, castInfo.port), castInfo)
525
+
526
+ # syncronize access via lock, as we are accessing the collection.
527
+ with self._SpotifyConnectDevices_RLock:
528
+
529
+ # reset device zeroconf response, as we are re-using an existing object.
530
+ scDevice.ZeroconfResponseInfo = ZeroconfResponse()
531
+
532
+ castDevice:Chromecast = None
533
+
534
+ # is this a cast group?
535
+ if (castInfo.cast_type == CAST_TYPE_GROUP):
536
+
537
+ # for groups, the SpotifyAppTask must be activated on the group leader (e.g. coordinator).
538
+ # the cast zeroconf info may not contain the group leader host info, as it is constantly
539
+ # being updated as devices are added / removed from the group. this can lead to the
540
+ # SpotifyAppTask being started on a member of the group that is not the leader, which will
541
+ # cause other devices in the group not to play.
542
+ #
543
+ # to combat this, we will call the `get_multizone_status` function that gives us the
544
+ # current group leader host info for each group. we will then create a cast device to
545
+ # the group leader host address, and start the SpotifyCastApp on that specific host.
546
+
547
+ # get current multizone status for all groups.
548
+ castMultiZoneStatus:MultizoneStatus = get_multizone_status(castInfo.host, castInfo.services, self.ZeroconfInstance, 5)
549
+ _logsi.LogObject(SILevel.Verbose, "%s - Chromecast group multizone status for device: %s [ip=%s:%s]" % (self.name, scDevice.Title, castInfo.host, castInfo.port), castMultiZoneStatus, colorValue=SIColors.Coral)
550
+
551
+ # default host ip and port in case we don't find the group coordinator.
552
+ groupHost:str = scDevice.DiscoveryResult.HostIpAddress
553
+ groupPort:int = scDevice.DiscoveryResult.HostIpPort
554
+
555
+ # find the group coordinator for the selected device.
556
+ zoneInfo:MultizoneInfo = None
557
+ for zoneInfo in castMultiZoneStatus.groups:
558
+
559
+ # is this our group?
560
+ if (str(zoneInfo.uuid) == scDevice.DiscoveryResult.Key):
561
+
562
+ # yes - get host info to use for the device.
563
+ # we will use the device discovery result HostIpAddress instead of the zoneInfo.host
564
+ # value here, as the zoneInfo MAY contain an IPV6 formatted address, which was causing
565
+ # issues if the device was originally activated using an IPV4 address!
566
+ groupHost = scDevice.DiscoveryResult.HostIpAddress
567
+ groupPort = scDevice.DiscoveryResult.HostIpPort
568
+ break
569
+
570
+ # connect to the device and build a Chromecast instance from group coordinator host info.
571
+ castDevice = get_chromecast_from_host(
572
+ host=(groupHost, groupPort, castInfo.uuid, castInfo.model_name, castInfo.friendly_name),
573
+ tries=2,
574
+ retry_wait=0.5,
575
+ timeout=5)
576
+
577
+ # calculate wait timeout for devices to become active, based on number of devices in group.
578
+ groupWaitTimeoutSecs:float = 15.0
579
+ if (castMultiZoneStatus.groups is not None):
580
+ groupCount:int = len(castMultiZoneStatus.groups)
581
+ if (groupCount > 1):
582
+ groupWaitTimeoutSecs = (groupCount * 7.5)
583
+
584
+ # wait for the group device to become ready.
585
+ _logsi.LogVerbose("%s - Waiting %d seconds max for Chromecast group multizone device to activate: %s [ip=%s:%s]" % (self.name, groupWaitTimeoutSecs, scDevice.Title, castInfo.host, castInfo.port), colorValue=SIColors.Coral)
586
+ castDevice.wait(groupWaitTimeoutSecs)
587
+
588
+ else:
589
+
590
+ # not a group; just use the current CastInfo object.
591
+
592
+ # connect to the device and build a Chromecast instance from a CastInfo object.
593
+ castDevice = get_chromecast_from_cast_info(
594
+ cast_info=castInfo,
595
+ zconf=self._ZeroconfInstance,
596
+ tries=2,
597
+ retry_wait=0.5,
598
+ timeout=3)
599
+
600
+ # wait for the device to become ready (10 seconds max).
601
+ castDevice.wait(10)
602
+
603
+ # trace.
604
+ if (_logsi.IsOn(SILevel.Verbose)):
605
+ _logsi.LogObject(SILevel.Verbose, "%s - Chromecast device object: \"%s\" (ip=%s:%s)" % (self.name, castInfo.friendly_name, castDevice.socket_client.host, castDevice.socket_client.port), castDevice)
606
+ _logsi.LogObject(SILevel.Verbose, "%s - Chromecast device status: %s [ip=%s:%s]" % (self.name, scDevice.Title, castDevice.socket_client.host, castDevice.socket_client.port), castDevice.status, colorValue=SIColors.Coral)
607
+
608
+ # start the Spotify Cast application on the Chromecast device.
609
+ # this will start the spotify app on the cast device.
610
+ # it will also call the `OnCastGetInfoResponseReceived` method when the SpotifyConnectZeroconfCastAppTask
611
+ # receives a `GetInfoResponse` block that contains Spotify Connect Zeroconf GetInformation data about the device.
612
+ # it will also call the `OnCastZeroconfResponseReceived` method when the SpotifyConnectZeroconfCastAppTask
613
+ # receives a `ZeroconfResponse` block that contains Spotify Connect Zeroconf Response data about the device.
614
+ _logsi.LogVerbose("%s - Starting Spotify Cast application on Chromecast device: \"%s\" (%s) [%s]" % (self.name, castDevice.name, str(castDevice.uuid), scDevice.DiscoveryResult.HostIpTitle))
615
+ castAppTask:SpotifyConnectZeroconfCastAppTask = SpotifyConnectZeroconfCastAppTask(
616
+ castDevice,
617
+ self.SpotifyClientInstance,
618
+ self.OnCastGetInfoResponseReceived,
619
+ self.OnCastZeroconfResponseReceived,
620
+ transferPlayback
621
+ )
622
+ castAppTask.daemon = True
623
+ castAppTask.start()
624
+
625
+ # add cast app task to active tasks collection.
626
+ self._CastAppTasks[str(castInfo.uuid)] = castAppTask
627
+
628
+ # wait for the launched spotify app to activate the user (or fail).
629
+ # this occurs when we receive any of the following zeroconf response
630
+ # messages: `addUserResponse`, `addUserError`, `getInfoError`, `launchError`.
631
+
632
+ counter = 0
633
+ while counter < (timeoutActivation + 1):
634
+ if (self.WaitForActivationComplete.wait(1)):
635
+ # syncronize access via lock, as we are accessing the collection.
636
+ with self._SpotifyConnectDevices_RLock:
637
+ scDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceByDiscoveryKey(str(castDevice.uuid))
638
+ if (scDevice is not None):
639
+ response = scDevice.ZeroconfResponseInfo
640
+ if (response.ResponseSource == TYPE_LAUNCH_ERROR):
641
+ raise SpotifyApiError("Spotify Cast Application could not be activated on Chromecast device \"%s\": %s" % (deviceName, response.StatusString), logsi=_logsi)
642
+ elif (response.ResponseSource == TYPE_ADD_USER_RESPONSE):
643
+ scDevice.WasReConnected = True
644
+ break
645
+ raise SpotifyApiError("Spotify Cast App could not be activated on Chromecast device \"%s\": %s" % (deviceName, response.ToString(False)), logsi=_logsi)
646
+ raise SpotifyApiError("Spotify Cast App could not activated on Chromecast device: unknown error.", logsi=_logsi)
647
+ if (counter >= timeoutActivation):
648
+ # syncronize access via lock, as we are accessing the collection.
649
+ with self._SpotifyConnectDevices_RLock:
650
+ scDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceByDiscoveryKey(str(castDevice.uuid))
651
+ if (scDevice is not None):
652
+ raise SpotifyApiError("Spotify Cast App activation timeout (%s seconds) was exceeded while trying to activate Chromecast device \"%s\"." % (timeoutActivation, deviceName), logsi=_logsi)
653
+ counter += 1
654
+
655
+ # at this point we have received an `adduserResponse` from the Chromecast device, indicating
656
+ # that it has launched the Spotify Cast App and that the user is logged in.
657
+
658
+ # was transfer playback specified?
659
+ if (transferPlayback == True):
660
+
661
+ # note that we only wait if we are transferring playback.
662
+
663
+ # wait for the launched spotify app to transfer playback (or fail).
664
+ # this occurs when we receive any of the following zeroconf response
665
+ # messages: `transferSuccess`, `transferError`.
666
+
667
+ counter = 0
668
+ while counter < (timeoutTransfer + 1):
669
+ if (self.WaitForTransferComplete.wait(1)):
670
+ # syncronize access via lock, as we are accessing the collection.
671
+ with self._SpotifyConnectDevices_RLock:
672
+ scDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceByDiscoveryKey(str(castDevice.uuid))
673
+ if (scDevice is not None):
674
+ response = scDevice.ZeroconfResponseInfo
675
+ if (response.ResponseSource == TYPE_TRANSFER_SUCCESS):
676
+ break
677
+ raise SpotifyApiError("Spotify Cast App failed to receive playback transfer on Chromecast device \"%s\": %s" % (deviceName, response.ToString(False)), logsi=_logsi)
678
+ raise SpotifyApiError("Spotify Cast App failed to receive playback transfer on Chromecast device: unknown error.", logsi=_logsi)
679
+ if (counter >= timeoutTransfer):
680
+ # syncronize access via lock, as we are accessing the collection.
681
+ with self._SpotifyConnectDevices_RLock:
682
+ scDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceByDiscoveryKey(str(castDevice.uuid))
683
+ if (scDevice is not None):
684
+ raise SpotifyApiError("Spotify Cast App transfer playback timeout (%s seconds) was exceeded while waiting for transfer of playback on Chromecast device \"%s\"." % (timeoutTransfer, deviceName), logsi=_logsi)
685
+ counter += 1
686
+
687
+ # at this point we have received an `transferSuccess` from the Chromecast device, indicating
688
+ # that transfer of playback was a success; it should now be playing a track!
689
+
690
+ # syncronize access via lock, as we are accessing the collection.
691
+ with self._SpotifyConnectDevices_RLock:
692
+ scDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceByDiscoveryKey(str(castDevice.uuid))
693
+ if (scDevice is not None):
694
+
695
+ # store the deviceId that was activated, if it's different than the original deviceId.
696
+ if (scDevice.Id != castAppTask.DeviceIdActivated):
697
+ scDevice.DeviceIdActivated = castAppTask.DeviceIdActivated
698
+
699
+ # return the device id that was activated.
700
+ return castAppTask.DeviceIdActivated
701
+
702
+ except SpotifyApiError: raise # pass handled exceptions on thru
703
+ except Exception as ex:
704
+
705
+ # format unhandled exception.
706
+ raise SpotifyApiError("Could not activate Spotify Cast application on Chromecast device \"%s\": %s" % (deviceName, str(ex)), ex, logsi=_logsi)
707
+
708
+ finally:
709
+
710
+ # reset activation and transfer wait events to initial state for next time;
711
+ # just in case they were not set in the wait logic above (e.g. error condition).
712
+ self.WaitForActivationComplete.set()
713
+ self.WaitForTransferComplete.set()
714
+
715
+ # trace.
716
+ _logsi.LeaveMethod(SILevel.Debug)
717
+
718
+
719
+ def AddDynamicDevice(
720
+ self,
721
+ device:Device,
722
+ ) -> None:
723
+ """
724
+ Adds a new dynamic SpotifyConnectDevice entry to the devices collection.
725
+
726
+ Args:
727
+ device (Device):
728
+ Device information to add.
729
+
730
+ Dynamic devices are Spotify Connect devices that are not found in Zeroconf discovery
731
+ process, but still exist in the player device list. These are usually Spotify Player
732
+ client devices (e.g. mobile / web / desktop players) that utilize temporary device id's.
733
+
734
+ <details>
735
+ <summary>Sample Code</summary>
736
+ ```python
737
+ .. include:: ../../docs/include/samplecode/SpotifyConnectDirectoryTask/AddDynamicDevice.py
738
+ ```
739
+ </details>
740
+ """
741
+ # syncronize access via lock, as we are accessing the collection.
742
+ with self._SpotifyConnectDevices_RLock:
743
+
744
+ # validations.
745
+ if (device is None) or (not isinstance(device, Device)):
746
+ return
747
+
748
+ # build zeroconf discovery result with what we can.
749
+ discoverResult:ZeroconfDiscoveryResult = ZeroconfDiscoveryResult()
750
+ discoverResult.DeviceName = device.Name
751
+ discoverResult.HostIpAddresses.append('127.0.0.1')
752
+ discoverResult.Server = '127.0.0.1'
753
+ discoverResult.HostIpPort = 0
754
+ discoverResult.Name = device.Name
755
+ discoverResult.SpotifyConnectCPath = '/zc'
756
+ discoverResult.SpotifyConnectVersion = '1.0'
757
+
758
+ # build zeroconf getinfo result with what we can.
759
+ info:ZeroconfGetInfo = ZeroconfGetInfo()
760
+ info.ActiveUser = self.SpotifyClientInstance.SpotifyConnectLoginId
761
+ info.DeviceId = device.Id
762
+ info.DeviceType = device.Type
763
+ info.RemoteName = device.Name
764
+ info.SpotifyError = 0
765
+ info.Status = 101
766
+ info.StatusString = 'OK'
767
+
768
+ # populate brand info for popular devices.
769
+ if (device.Name == "Web Player (Chrome)"):
770
+ info.BrandDisplayName = 'Google'
771
+ info.ModelDisplayName = 'Chrome'
772
+ info.ProductId = 'Web Player'
773
+ elif (device.Name == "Web Player (Microsoft Edge)"):
774
+ info.BrandDisplayName = 'Microsoft'
775
+ info.ModelDisplayName = 'Edge'
776
+ info.ProductId = 'Web Player'
777
+ elif (device.Name == "iPhone") and (device.Type == "Smartphone"):
778
+ info.BrandDisplayName = 'Apple'
779
+ info.ModelDisplayName = 'iPhone'
780
+ info.ProductId = 'Mobile Player'
781
+ else:
782
+ info.BrandDisplayName = 'unknown'
783
+ info.ModelDisplayName = 'unknown'
784
+ info.ProductId = 'unknown'
785
+
786
+ # add the device.
787
+ scDevice:SpotifyConnectDevice = SpotifyConnectDevice()
788
+ scDevice.Id = info.DeviceId
789
+ scDevice.Name = info.RemoteName
790
+ scDevice.DeviceInfo = info
791
+ scDevice.DiscoveryResult = discoverResult
792
+ self._SpotifyConnectDevices.Items.append(scDevice)
793
+ self._SpotifyConnectDevices.DateLastRefreshed = datetime.utcnow().timestamp()
794
+
795
+ # sort devices collection by device name.
796
+ if (len(self._SpotifyConnectDevices.Items) > 0):
797
+ self._SpotifyConnectDevices.Items.sort(key=lambda x: (x.Name or "").lower(), reverse=False)
798
+
799
+ # trace.
800
+ _logsi.LogVerbose("Added SpotifyConnectDevices collection entry: %s - %s" % (scDevice.Title, scDevice.DiscoveryResult.Description), colorValue=SIColors.ForestGreen)
801
+
802
+ # raise event.
803
+ self._RaiseDeviceAdded(scDevice)
804
+
805
+
806
+ def GetActiveDevice(
807
+ self,
808
+ refresh:bool=False,
809
+ ) -> SpotifyConnectDevice:
810
+ """
811
+ Returns the device currently marked as active if found; otherwise, null.
812
+
813
+ Args:
814
+ refresh (bool):
815
+ True to retrieve active device from Spotify Web API player state;
816
+ otherwise, False to retrieve active device from devices collection.
817
+
818
+ Returns:
819
+ The device currently marked as active if found; otherwise, null.
820
+
821
+ The object returned is a copy of the internal master device entry; any changes made
822
+ to the copy do not affect the internal master device entry.
823
+
824
+ <details>
825
+ <summary>Sample Code</summary>
826
+ ```python
827
+ .. include:: ../../docs/include/samplecode/SpotifyConnectDirectoryTask/GetActiveDevice.py
828
+ ```
829
+ </details>
830
+ """
831
+ # syncronize access via lock, as we are accessing the collection.
832
+ with self._SpotifyConnectDevices_RLock:
833
+
834
+ result:SpotifyConnectDevice = None
835
+
836
+ # if refresh selected then retrieve the active device from playerState.
837
+ if (refresh):
838
+ result = self.UpdateActiveDevice(None)
839
+ if (result is not None):
840
+ return result
841
+
842
+ # find active device in the devices collection.
843
+ for scDevice in self._SpotifyConnectDevices.Items:
844
+ if (scDevice.DeviceInfo is not None):
845
+ if (scDevice.IsActiveDevice):
846
+ _logsi.LogVerbose("Spotify Connect active device detected: %s" % (scDevice.Title))
847
+ result = copy.deepcopy(scDevice)
848
+ break
849
+
850
+ # returns null if no active device found.
851
+ return result
852
+
853
+
854
+ def GetDevice(
855
+ self,
856
+ value:str,
857
+ refreshDynamicDevices:bool=True,
858
+ raiseExceptionIfNotFound:bool=True,
859
+ ) -> SpotifyConnectDevice:
860
+ """
861
+ Returns a `SpotifyConnectDevice` object for the specified device name / id if found
862
+ in the devices collection. otherwise, returns the currently active player
863
+ `SpotifyConnectDevice` object is returned. otherwise, null is returned.
864
+
865
+ Args:
866
+ value (str | None):
867
+ The target player device identifier.
868
+ This could be an id, name, a default device indicator (e.g. "*"), or null
869
+ to utilize the active player device.
870
+ Examples are `0d1841b0976bae2a3a310dd74c0f3df354899bc8`, `Office`, `*`, None.
871
+ refreshDynamicDevices (bool):
872
+ True to refresh the list of dynamically added devices and the active player
873
+ state from real-time Spotify Web API data; otherwise, false to use the
874
+ current cached state and device list.
875
+ Default is True.
876
+ raiseExceptionIfNotFound (bool):
877
+ True to raise an exception if a device could not be resolved;
878
+ otherwise, False to just return a null value.
879
+ Default is True.
880
+
881
+ Returns:
882
+ A `SpotifyConnectDevice` object for the specified device name / id if found;
883
+ otherwise, null.
884
+
885
+ Raises:
886
+ SpotifyConnectDeviceNotFound:
887
+ If the `raiseExceptionIfNotFound` argument value is True, and the specified
888
+ device value was not found or there was no default device found.
889
+ SpotifyApiError:
890
+ If an error occured while obtaining current Spotify Web API Player device data.
891
+
892
+ If a SpotifyConnectDevice object is returned, it will be a copy of the internal master
893
+ device entry; any changes made to the copy do not affect the internal master device entry.
894
+
895
+ <details>
896
+ <summary>Sample Code</summary>
897
+ ```python
898
+ .. include:: ../../docs/include/samplecode/SpotifyConnectDirectoryTask/GetDevice.py
899
+ ```
900
+ </details>
901
+ """
902
+ # syncronize access via lock, as we are accessing the collection.
903
+ with self._SpotifyConnectDevices_RLock:
904
+
905
+ apiMethodName:str = "GetDevice"
906
+ apiMethodParms:SIMethodParmListContext = None
907
+ scDevice:SpotifyConnectDevice = None
908
+ scActiveDevice:SpotifyConnectDevice = None
909
+
910
+ try:
911
+
912
+ # trace.
913
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
914
+ apiMethodParms.AppendKeyValue("value", value)
915
+ apiMethodParms.AppendKeyValue("refreshDynamicDevices", refreshDynamicDevices)
916
+ apiMethodParms.AppendKeyValue("SpotifyClient.DefaultDeviceId", self.SpotifyClientInstance.DefaultDeviceId)
917
+ _logsi.LogMethodParmList(SILevel.Verbose, "Getting Spotify Connect device instance for value \"%s\"" % (value), apiMethodParms)
918
+
919
+ # validations.
920
+ if (value is not None) and (not isinstance(value, str)):
921
+ value = None
922
+ if (not isinstance(refreshDynamicDevices, bool)):
923
+ refreshDynamicDevices = True
924
+ if (not isinstance(raiseExceptionIfNotFound, bool)):
925
+ raiseExceptionIfNotFound = True
926
+ valueOriginal:str = value
927
+
928
+ # get currently active player device.
929
+ if (refreshDynamicDevices):
930
+ scActiveDevice = self.RefreshDynamicDevices()
931
+ else:
932
+ scActiveDevice = self.UpdateActiveDevice()
933
+
934
+ # was the active spotify player device value specified (e.g. null / empty string)?
935
+ if (value is None) or (value.strip() == ""):
936
+
937
+ # is there an active player device?
938
+ if (scActiveDevice is not None):
939
+ _logsi.LogObject(SILevel.Verbose, "Spotify Player device \"%s\" (%s) was found in the Spotify Connect Devices collection (by Active PlayerState)" % (scActiveDevice.Name, scActiveDevice.Id), scActiveDevice, excludeNonPublic=True)
940
+ return scActiveDevice
941
+
942
+ # if no active player device, then let's use the default device value.
943
+ _logsi.LogVerbose("Spotify Player has no active device; defaulting device selection to use DefaultDeviceId: \"%s\"" % (self.SpotifyClientInstance.DefaultDeviceId))
944
+ value = self.SpotifyClientInstance.DefaultDeviceId
945
+
946
+ # was a default device value specified?
947
+ # if so, then use the `defaultDeviceId` value; if the `defaultDeviceId` value is not in the device list,
948
+ # then we will catch it with the `GetDeviceById` / `GetDeviceByName` logic.
949
+ # if `defaultDeviceId` was not configured, then use active player device (via playerstate).
950
+ # if `defaultDeviceId` value is not in the device list, then use active player device (via playerstate).
951
+ # if no active player device, then it's an exception!
952
+ if (value == "*"):
953
+
954
+ # get default device id value from the client.
955
+ defaultDeviceId:str = self.SpotifyClientInstance.DefaultDeviceId
956
+
957
+ # was a default device specified?
958
+ if (defaultDeviceId is None) or (defaultDeviceId.strip() == ""):
959
+ # is there an active player device?
960
+ if (scActiveDevice is not None):
961
+ _logsi.LogObject(SILevel.Verbose, "Spotify Player default device (*) was not configured; using active player device: %s" % (scActiveDevice.Title), scActiveDevice, excludeNonPublic=True)
962
+ return scActiveDevice
963
+ # if no default device and no active player, then it's an error!
964
+ #raise SpotifyApiError("Spotify Player default device (*) was not configured, and there is no active Spotify Player device", logsi=_logsi)
965
+
966
+ # assign default device value.
967
+ _logsi.LogVerbose("Spotify Player device default \"%s\" will be used for selecting a device (by default *)" % (defaultDeviceId))
968
+ value = defaultDeviceId
969
+
970
+ # check for the device id in the devices collection.
971
+ scDevice = self._SpotifyConnectDevices.GetDeviceById(value)
972
+ if (scDevice is not None):
973
+ _logsi.LogObject(SILevel.Verbose, "Spotify Player device %s was found in the Spotify Connect Devices collection (by Id)" % (scDevice.Title), scDevice, excludeNonPublic=True)
974
+ return copy.deepcopy(scDevice)
975
+
976
+ # check for the device name in the devices collection.
977
+ scDevice = self._SpotifyConnectDevices.GetDeviceByName(value)
978
+ if (scDevice is not None):
979
+
980
+ # we have a device match by name!
981
+ # is there an active player device?
982
+ if (scActiveDevice is not None):
983
+
984
+ # does the active device have the same name, but a different device id?
985
+ if (scActiveDevice.Name == scDevice.Name) and (scActiveDevice.Id != scDevice.Id):
986
+ # this denotes that we have 2 devices in the device list with the same name
987
+ # but different id's. in this case we will use the active device, since it
988
+ # is already active.
989
+ _logsi.LogObject(SILevel.Verbose, "Spotify Player device %s was found in the Spotify Connect Devices collection (by Duplicate Name), which denotes that we have 2 devices in the device list with the same name but different id's; in this case we will use the active device (id=%s), since it is already active" % (scDevice.Title, scActiveDevice.Id), scDevice, excludeNonPublic=True)
990
+ scDevice = scActiveDevice
991
+
992
+ _logsi.LogObject(SILevel.Verbose, "Spotify Player device %s was found in the Spotify Connect Devices collection (by Name)" % (scDevice.Title), scDevice, excludeNonPublic=True)
993
+ return copy.deepcopy(scDevice)
994
+
995
+ # is there an active player device?
996
+ if (scActiveDevice is not None):
997
+
998
+ # check if the active device is restricted; if it is, then it may not show up in the
999
+ # available device list of devices (common issue with Sonos devices). in this case,
1000
+ # it WILL show up in the player state device property as the active device.
1001
+ # note that the PlayerPlayState device is a name and not a device id.
1002
+ if (scActiveDevice.IsRestricted):
1003
+ value = ("" + value).lower()
1004
+ if (scActiveDevice.Id.lower() == value) or (scActiveDevice.Name.lower() == value):
1005
+ _logsi.LogObject(SILevel.Verbose, "Spotify Player device %s was found to be a restricted device (by Active PlayerState)" % (scDevice.Title), scDevice, excludeNonPublic=True)
1006
+ return scActiveDevice
1007
+
1008
+ # if `defaultDeviceId` value is not in the device list, then use active player device.
1009
+ if (valueOriginal == "*"):
1010
+ _logsi.LogObject(SILevel.Verbose, "Spotify Player default device \"%s\" was not found in the Spotify Connect Devices collection; using active player device: %s" % (value, scActiveDevice.Title), scActiveDevice, excludeNonPublic=True)
1011
+ return scActiveDevice
1012
+
1013
+ # at this point we could not resolve the device name / id, and there is no active player
1014
+ # available; raise exception / return null as requested by the caller.
1015
+ if (raiseExceptionIfNotFound):
1016
+ raise SpotifyConnectDeviceNotFound("Spotify Player device \"%s\" was not found, and there is no active Spotify player" % (value), logsi=_logsi)
1017
+ else:
1018
+ _logsi.LogVerbose("Spotify Player device \"%s\" was not found, and there is no active Spotify player" % (value))
1019
+ return None
1020
+
1021
+ except SpotifyApiError: raise # pass handled exceptions on thru
1022
+ except Exception as ex:
1023
+
1024
+ # format unhandled exception.
1025
+ raise SpotifyApiError("%s - Could not retrieve dynamic device list: %s" % (self.name, str(ex)), ex, logsi=_logsi)
1026
+
1027
+ finally:
1028
+
1029
+ # trace.
1030
+ _logsi.LeaveMethod(SILevel.Debug)
1031
+
1032
+
1033
+ def GetDevices(self,
1034
+ ) -> SpotifyConnectDevices:
1035
+ """
1036
+ Returns a collection of available Spotify Connect devices.
1037
+
1038
+ The object returned is a copy of the internal master device list; any changes made
1039
+ to the copy do not affect the internal master device list.
1040
+
1041
+ <details>
1042
+ <summary>Sample Code</summary>
1043
+ ```python
1044
+ .. include:: ../../docs/include/samplecode/SpotifyConnectDirectoryTask/GetDevices.py
1045
+ ```
1046
+ </details>
1047
+ """
1048
+ result:SpotifyConnectDevices
1049
+
1050
+ # DO NOT syncronize access via lock here!
1051
+ # doing so can cause thread deadlocks in Home Assistant state machine.
1052
+ # with self._SpotifyConnectDevices_RLock:
1053
+
1054
+ # creates a completely independent copy of the devices collection (no references),
1055
+ # and return it to the caller.
1056
+ result = copy.deepcopy(self._SpotifyConnectDevices)
1057
+ return result
1058
+
1059
+
1060
+ def GetPlayerDevice(
1061
+ self,
1062
+ value:str,
1063
+ refresh:bool=False,
1064
+ ) -> SpotifyConnectDevice:
1065
+ """
1066
+ Returns the device instance if it is currently in the Spotify Web API player device list;
1067
+ otherwise, null.
1068
+
1069
+ Args:
1070
+ value (str):
1071
+ Spotify Connect device name or id value used to identify the device.
1072
+ refresh (bool):
1073
+ True to refresh player devices from Spotify Web API devices;
1074
+ otherwise, False to retrieve player device from devices collection.
1075
+
1076
+ Returns:
1077
+ The device instance if it is currently in the Spotify Web API player device list;
1078
+ otherwise, null.
1079
+
1080
+ The object returned is a copy of the internal master device entry; any changes made
1081
+ to the copy do not affect the internal master device entry.
1082
+
1083
+ <details>
1084
+ <summary>Sample Code</summary>
1085
+ ```python
1086
+ .. include:: ../../docs/include/samplecode/SpotifyConnectDirectoryTask/GetPlayerDevice.py
1087
+ ```
1088
+ </details>
1089
+ """
1090
+ # syncronize access via lock, as we are accessing the collection.
1091
+ with self._SpotifyConnectDevices_RLock:
1092
+
1093
+ result:SpotifyConnectDevice = None
1094
+
1095
+ # validations.
1096
+ if (value is None):
1097
+ return result
1098
+
1099
+ # if refresh selected then update player devices from Spotify Web API.
1100
+ if (refresh):
1101
+ self.UpdatePlayerDevices()
1102
+
1103
+ # prepare for compare.
1104
+ value = "" + value.strip().lower()
1105
+
1106
+ # find specified player device in the devices collection.
1107
+ for scDevice in self._SpotifyConnectDevices._Items:
1108
+ if (scDevice.IsInDeviceList):
1109
+ if (scDevice.Id.lower() == value) or (scDevice.Name.lower() == value) or (scDevice.DiscoveryResult.DeviceName.lower() == value):
1110
+ _logsi.LogVerbose("Spotify Connect player device detected: %s" % (scDevice.Title))
1111
+ result = copy.deepcopy(scDevice)
1112
+ break
1113
+
1114
+ # returns null if no active device found.
1115
+ return result
1116
+
1117
+
1118
+ def GetSonosPlayer(
1119
+ self,
1120
+ device:SpotifyConnectDevice,
1121
+ returnCoordinator:bool=True,
1122
+ ) -> SoCo:
1123
+ """
1124
+ Returns the Sonos Controller instance for the specified Spotify Connect device.
1125
+ Returns the Sonos player instance for the specified Spotify Connect Sonos device.
1126
+
1127
+ Args:
1128
+ device (SpotifyConnectDevice):
1129
+ Spotify Connect device instance used to identify the Sonos player.
1130
+ returnCoordinator (bool):
1131
+ True to return the group coordinator instance if the device is part of a Sonos group;
1132
+ otherwise, False to return the found instance;
1133
+ Default is True.
1134
+
1135
+ Returns:
1136
+ The Sonos Controller instance for the specified Spotify Connect device.
1137
+
1138
+ Raises:
1139
+ SpotifyApiError:
1140
+ If a Sonos Controller instance could not be found for the device id.
1141
+
1142
+ You should invoke "if (scDevice.IsSonos):" logic prior to calling this method, as
1143
+ a Sonos Controller instance will not be created for non-Sonos devices and an exception
1144
+ will be raised!
1145
+ """
1146
+ apiMethodName:str = "GetSonosPlayer"
1147
+
1148
+ # syncronize access via lock, as we are accessing the collection.
1149
+ with self._SpotifyConnectDevices_RLock:
1150
+
1151
+ result:SpotifyConnectDevice = None
1152
+
1153
+ # validations.
1154
+ if (device is None):
1155
+ raise SpotifyApiError(SAAppMessages.ARGUMENT_REQUIRED_ERROR % (apiMethodName, 'device'), logsi=_logsi)
1156
+ if (not isinstance(returnCoordinator, bool)):
1157
+ returnCoordinator = True
1158
+
1159
+ # get Sonos Controller instance for device name.
1160
+ sonosPlayer:SoCo = self._SonosPlayers.get(device.DiscoveryResult.HostIpAddress, None)
1161
+
1162
+ # if not found then it's an error.
1163
+ if (sonosPlayer is None):
1164
+ raise SpotifyApiError("Could not find Sonos Controller instance for device: %s" % (device.Title), None, logsi=_logsi)
1165
+
1166
+ # trace.
1167
+ _logsi.LogDictionary(SILevel.Verbose, "Sonos Controller instance for device: %s (speaker_info)" % (device.Title), sonosPlayer.speaker_info)
1168
+
1169
+ # does caller want the group coordinator?
1170
+ if (returnCoordinator):
1171
+
1172
+ # TEST TODO - use to test Sonos coordinator functionality.
1173
+ # _logsi.LogObject(SILevel.Verbose, "Sonos Controller instance for device: %s" % (device.Title), sonosPlayer, colorValue=SIColors.Red)
1174
+ #sonosPlayer._is_coordinator = False
1175
+
1176
+ # is Sonos Controller instance the group coordinator?
1177
+ if (sonosPlayer.is_coordinator):
1178
+
1179
+ # yes - nothing else to do since it's already a group coordinator.
1180
+ _logsi.LogVerbose("Sonos Controller instance is a group coordinator")
1181
+
1182
+ else:
1183
+
1184
+ # no - try to determine the group coordinator.
1185
+ _logsi.LogVerbose("Sonos Controller instance is NOT a group coordinator for device: %s" % (device.Title))
1186
+
1187
+ # is the device part of a group? if so, then use the `group.coordinator` value (if present).
1188
+ sonosPlayerCoordinator:SoCo = None
1189
+ if (sonosPlayer.group is not None):
1190
+ if (sonosPlayer.group.coordinator is not None):
1191
+ sonosPlayerCoordinator = sonosPlayer.group.coordinator
1192
+ sonosPlayer = sonosPlayerCoordinator
1193
+ _logsi.LogVerbose("Sonos Controller group coordinator \"%s\" (%s) will be used for device: %s" % (sonosPlayerCoordinator.player_name, sonosPlayerCoordinator.ip_address, device.Title))
1194
+ _logsi.LogDictionary(SILevel.Verbose, "Sonos Controller instance for device: %s (speaker_info)" % (device.Title), sonosPlayerCoordinator.speaker_info)
1195
+
1196
+ # if group coordinator could not be found, then log a trace message indicating
1197
+ # this and that subsequent Sonos Controller operations will probably fail.
1198
+ # this only seems to happen for "orphaned" Sonos devices (e.g. uid="")
1199
+ # zone_group_state.groups = [ZoneGroup(uid='RINCON_5CAAFDF4E8DE01400:orphan', coordinator=None, members={SoCo("192.168.50.79")}), etc
1200
+ if (sonosPlayerCoordinator is None):
1201
+ _logsi.LogVerbose("Sonos Controller group coordinator could not be determined for device: %s; subsequent Sonos Controller API functions will probably fail!" % (device.Title), colorValue=SIColors.Red)
1202
+ _logsi.LogObject(SILevel.Verbose, "Sonos Controller instance for device: %s (group)" % (device.Title), sonosPlayer.group)
1203
+
1204
+ # return Sonos Controller instance to caller.
1205
+ return sonosPlayer
1206
+
1207
+
1208
+ def GetSpotifyDeviceIDFromName(
1209
+ self,
1210
+ name:str
1211
+ ) -> str:
1212
+ """
1213
+ Retrieve the Spotify deviceID from provided user-friendly name.
1214
+
1215
+ DeviceId is just the md5 hash representation of the user-friendly-name.
1216
+ """
1217
+ if (name is None):
1218
+ name = ""
1219
+
1220
+ deviceId:str = hashlib.md5(name.encode()).hexdigest()
1221
+ return deviceId
1222
+
1223
+
1224
+ def RefreshDynamicDevices(
1225
+ self,
1226
+ ) -> SpotifyConnectDevice:
1227
+ """
1228
+ Refreshes dynamic Spotify Connect devices, and also determines the currently
1229
+ active device (if any).
1230
+
1231
+ Returns:
1232
+ A SpotifyConnectDevice object that contains the active device if one was found;
1233
+ otherwise, null to indicate no active device.
1234
+
1235
+ Dynamic devices are Spotify Connect devices that are not found in Zeroconf discovery
1236
+ process, but exist in the player device list. These are usually Spotify Connect
1237
+ web or mobile players with temporary device id's.
1238
+
1239
+ The object returned is a copy of the internal master device entry; any changes made
1240
+ to the copy do not affect the internal master device entry.
1241
+
1242
+ <details>
1243
+ <summary>Sample Code</summary>
1244
+ ```python
1245
+ .. include:: ../../docs/include/samplecode/SpotifyConnectDirectoryTask/RefreshDynamicDevices.py
1246
+ ```
1247
+ </details>
1248
+ """
1249
+ # syncronize access via lock, as we are accessing the collection.
1250
+ with self._SpotifyConnectDevices_RLock:
1251
+
1252
+ try:
1253
+
1254
+ # trace.
1255
+ apiMethodParms:SIMethodParmListContext = _logsi.EnterMethodParmList(SILevel.Debug)
1256
+ _logsi.LogMethodParmList(SILevel.Verbose, "Refreshing Spotify Connect dynamic device list", apiMethodParms)
1257
+
1258
+ # trace.
1259
+ if (_logsi.IsOn(SILevel.Verbose)):
1260
+ _logsi.LogDictionary(SILevel.Verbose, "Current Spotify Connect device list (before refresh)", self.GetDevices().ToDictionary(), prettyPrint=True)
1261
+
1262
+ # update player devices known to the Spotify Web API (e.g. dynamic devices).
1263
+ self.UpdatePlayerDevices()
1264
+
1265
+ # update the currently active device (if any).
1266
+ scDevice:SpotifyConnectDevice = self.UpdateActiveDevice()
1267
+
1268
+ # trace.
1269
+ if (_logsi.IsOn(SILevel.Verbose)):
1270
+ _logsi.LogDictionary(SILevel.Verbose, "Current Spotify Connect device list (after refresh)", self.GetDevices().ToDictionary(), prettyPrint=True)
1271
+ if (scDevice is None):
1272
+ _logsi.LogVerbose("Spotify Player playstate device is not present; no active device")
1273
+ else:
1274
+ _logsi.LogObject(SILevel.Verbose, "Spotify Player playstate active device instance: %s" % (scDevice.Title), scDevice, excludeNonPublic=True)
1275
+
1276
+ # return real-time active Spotify Player device.
1277
+ return scDevice
1278
+
1279
+ except SpotifyApiError: raise # pass handled exceptions on thru
1280
+ except Exception as ex:
1281
+
1282
+ # format unhandled exception.
1283
+ raise SpotifyApiError("%s - Could not retrieve dynamic device list: %s" % (self.name, str(ex)), ex, logsi=_logsi)
1284
+
1285
+ finally:
1286
+
1287
+ # trace.
1288
+ _logsi.LeaveMethod(SILevel.Debug)
1289
+
1290
+
1291
+ def RemoveDevice(
1292
+ self,
1293
+ deviceId:str,
1294
+ dynamicDeviceOnly:bool=False
1295
+ ) -> SpotifyConnectDevice:
1296
+ """
1297
+ Removes existing device entry(s) from the devices collection by device id value.
1298
+
1299
+ Args:
1300
+ deviceId (str):
1301
+ Device id to remove.
1302
+ dynamicDeviceOnly (str):
1303
+ True to only remove the device if it's a dynamic device entry;
1304
+ otherwise, False to remove the device id regardless.
1305
+ Default is False.
1306
+
1307
+ Returns:
1308
+ A SpotifyConnectDevice object that contains the removed device if one was found;
1309
+ otherwise, null to indicate no device was removed.
1310
+
1311
+ It's possible to have multiple devices with the same id (e.g. both a dynamic and a
1312
+ zeroconf registered device).
1313
+
1314
+ Dynamic devices are Spotify Connect devices that are not found in Zeroconf discovery
1315
+ process, but still exist in the player device list. These are usually Spotify Player
1316
+ client devices (e.g. mobile / web / desktop players) that utilize temporary device id's.
1317
+
1318
+ The object returned is a copy of the internal master device entry; any changes made
1319
+ to the copy do not affect the internal master device entry.
1320
+
1321
+ <details>
1322
+ <summary>Sample Code</summary>
1323
+ ```python
1324
+ .. include:: ../../docs/include/samplecode/SpotifyConnectDirectoryTask/AddDynamicDevice.py
1325
+ ```
1326
+ </details>
1327
+ """
1328
+ # syncronize access via lock, as we are accessing the collection.
1329
+ with self._SpotifyConnectDevices_RLock:
1330
+
1331
+ try:
1332
+
1333
+ # validations.
1334
+ if (not isinstance(dynamicDeviceOnly,bool)):
1335
+ dynamicDeviceOnly = False
1336
+
1337
+ # remove device from devices collection;
1338
+ # the reversed() function creates an iterator that traverses the list in reverse order.
1339
+ # this ensures that removing an element doesn't affect the indices of the subsequent
1340
+ # elements we're going to iterate over.
1341
+ deviceIdCompare:str = (deviceId or "").lower()
1342
+ scDevice:SpotifyConnectDevice
1343
+ for idx in reversed(range(len(self._SpotifyConnectDevices.Items))):
1344
+
1345
+ scDevice = self._SpotifyConnectDevices.Items[idx]
1346
+
1347
+ # did we find a match by device id?
1348
+ if (deviceIdCompare == (scDevice.Id or "").lower()):
1349
+
1350
+ isFound:bool = True
1351
+
1352
+ # get dynamic device status.
1353
+ isDynamic:bool = False
1354
+ if (scDevice.DiscoveryResult is not None):
1355
+ isDynamic = scDevice.DiscoveryResult.IsDynamicDevice
1356
+
1357
+ # are we checking for dynamic device? if so, and it's NOT dynamic,
1358
+ # then don't delete it!
1359
+ if (dynamicDeviceOnly) and (not isDynamic):
1360
+ isFound = False
1361
+
1362
+ # are we deleting this device?
1363
+ if (isFound):
1364
+
1365
+ # remove the device.
1366
+ scDevice:SpotifyConnectDevice = copy.deepcopy(self._SpotifyConnectDevices.Items.pop(idx))
1367
+ _logsi.LogVerbose("Removed SpotifyConnectDevices collection entry: %s - %s" % (scDevice.Title, scDevice.DiscoveryResult.Description), colorValue=SIColors.Orange)
1368
+
1369
+ # raise event.
1370
+ self._RaiseDeviceRemoved(scDevice)
1371
+
1372
+ except SpotifyApiError: raise # pass handled exceptions on thru
1373
+ except Exception as ex:
1374
+
1375
+ # format unhandled exception.
1376
+ raise SpotifyApiError(SAAppMessages.UNHANDLED_EXCEPTION.format("RemoveDevice", str(ex)), ex, logsi=_logsi)
1377
+
1378
+
1379
+ def UpdatePlayerDevices(
1380
+ self,
1381
+ playerDevices:list[Device]=None,
1382
+ ) -> None:
1383
+ """
1384
+ Adds a list of dynamic player device entries to the devices collection, and removes any
1385
+ existing dynamic devices from the collection that are not in the `playerDevices` list.
1386
+
1387
+ Args:
1388
+ playerDevices (list[Device]):
1389
+ If specified, the list of current player devices obtained via a previous call to
1390
+ the Spotify Web API; otherwise, null to call the Spotify Web API to retrieve the list.
1391
+
1392
+ Dynamic devices are Spotify Connect devices that are not found in Zeroconf discovery
1393
+ process, but still exist in the player device list. These are usually Spotify Player
1394
+ client devices (e.g. mobile / web / desktop players) that utilize temporary device id's.
1395
+
1396
+ <details>
1397
+ <summary>Sample Code</summary>
1398
+ ```python
1399
+ .. include:: ../../docs/include/samplecode/SpotifyConnectDirectoryTask/UpdatePlayerDevices.py
1400
+ ```
1401
+ </details>
1402
+ """
1403
+ # syncronize access via lock, as we are accessing the collection.
1404
+ with self._SpotifyConnectDevices_RLock:
1405
+
1406
+ # validations.
1407
+ if (playerDevices is not None) and (not isinstance(playerDevices, list)):
1408
+ return
1409
+
1410
+ # if playerDevices object not supplied, then go get them.
1411
+ if (playerDevices is None):
1412
+ playerDevices:list[Device] = self._SpotifyClientInstance.GetPlayerDevices(refresh=True)
1413
+
1414
+ # reset device list indicators, just in case any were disconnected and are not in the
1415
+ # current player device list.
1416
+ for scDevice in self._SpotifyConnectDevices.Items:
1417
+ scDevice.IsInDeviceList = False
1418
+
1419
+ # add dynamic devices.
1420
+ scDevice:SpotifyConnectDevice
1421
+ device:Device
1422
+ for device in playerDevices:
1423
+
1424
+ # is the player device present in the collection? if not, then add it.
1425
+ if (not self._SpotifyConnectDevices.ContainsDeviceId(device.Id)):
1426
+ self.AddDynamicDevice(device)
1427
+
1428
+ # check all registered Spotify Connect device entries.
1429
+ for scDevice in self._SpotifyConnectDevices.Items:
1430
+
1431
+ # do we have a match on the device id?
1432
+ if (scDevice.DeviceInfo.DeviceId == device.Id) and ((device.Id or "") != ""):
1433
+
1434
+ # indicate the device is in the available device list.
1435
+ scDevice.IsInDeviceList = True
1436
+
1437
+ # is the device an alias entry?
1438
+ # if Spotify Web API device entry is an alias, then the device id and device name
1439
+ # values will USUALLY be the same (e.g. a device id value) and the SpotifyConnectDevices
1440
+ # collection entry's DeviceInfo.RemoteName value will be an empty string (e.g. "").
1441
+ if (scDevice.DeviceInfo.HasAliases) and (device.Id == device.Name):
1442
+
1443
+ # yes - DO NOT change the RemoteName value, as an alias is in use.
1444
+ _logsi.LogVerbose("Detected Spotify Connect Alias in use for SpotifyConnectDevices collection entry %s [%s]; first Alias name is \"%s\"; RemoteName is \"%s\"" % (scDevice.Title, scDevice.DiscoveryResult.Description, scDevice.DeviceInfo.Aliases[0].Name, scDevice.DeviceInfo.RemoteName), colorValue=SIColors.Coral)
1445
+
1446
+ else:
1447
+
1448
+ # did the player device remote name change?
1449
+ # if so, then update the collection RemoteName as well as the Name.
1450
+ # this can happen for devices that are added to / removed from groups
1451
+ # and do not correctly inform interested parties via a zeroconf
1452
+ # OnServiceStateChange event that the name has changed (e.g. Denon HEOS devices, etc).
1453
+ if (scDevice.DeviceInfo.RemoteName != device.Name) and ((device.Name or "") != ""):
1454
+ _logsi.LogVerbose("Detected Spotify Connect RemoteName change for SpotifyConnectDevices collection entry %s [%s]; updated Spotify Web API PlayerDevice Name is \"%s\" - this is usually caused by a Speaker Group membership change" % (scDevice.Title, scDevice.DiscoveryResult.Description, device.Name), colorValue=SIColors.Coral)
1455
+ scDevice.DeviceInfo.RemoteName = device.Name
1456
+ scDevice.Name = device.Name
1457
+ self._SpotifyConnectDevices.DateLastRefreshed = datetime.utcnow().timestamp()
1458
+ # ???? - we should probably call getInfo again to update DeviceInfo since GroupStatus has
1459
+ # probably changed from "NONE" to "GROUP" or vice versa!
1460
+ # we will start with this to see how it goes.
1461
+ self._RaiseDeviceUpdated(scDevice)
1462
+
1463
+ # don't need to search any more devices since we found the device id.
1464
+ break
1465
+
1466
+ # remove stale dynamic devices from results collection;
1467
+ # the reversed() function creates an iterator that traverses the list in reverse order.
1468
+ # this ensures that removing an element doesn't affect the indices of the subsequent
1469
+ # elements we're going to iterate over.
1470
+ for idx in reversed(range(len(self._SpotifyConnectDevices.Items))):
1471
+
1472
+ # only process dynamic devices.
1473
+ scDevice = self._SpotifyConnectDevices.Items[idx]
1474
+ if (scDevice.DiscoveryResult.IsDynamicDevice):
1475
+
1476
+ # process all player devices.
1477
+ wasFound:bool = False
1478
+ playerDevice:Device
1479
+ for playerDevice in playerDevices:
1480
+ if (scDevice.Id == playerDevice.Id):
1481
+ wasFound = True
1482
+ break
1483
+
1484
+ # if collection entry was not found in the player device list then remove it.
1485
+ if (not wasFound):
1486
+
1487
+ # remove the device.
1488
+ scDevice:SpotifyConnectDevice = copy.deepcopy(self._SpotifyConnectDevices.Items.pop(idx))
1489
+ _logsi.LogVerbose("Removed SpotifyConnectDevices collection entry: %s - %s" % (scDevice.Title, scDevice.DiscoveryResult.Description), colorValue=SIColors.Orange)
1490
+
1491
+ # raise event.
1492
+ self._RaiseDeviceRemoved(scDevice)
1493
+
1494
+
1495
+ def UpdateActiveDevice(
1496
+ self,
1497
+ playerState:PlayerPlayState=None
1498
+ ) -> SpotifyConnectDevice:
1499
+ """
1500
+ Updates the currently active device based on playerState.
1501
+
1502
+ Args:
1503
+ playerState (PlayerPlayState):
1504
+ Current player state obtained via a call to `GetPlayerPlaybackState` method
1505
+ which will be used to set the active device in the `Items` collection;
1506
+ otherwise, null to bypass active device set.
1507
+
1508
+ Returns:
1509
+ The currently active Spotify Player device instance if one was found; otherwise, null.
1510
+
1511
+ The Spotify Web API GetPlayerPlaybackState method is called to retrieve the active device
1512
+ information.
1513
+
1514
+ The object returned is a copy of the internal master device entry; any changes made
1515
+ to the copy do not affect the internal master device entry.
1516
+
1517
+ <details>
1518
+ <summary>Sample Code</summary>
1519
+ ```python
1520
+ .. include:: ../../docs/include/samplecode/SpotifyConnectDirectoryTask/UpdateActiveDevice.py
1521
+ ```
1522
+ </details>
1523
+ """
1524
+ # syncronize access via lock, as we are accessing the collection.
1525
+ with self._SpotifyConnectDevices_RLock:
1526
+
1527
+ result:SpotifyConnectDevice = None
1528
+
1529
+ # if playerState object not supplied, then go get it.
1530
+ if (playerState is None):
1531
+ playerState:PlayerPlayState = self._SpotifyClientInstance.GetPlayerPlaybackState(additionalTypes=SpotifyMediaTypes.EPISODE.value)
1532
+
1533
+ # if valid object then process it.
1534
+ if (isinstance(playerState, PlayerPlayState)):
1535
+
1536
+ # we do this in case the active device is a "restricted" device; if it is, then it may
1537
+ # not show up as the active device (common issue with Sonos devices).
1538
+ # in this case, it WILL show up in the player state device property as the active device.
1539
+ # if it IS active, then we will set the active flag.
1540
+ # note that the PlayerPlayState device is a name and not a device id; it's device id
1541
+ # could also be set to null if it's a restricted device (e.g. Sonos).
1542
+ if playerState.Device is not None:
1543
+
1544
+ # reset active device status for all devices; set active device status if found.
1545
+ device = playerState.Device
1546
+ for scDevice in self._SpotifyConnectDevices.Items:
1547
+ scDevice.IsActiveDevice = False
1548
+
1549
+ # is a device id present?
1550
+ if ((device.Id or "") != ""):
1551
+
1552
+ # yes - compare on the device id.
1553
+ if (device.Id == scDevice.Id):
1554
+ _logsi.LogVerbose("Spotify Connect active device detected (By Id): %s" % (scDevice.Title))
1555
+ scDevice.IsActiveDevice = True
1556
+ scDevice.IsRestricted = playerState.Device.IsRestricted
1557
+ result = copy.deepcopy(scDevice)
1558
+
1559
+ # no - compare on the device name.
1560
+ elif (device.Name == scDevice.Name):
1561
+ _logsi.LogVerbose("Spotify Connect active device detected (By Name): %s" % (scDevice.Title))
1562
+ scDevice.IsActiveDevice = True
1563
+ scDevice.IsRestricted = playerState.Device.IsRestricted
1564
+ result = copy.deepcopy(scDevice)
1565
+
1566
+ # returns null if the `playerState` argument was not specified;
1567
+ # otherwise, the currently active Spotify Player device instance as determined by
1568
+ # the Spotify Player PlayState (could be null if no active device).
1569
+ return result
1570
+
1571
+
1572
+ def OnServiceInfoAddedUpdatedChromecast(
1573
+ self,
1574
+ zeroconfDiscoveryResult:ZeroconfDiscoveryResult,
1575
+ uuid:UUID,
1576
+ serviceName:str,
1577
+ serviceType:str,
1578
+ ) -> None:
1579
+ """
1580
+ Called when a Chromecast MDNS service record has been discovered
1581
+ or updated for a Chromecast device.
1582
+
1583
+ Args:
1584
+ zeroconfDiscoveryResult (ZeroconfDiscoveryResult):
1585
+ A `ZeroconfDiscoveryResult` object that contains discovery details.
1586
+ uuid (UUID):
1587
+ The cast's uuid, which is the dictionary key to find the chromecast
1588
+ metadata in CastBrowser.devices and CastBrowser.services collection.
1589
+ serviceName (str):
1590
+ First known MDNS service name or host:port.
1591
+ serviceType (str):
1592
+ Service call type (e.g. "add_cast", "update_cast").
1593
+
1594
+ This method will be called in a thread-safe manner, as the caller is using
1595
+ the `_Zeroconf_RLock` object to control access.
1596
+ """
1597
+ # syncronize access via lock, as we are accessing the collection.
1598
+ with self._SpotifyConnectDevices_RLock:
1599
+
1600
+ try:
1601
+
1602
+ # trace.
1603
+ _logsi.EnterMethod(SILevel.Debug)
1604
+
1605
+ # add / update discovered results by serviceinfo key value.
1606
+ # examples:
1607
+ # - Name="SHIELD-Android-TV-72735c92dead1a2d62df0229b1590a65._googlecast._tcp.local.", Key="72735c92-dead-1a2d-62df-0229b1590a65"
1608
+ # - Name="LS50-Wireless-II-5b1bb9c433d5bc1f7c8bf208c0cdda3f._googlecast._tcp.local.", Key="5b1bb9c4-33d5-bc1f-7c8b-f208c0cdda3f"
1609
+
1610
+ # note that for "Google-Cast-Group-x" entries, the name will vary slightly with each member of
1611
+ # the group, but the key value will be the same. this is important because for groups you want
1612
+ # to update the same group KEY as group updates are made (e.g. group / ungroup devices).
1613
+ # examples:
1614
+ # - Name="Google-Cast-Group-631B535F21D3408A8EE2C52BEBDB99E7._googlecast._tcp.local.", Key="631b535f-21d3-408a-8ee2-c52bebdb99e7"
1615
+ # - Name="Google-Cast-Group-631B535F21D3408A8EE2C52BEBDB99E7-1._googlecast._tcp.local.", Key="631b535f-21d3-408a-8ee2-c52bebdb99e7"
1616
+ # - Name="Google-Cast-Group-631B535F21D3408A8EE2C52BEBDB99E7-2._googlecast._tcp.local.", Key="631b535f-21d3-408a-8ee2-c52bebdb99e7"
1617
+
1618
+ # get cast device instance by discovery key.
1619
+ # note that we cannot use discovery name here, as Google Groups use different
1620
+ # names that refer to the same key value!
1621
+ scDevice:SpotifyConnectDevice = None
1622
+ idx:int = self._SpotifyConnectDevices.GetDeviceIndexByDiscoveryKey(zeroconfDiscoveryResult.Key)
1623
+ if (idx == -1):
1624
+
1625
+ # trace.
1626
+ _logsi.LogVerbose("Creating new SpotifyConnectDevice instance from CastInfo data: \"%s\" (%s) [key=%s]" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name, zeroconfDiscoveryResult.Key))
1627
+
1628
+ # connect to the device using CastInfo to get more details.
1629
+ castInfo:CastInfo = self._CastBrowser.services[uuid]
1630
+ _logsi.LogVerbose("Retrieving Chromecast device instance from CastInfo object \"%s\" (%s)" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name))
1631
+ castDevice:Chromecast = get_chromecast_from_cast_info(
1632
+ cast_info=castInfo,
1633
+ zconf=self._ZeroconfInstance,
1634
+ tries=2,
1635
+ retry_wait=0.5,
1636
+ timeout=5)
1637
+
1638
+ # wait for the device to become ready (10 seconds max).
1639
+ castDevice.wait(10)
1640
+ _logsi.LogObject(SILevel.Verbose, "Chromecast device instance \"%s\" (%s)" % (castInfo.friendly_name, zeroconfDiscoveryResult.Name), castDevice)
1641
+
1642
+ # create a new Spotify Connect Device instance.
1643
+ scDevice = SpotifyConnectDevice()
1644
+ scDevice.Name = zeroconfDiscoveryResult.DeviceName
1645
+ scDevice.Id = self.GetSpotifyDeviceIDFromName(castInfo.friendly_name)
1646
+
1647
+ # set zeroconf discovery result properties.
1648
+ scDevice.DiscoveryResult = zeroconfDiscoveryResult
1649
+
1650
+ # Spotify Connect GetInformation is called as part of the cast app launch process,
1651
+ # and cannot be performed yet.
1652
+
1653
+ # set device info properties that we know about; the rest will be filled in
1654
+ # when we connect to the device and the cast `getInfoResponse` is returned.
1655
+ scDevice.DeviceInfo = ZeroconfGetInfo()
1656
+ scDevice.DeviceInfo.DeviceId = scDevice.Id
1657
+ scDevice.DeviceInfo.DeviceType = "CastAudio"
1658
+ scDevice.DeviceInfo.BrandDisplayName = castInfo.manufacturer or "ChromeCast"
1659
+ scDevice.DeviceInfo.ModelDisplayName = castInfo.model_name
1660
+ scDevice.DeviceInfo.RemoteName = castInfo.friendly_name
1661
+
1662
+ # did this device change from a dynamic device (e.g. non-zeroconf) to a zeroconf device?
1663
+ # this can happen if the device is initially added as a dynamic device, then the zeroconf
1664
+ # device is discovered. if so, we will remove the dynamic device entry.
1665
+ spDynamicDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceById(scDevice.DeviceInfo.DeviceId)
1666
+ if (spDynamicDevice is not None):
1667
+
1668
+ # trace.
1669
+ _logsi.LogVerbose("Chromecast Zeroconf is converting SpotifyConnectDevice entry %s from dynamic to zeroconf" % (spDynamicDevice.Title))
1670
+
1671
+ # copy real-time status dynamic properties to zerconf object.
1672
+ scDevice.IsActiveDevice = spDynamicDevice.IsActiveDevice
1673
+ scDevice.IsInDeviceList = spDynamicDevice.IsInDeviceList
1674
+
1675
+ # remove the dynamic device from the collection.
1676
+ self.RemoveDevice(spDynamicDevice.Id, dynamicDeviceOnly=True)
1677
+
1678
+ # is this a Google Cast Group?
1679
+ if (castInfo.cast_type == CAST_TYPE_GROUP):
1680
+
1681
+ # if so, then "add_cast" entries will occur for every member of the group.
1682
+ # we only want to keep the LAST group entry zeroconfDiscoveryResult on file,
1683
+ # and ignore all of the others; otherwise, it creates duplicate entries in the
1684
+ # devices collection for each member device of the group!
1685
+
1686
+ # note that we will query the current multi-zone status if it's a group entry, as
1687
+ # we have to start the SpotifyAppTask on the group leader / coordinator device.
1688
+
1689
+ # example: RemoteName = "Kitchen Parlor", DeviceID = 3fb9b12379e4fa1957d065b4c4568939
1690
+ # - (add_cast) "Google-Cast-Group-B85B00FE5F514A40BF6AFBBCCEB7C8DF-1._googlecast._tcp.local."
1691
+ # HostIPAddr = 192.168.1.14:32139
1692
+ # GroupMembr = "Kitchen Speakers", 192.168.1.14:8009
1693
+ # RemoteName = "Kitchen Parlor"
1694
+ # DeviceID = 3fb9b12379e4fa1957d065b4c4568939
1695
+ # - (add_cast) "Google-Cast-Group-B85B00FE5F514A40BF6AFBBCCEB7C8DF._googlecast._tcp.local."
1696
+ # HostIPAddr = 192.168.1.186:32139
1697
+ # GroupMembr = "Parlor Speakers", 192.168.1.186:8009
1698
+ # RemoteName = "Kitchen Parlor"
1699
+ # DeviceID = 3fb9b12379e4fa1957d065b4c4568939
1700
+
1701
+ # ignore "update_cast" entries, as we don't want to create duplicates.
1702
+ if (serviceType == "update_cast"):
1703
+ _logsi.LogVerbose("SpotifyConnectDevice instance will be ignored for Google Cast Group data: \"%s\" (%s)" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name))
1704
+ return
1705
+
1706
+ # does the device name and id already exist?
1707
+ groupDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceByNameAndId(scDevice.Name, scDevice.Id)
1708
+ if (groupDevice is not None):
1709
+ _logsi.LogVerbose("SpotifyConnectDevice instance already exists for Google Cast Group data: \"%s\" (%s), Group RemoteName=%s, Host IP=%s" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name, groupDevice.Title, groupDevice.DiscoveryResult.HostIpTitle))
1710
+
1711
+ # set zeroconf discovery result properties.
1712
+ scDevice.DiscoveryResult = zeroconfDiscoveryResult
1713
+
1714
+ # update existing Spotify Connect Device in devices collection.
1715
+ self._SpotifyConnectDevices.Items[idx] = scDevice
1716
+ self._SpotifyConnectDevices.DateLastRefreshed = datetime.utcnow().timestamp()
1717
+ return
1718
+
1719
+ # register a multizone listener to listen for group change events; these
1720
+ # events will (hopefully) help us to determine the leader of a group.
1721
+ try:
1722
+
1723
+ # is this an add_cast event?
1724
+ if (serviceType == "add_cast"):
1725
+
1726
+ # do we have a multizone controller registered for this device yet?
1727
+ castMultizoneController:MultizoneController = self._CastMultiZoneControllers.get(scDevice.DiscoveryResult.Key, None)
1728
+ if (castMultizoneController is None):
1729
+
1730
+ # trace.
1731
+ _logsi.LogVerbose("Chromecast Multizone Controller Listener is being added for Cast device: %s [%s]" % (scDevice.Title, scDevice.DiscoveryResult.HostIpTitle), colorValue=SIColors.Lavender)
1732
+
1733
+ # add cast device to multizone controller instance. this will add a listener
1734
+ # for multizone status events to be processed, which allows us to keep track
1735
+ # of which device is a group leader.
1736
+ castMultizoneController = MultizoneController(castDevice.uuid)
1737
+ castMultizoneController.register_listener(SpotifyConnectZeroconfCastMultiZoneListener(self, self._ZeroconfInstance, self._Zeroconf_RLock, castMultizoneController, castDevice))
1738
+ castDevice.register_handler(castMultizoneController)
1739
+ castMultizoneController.update_members() # Request an initial status
1740
+
1741
+ # add cast multizone controller to active multizone controllers collection.
1742
+ self._CastMultiZoneControllers[str(castDevice.uuid)] = castMultizoneController
1743
+
1744
+ except Exception as ex:
1745
+
1746
+ # trace.
1747
+ _logsi.LogException("%s - MultiZoneController register_listener Exception: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
1748
+
1749
+ # add new Spotify Connect Device to devices collection.
1750
+ self._SpotifyConnectDevices.Items.append(scDevice)
1751
+ self._SpotifyConnectDevices.DateLastRefreshed = datetime.utcnow().timestamp()
1752
+
1753
+ # trace.
1754
+ _logsi.LogObject(SILevel.Verbose, "Chromecast Zeroconf added SpotifyConnectDevices collection entry: \"%s\" (%s)" % (scDevice.Name, scDevice.DiscoveryResult.Name), scDevice, excludeNonPublic=True, colorValue=SIColors.ForestGreen)
1755
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DeviceInfo / getInfo)" % (scDevice.Title), scDevice.DeviceInfo, excludeNonPublic=True)
1756
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DiscoveryResult) [%s]" % (scDevice.Title, scDevice.DiscoveryResult.HostIpTitle), scDevice.DiscoveryResult, excludeNonPublic=True)
1757
+
1758
+ # sort devices collection by device name.
1759
+ if (len(self._SpotifyConnectDevices.Items) > 0):
1760
+ self._SpotifyConnectDevices.Items.sort(key=lambda x: (x.Name or "").lower(), reverse=False)
1761
+
1762
+ # raise event.
1763
+ self._RaiseDeviceAdded(scDevice)
1764
+
1765
+ else:
1766
+
1767
+ # trace.
1768
+ _logsi.LogDebug("Updating existing SpotifyConnectDevice instance from CastInfo data: \"%s\" (%s) [key=%s]" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name, zeroconfDiscoveryResult.Key))
1769
+
1770
+ # get existing Spotify Connect Device instance.
1771
+ scDevice = self._SpotifyConnectDevices.Items[idx]
1772
+
1773
+ # were any changes made to the zeroconf discovery results?
1774
+ if (not scDevice.DiscoveryResult.Equals(zeroconfDiscoveryResult)):
1775
+
1776
+ # trace.
1777
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: \"%s\" (OLD DeviceInfo / getInfo)" % (scDevice.Title), scDevice.DeviceInfo, excludeNonPublic=True)
1778
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: \"%s\" (OLD DiscoveryResult) [%s]" % (scDevice.Title, scDevice.DiscoveryResult.HostIpTitle), scDevice.DiscoveryResult, excludeNonPublic=True)
1779
+
1780
+ # did the device name change? if so, then update name and id properties,
1781
+ # as well as the corresponding getInfo properties.
1782
+ deviceNameChanged:bool = False
1783
+ if (scDevice.Name != zeroconfDiscoveryResult.DeviceName):
1784
+
1785
+ # if Spotify Cast App is active on the cast device then request that it stop
1786
+ # as it will need to re-register the new device name and device id.
1787
+ castAppTask:SpotifyConnectZeroconfCastAppTask = self._CastAppTasks.get(zeroconfDiscoveryResult.Key, None)
1788
+ if (castAppTask is not None):
1789
+ if (castAppTask.is_alive()):
1790
+ _logsi.LogVerbose("%s - Stopping Spotify Cast App task (due to device rename)" % (self.name))
1791
+ castAppTask.IsStopRequested = True
1792
+ castAppTask.join()
1793
+ _logsi.LogVerbose("%s - Spotify Cast App task was stopped successfully (due to device rename)" % (self.name))
1794
+ self._CastAppTasks.pop(zeroconfDiscoveryResult.Key, None)
1795
+
1796
+ # update name and id properties, as well as the corresponding getInfo properties.
1797
+ newDeviceName:str = zeroconfDiscoveryResult.DeviceName
1798
+ newDeviceId:str = self.GetSpotifyDeviceIDFromName(zeroconfDiscoveryResult.DeviceName)
1799
+ _logsi.LogVerbose("Chromecast Zeroconf SpotifyConnectDevice entry name and id changed from %s to \"%s\" (%s)" % (scDevice.Title, newDeviceName, newDeviceId))
1800
+ scDevice.Name = newDeviceName
1801
+ scDevice.Id = newDeviceId
1802
+ scDevice.DeviceInfo.DeviceId = newDeviceId
1803
+ scDevice.DeviceInfo.RemoteName = newDeviceName
1804
+ deviceNameChanged = True
1805
+
1806
+ # is this a Google Cast Group?
1807
+ # if so, AND the device name did not change, then ignore the update since
1808
+ # we want the first device discovered in the group to be the coordinator by default.
1809
+ # this might change later, but we will detect that using calls to multizone status.
1810
+ if (zeroconfDiscoveryResult.IsChromeCastGroup) and (not deviceNameChanged):
1811
+ _logsi.LogObject(SILevel.Verbose, "SpotifyConnectDevice info: \"%s\" (ignored; Cast Group device update)" % (scDevice.Name), scDevice.DiscoveryResult, excludeNonPublic=True)
1812
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (NEW DiscoveryResult ignored) [%s]" % (scDevice.Title, zeroconfDiscoveryResult.HostIpTitle), zeroconfDiscoveryResult, excludeNonPublic=True)
1813
+ return
1814
+
1815
+ # set zeroconf discovery result properties.
1816
+ scDevice.DiscoveryResult = zeroconfDiscoveryResult
1817
+
1818
+ # update existing Spotify Connect Device in devices collection.
1819
+ self._SpotifyConnectDevices.Items[idx] = scDevice
1820
+ self._SpotifyConnectDevices.DateLastRefreshed = datetime.utcnow().timestamp()
1821
+
1822
+ # trace.
1823
+ _logsi.LogObject(SILevel.Verbose, "Chromecast Zeroconf updated SpotifyConnectDevices collection entry: \"%s\" (%s)" % (scDevice.Name, scDevice.DiscoveryResult.Name), scDevice, excludeNonPublic=True, colorValue=SIColors.ForestGreen)
1824
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DeviceInfo / getInfo)" % (scDevice.Title), scDevice.DeviceInfo, excludeNonPublic=True)
1825
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DiscoveryResult) [%s]" % (scDevice.Title, scDevice.DiscoveryResult.HostIpTitle), scDevice.DiscoveryResult, excludeNonPublic=True)
1826
+
1827
+ # sort devices collection by device name.
1828
+ if (len(self._SpotifyConnectDevices.Items) > 0):
1829
+ self._SpotifyConnectDevices.Items.sort(key=lambda x: (x.Name or "").lower(), reverse=False)
1830
+
1831
+ # raise event.
1832
+ self._RaiseDeviceUpdated(scDevice)
1833
+
1834
+ else:
1835
+
1836
+ # trace.
1837
+ _logsi.LogObject(SILevel.Verbose, "SpotifyConnectDevice info: \"%s\" (ignored; DiscoveryResult did not change)" % (scDevice.Name), scDevice.DiscoveryResult, excludeNonPublic=True)
1838
+
1839
+ except Exception as ex:
1840
+
1841
+ # trace.
1842
+ _logsi.LogException("%s - Exception: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
1843
+
1844
+ # ignore exceptions, as there is nothing we can do at this point.
1845
+
1846
+ finally:
1847
+
1848
+ # trace.
1849
+ _logsi.LeaveMethod(SILevel.Debug)
1850
+
1851
+
1852
+ def OnServiceInfoRemovedChromecast(
1853
+ self,
1854
+ zeroconfDiscoveryResult: ZeroconfDiscoveryResult,
1855
+ uuid:UUID,
1856
+ serviceName:str,
1857
+ castInfo:CastInfo
1858
+ ) -> None:
1859
+ """
1860
+ Called when a cast has been lost (MDNS info expired or host down).
1861
+
1862
+ Args:
1863
+ zeroconfDiscoveryResult (ZeroconfDiscoveryResult):
1864
+ A `ZeroconfDiscoveryResult` object that contains discovery details.
1865
+ uuid (UUID):
1866
+ The cast's uuid, which is the dictionary key to find the chromecast
1867
+ metadata in CastBrowser.devices collection.
1868
+ serviceName (str):
1869
+ Last valid MDNS service name or host:port.
1870
+ castInfo (CastInfo):
1871
+ CastInfo for the service to aid cleanup.
1872
+
1873
+ This could be called under the following conditions:
1874
+ - when a Chromecast device has logged out of zeroconf and left the network.
1875
+ - transfer playback failed (e.g. device restriction, etc).
1876
+
1877
+ This method will be called in a thread-safe manner, as the caller is using
1878
+ the `_Zeroconf_RLock` object to control access.
1879
+ """
1880
+ # syncronize access via lock, as we are accessing the collection.
1881
+ with self._SpotifyConnectDevices_RLock:
1882
+
1883
+ try:
1884
+
1885
+ # trace.
1886
+ _logsi.EnterMethod(SILevel.Debug)
1887
+
1888
+ # get cast device instance by discovery key.
1889
+ # note that we cannot use discovery name here, as Google Groups use different
1890
+ # names that refer to the same key value!
1891
+ idx:int = self._SpotifyConnectDevices.GetDeviceIndexByDiscoveryKey(zeroconfDiscoveryResult.Key)
1892
+ if (idx == -1):
1893
+
1894
+ # trace.
1895
+ _logsi.LogVerbose("SpotifyConnectDevice instance could not be found to remove: \"%s\" (%s) [%s]" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name, zeroconfDiscoveryResult.Key))
1896
+
1897
+ else:
1898
+
1899
+ # unregister the multizone listener, if one was registered.
1900
+ try:
1901
+
1902
+ # do we have a multizone controller registered for this device?
1903
+ castMultizoneController:MultizoneController = self._CastMultiZoneControllers.get(zeroconfDiscoveryResult.Key, None)
1904
+ if (castMultizoneController is not None):
1905
+
1906
+ # trace.
1907
+ _logsi.LogVerbose("Chromecast Multizone Controller Listener is being removed: \"%s\" (%s) [%s]" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name, zeroconfDiscoveryResult.Key), colorValue=SIColors.Lavender)
1908
+
1909
+ # tear down the listener.
1910
+ castMultizoneController.tear_down()
1911
+
1912
+ # remove cast multizone controller from active multizone controllers collection.
1913
+ self._CastMultiZoneControllers.pop(zeroconfDiscoveryResult.Key, None)
1914
+
1915
+ except Exception as ex:
1916
+
1917
+ # trace.
1918
+ _logsi.LogException("%s - MultiZoneController register_listener Exception: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
1919
+
1920
+ # trace.
1921
+ _logsi.LogVerbose("Removing existing SpotifyConnectDevice instance from CastInfo data: \"%s\" (%s) [%s]" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name, zeroconfDiscoveryResult.Key))
1922
+
1923
+ # remove existing Spotify Connect Device instance from devices collection.
1924
+ scDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.Items.pop(idx)
1925
+ self._SpotifyConnectDevices.DateLastRefreshed = datetime.utcnow().timestamp()
1926
+
1927
+ # trace.
1928
+ _logsi.LogObject(SILevel.Verbose, "Chromecast Zeroconf removed SpotifyConnectDevices collection entry: \"%s\" (%s)" % (scDevice.Name, scDevice.DiscoveryResult.Name), scDevice, excludeNonPublic=True, colorValue=SIColors.DarkOrange)
1929
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DeviceInfo / getInfo)" % (scDevice.Title), scDevice.DeviceInfo, excludeNonPublic=True)
1930
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DiscoveryResult) [%s]" % (scDevice.Title, scDevice.DiscoveryResult.HostIpTitle), scDevice.DiscoveryResult, excludeNonPublic=True)
1931
+
1932
+ # raise event.
1933
+ self._RaiseDeviceRemoved(scDevice)
1934
+
1935
+ except Exception as ex:
1936
+
1937
+ # trace.
1938
+ _logsi.LogException("%s - Exception: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
1939
+
1940
+ # ignore exceptions, as there is nothing we can do at this point.
1941
+
1942
+ finally:
1943
+
1944
+ # trace.
1945
+ _logsi.LeaveMethod(SILevel.Debug)
1946
+
1947
+
1948
+ def OnCastGetInfoResponseReceived(
1949
+ self,
1950
+ uuid:UUID,
1951
+ info:ZeroconfGetInfo
1952
+ ) -> None:
1953
+ """
1954
+ Called when the cast app task has received a Spotify Connect Zeroconf GetInfoResponse
1955
+ data structure.
1956
+
1957
+ Args:
1958
+ uuid (UUID):
1959
+ Spotify Connect device that was added.
1960
+ info (ZeroconfGetInfo):
1961
+ A `ZeroconfGetInfo` instance that contains the GetInfoResponse data.
1962
+
1963
+ This method allows us to update the SpotifyConnectDevice entry with the Spotify Connect
1964
+ Zeroconf device information.
1965
+ """
1966
+ # syncronize access via lock, as we are accessing the collection.
1967
+ with self._SpotifyConnectDevices_RLock:
1968
+
1969
+ # get device instance.
1970
+ scDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceByDiscoveryKey(str(uuid))
1971
+ if (scDevice is not None):
1972
+
1973
+ # the actual deviceId that was retrieved may be different than the requested deviceId.
1974
+ # this can sometimes occur when activating a group, as getInfoResponse will return the
1975
+ # deviceId of the group coordinator instead of the deviceId of the group itself.
1976
+ # this only seems to happen in non-Google manufactured devices that have not properly
1977
+ # implemented the Cast protocol for grouped devices (e.g. KEF, etc); it never occurs when
1978
+ # casting to groups of devices manufactured by Google!
1979
+
1980
+ if (scDevice.Id != info.DeviceId):
1981
+
1982
+ # trace.
1983
+ if (_logsi.IsOn(SILevel.Verbose)):
1984
+ _logsi.LogVerbose("Received getInfoResponse from group coordinator device \"%s\" (%s); expected group device \"%s\" (OnCastGetInfoResponseReceived)" % (info.RemoteName or str(info.Aliases), info.DeviceId, scDevice.Title), colorValue=SIColors.Red)
1985
+ _logsi.LogObject(SILevel.Verbose, "SpotifyConnectDevice info: \"%s\" (%s) (Group Coordinator DeviceInfo / getInfo, OnCastGetInfoResponseReceived)" % (info.RemoteName or str(info.Aliases), info.DeviceId), info, excludeNonPublic=True, colorValue=SIColors.Red)
1986
+
1987
+ # if device id's don't match then we will NOT overlay the original group's ZeroconfGetInfo structure
1988
+ # as we want to keep it intact, since the getInfoResponse is from the group coordinator.
1989
+ # overlaying it here would cause duplicate device ID's in the collection!
1990
+ # in this case, just populate the ZeroconfResponse base class properties and other basic properties.
1991
+ scDevice.DeviceInfo.ActiveUser = info.ActiveUser
1992
+ scDevice.DeviceInfo.InteractionIDs = info.InteractionIDs
1993
+ scDevice.DeviceInfo._GroupStatus = info.GroupStatus
1994
+
1995
+ # ZeroconfResponse base class properties.
1996
+ scDevice.DeviceInfo._ResponseSource = info.ResponseSource
1997
+ scDevice.DeviceInfo.SpotifyError = info.SpotifyError
1998
+ scDevice.DeviceInfo.Status = info.Status
1999
+ scDevice.DeviceInfo.StatusString = info.StatusString
2000
+
2001
+ else:
2002
+
2003
+ # ZeroconfGetInfo class properties.
2004
+ # we use underscore property reference for those that don't have a property setter method.
2005
+ scDevice.DeviceInfo._AccountReq = info.AccountReq
2006
+ scDevice.DeviceInfo.ActiveUser = info.ActiveUser
2007
+ scDevice.DeviceInfo._Aliases = info.Aliases
2008
+ scDevice.DeviceInfo._Availability = info.Availability
2009
+ scDevice.DeviceInfo._ClientId = info.ClientId
2010
+ scDevice.DeviceInfo.DeviceId = info.DeviceId # same as zeroconf deviceId
2011
+ scDevice.DeviceInfo._GroupStatus = info.GroupStatus
2012
+ scDevice.DeviceInfo.InteractionIDs = info.InteractionIDs
2013
+ scDevice.DeviceInfo._LibraryVersion = info.LibraryVersion
2014
+ scDevice.DeviceInfo.ProductId = info.ProductId
2015
+ scDevice.DeviceInfo.PublicKey = info.PublicKey
2016
+ scDevice.DeviceInfo.RemoteName = info.RemoteName # same as zeroconf friendly_name
2017
+ scDevice.DeviceInfo._ResolverVersion = info.ResolverVersion
2018
+ scDevice.DeviceInfo._Scope = info.Scope
2019
+ scDevice.DeviceInfo._SupportedCapabilities = info.SupportedCapabilities
2020
+ scDevice.DeviceInfo._SupportedDrmMediaFormats = info.SupportedDrmMediaFormats
2021
+ scDevice.DeviceInfo._TokenType = info.TokenType
2022
+ scDevice.DeviceInfo._Version = info.Version
2023
+ scDevice.DeviceInfo._VoiceSupport = info.VoiceSupport
2024
+
2025
+ # we will leave the following properties with their current values as
2026
+ # set from the CastInfo instance, as they contain more consistent values
2027
+ # that properly describe the device.
2028
+ #scDevice.DeviceInfo.BrandDisplayName = info.BrandDisplayName
2029
+ #scDevice.DeviceInfo.DeviceType = info.DeviceType
2030
+ #scDevice.DeviceInfo.ModelDisplayName = info.ModelDisplayName
2031
+
2032
+ # ZeroconfResponse base class properties.
2033
+ scDevice.DeviceInfo._ResponseSource = info.ResponseSource
2034
+ scDevice.DeviceInfo.SpotifyError = info.SpotifyError
2035
+ scDevice.DeviceInfo.Status = info.Status
2036
+ scDevice.DeviceInfo.StatusString = info.StatusString
2037
+
2038
+ # trace.
2039
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DeviceInfo / getInfo, OnCastGetInfoResponseReceived)" % (scDevice.Title), scDevice.DeviceInfo, excludeNonPublic=True)
2040
+
2041
+
2042
+ def OnCastZeroconfResponseReceived(
2043
+ self,
2044
+ uuid:UUID,
2045
+ response:ZeroconfResponse,
2046
+ ) -> None:
2047
+ """
2048
+ Called when the cast app task has received a Spotify Connect ZeroconfResponse
2049
+ data structure.
2050
+
2051
+ Args:
2052
+ uuid (UUID):
2053
+ Spotify Connect device that was added.
2054
+ response (ZeroconfResponse):
2055
+ A `ZeroconfResponse` instance that contains the zeroconf response data.
2056
+
2057
+ This method allows us to update the SpotifyConnectDevice entry with the Spotify Connect
2058
+ Zeroconf response information.
2059
+ """
2060
+ # syncronize access via lock, as we are accessing the collection.
2061
+ with self._SpotifyConnectDevices_RLock:
2062
+
2063
+ # get device instance.
2064
+ scDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceByDiscoveryKey(str(uuid))
2065
+ if (scDevice is not None):
2066
+
2067
+ # ZeroconfResponse class properties.
2068
+ scDevice.ZeroconfResponseInfo.ResponseSource = response.ResponseSource
2069
+ scDevice.ZeroconfResponseInfo.SpotifyError = response.SpotifyError
2070
+ scDevice.ZeroconfResponseInfo.Status = response.Status
2071
+ scDevice.ZeroconfResponseInfo.StatusString = response.StatusString
2072
+
2073
+ # trace.
2074
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: \"%s\" (ZeroconfResponseInfo)" % (scDevice.Name), scDevice.ZeroconfResponseInfo, excludeNonPublic=True)
2075
+
2076
+ # indicate zeroconf response data was received.
2077
+ if (response.ResponseSource == TYPE_ADD_USER_RESPONSE) or (response.ResponseSource == TYPE_ADD_USER_ERROR) \
2078
+ or (response.ResponseSource == TYPE_GET_INFO_ERROR):
2079
+ self.WaitForActivationComplete.set()
2080
+ elif (response.ResponseSource == TYPE_TRANSFER_SUCCESS) or (response.ResponseSource == TYPE_TRANSFER_ERROR):
2081
+ self.WaitForTransferComplete.set()
2082
+ elif (response.ResponseSource == TYPE_LAUNCH_ERROR):
2083
+ self.WaitForActivationComplete.set()
2084
+
2085
+
2086
+ def OnServiceInfoAddedUpdatedSpotifyConnect(
2087
+ self,
2088
+ zeroconfDiscoveryResult:ZeroconfDiscoveryResult
2089
+ ) -> None:
2090
+ """
2091
+ Called when a new Spotify Connect ServiceInfo record has been discovered
2092
+ or updated for a Spotify Connect device.
2093
+
2094
+ Args:
2095
+ zeroconfDiscoveryResult (ZeroconfDiscoveryResult):
2096
+ A `ZeroconfDiscoveryResult` object that contains discovery details.
2097
+
2098
+ This method will be called in a thread-safe manner, as the caller is using
2099
+ the `_Zeroconf_RLock` object to control access.
2100
+ """
2101
+ # syncronize access via lock, as we are accessing the collection.
2102
+ with self._SpotifyConnectDevices_RLock:
2103
+
2104
+ try:
2105
+
2106
+ # trace.
2107
+ _logsi.EnterMethod(SILevel.Debug)
2108
+
2109
+ # add / update discovered results by serviceinfo key value.
2110
+ # we use the serviceinfo NAME value (not KEY) since some manufacturers use random ip port
2111
+ # numbers, and the port number could have been updated. in this case, the name SHOULD remain the same.
2112
+ # note that a different serviceinfo name SHOULD be created for speaker groups; at least they are for BOSE devices.
2113
+ scDevice:SpotifyConnectDevice = None
2114
+ idx:int = self._SpotifyConnectDevices.GetDeviceIndexByDiscoveryName(zeroconfDiscoveryResult.Name)
2115
+ if (idx == -1):
2116
+
2117
+ # trace.
2118
+ _logsi.LogVerbose("Creating new SpotifyConnectDevice instance from ServiceInfo data: \"%s\" (%s)" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name))
2119
+
2120
+ # By default, the ZeroConf DeviceName is used for the device name value; this
2121
+ # will change later when the Spotify Connect `getInfo` call is made to retrieve the
2122
+ # RemoteName value, which is what Spotify Connect capable players display as a device name.
2123
+ # For most manufacturers, the ZeroConf DeviceName is an internal-use type of value (e.g. "sonosRINCON_38420B909DC801400").
2124
+ # For other manufacturers, this is a user-friendly type of value (e.g. "Bose-ST10-1").
2125
+ # Here are some examples (by manufacturer):
2126
+
2127
+ # Manufacturer ZeroConf DeviceName getInfo Remotename getInfo Alias
2128
+ # ------------ ------------------------------ ----------------------------------- -----------------------
2129
+ # Sonos sonosRINCON_38420B909DC801400" Office n/a
2130
+ # Bose Bose-ST10-1 Bose-ST10-1 n/a
2131
+ # Bose Bose-ST10-1 + Bose-ST10-2 (L+R) Bose-ST10-1 (if stereo paired) n/a
2132
+ # Bose Bose-ST10-1 Group Bose-ST10-1 (if zoned) n/a
2133
+ # Denon 0005cdb737b2LivingRoom Living Room n/a
2134
+ # Wiim FF98F35902BB6CA851FE34D5 n/a (uses Alias) "Kitchen Speakers"
2135
+
2136
+ # create a new Spotify Connect Device instance.
2137
+ scDevice = SpotifyConnectDevice()
2138
+ scDevice.Name = zeroconfDiscoveryResult.DeviceName
2139
+
2140
+ # set zeroconf discovery result properties.
2141
+ scDevice.DiscoveryResult = zeroconfDiscoveryResult
2142
+
2143
+ # verify CPath property was present; if not, log a warning as the endpoint url will be invalid!
2144
+ if (zeroconfDiscoveryResult.SpotifyConnectCPath is None):
2145
+ _logsi.LogWarning("Spotify Connect device Zeroconf ServiceInfo data did not contain a CPath value: \"%s\" (%s)" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name))
2146
+
2147
+ try:
2148
+
2149
+ # trace.
2150
+ _logsi.LogVerbose("Retrieving Spotify Connect device information: %s" % (zeroconfDiscoveryResult.Id))
2151
+
2152
+ # create connection object to retrieve spotify connect device information (via direct ip address).
2153
+ zconn:ZeroconfConnect = ZeroconfConnect(
2154
+ zeroconfDiscoveryResult.HostIpAddress,
2155
+ zeroconfDiscoveryResult.HostIpPort,
2156
+ zeroconfDiscoveryResult.SpotifyConnectCPath,
2157
+ zeroconfDiscoveryResult.SpotifyConnectVersion,
2158
+ useSSL=False,
2159
+ tokenStorageDir=self.SpotifyClientInstance.TokenStorageDir,
2160
+ tokenStorageFile=self.SpotifyClientInstance.TokenStorageFile
2161
+ )
2162
+
2163
+ # retrieve initial spotify connect device information (by ip address).
2164
+ scDevice.DeviceInfo = zconn.GetInformation()
2165
+ scDevice.Id = scDevice.DeviceInfo.DeviceId
2166
+ scDevice.Name = scDevice.DeviceInfo.RemoteName
2167
+
2168
+ # if remote name was not specified, then set device name to first alias name.
2169
+ # note that we will not reset the RemoteName, as the "" value indicates an alias is in use.
2170
+ if ((scDevice.DeviceInfo.RemoteName + "").strip() == ""):
2171
+ if (scDevice.DeviceInfo.HasAliases):
2172
+ _logsi.LogVerbose("Spotify Connect Zeroconf GetInformation alias name will be utilized for Zeroconf Discovery Result: \"%s\" (%s)" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name))
2173
+ scDevice.Name = scDevice.DeviceInfo.Aliases[0].Name
2174
+
2175
+ except Exception as ex:
2176
+
2177
+ # trace.
2178
+ _logsi.LogVerbose("Could not retrieve Spotify Connect device information by ip address; retrying with DNS server alias \"%s\"" % (zeroconfDiscoveryResult.Server))
2179
+
2180
+ try:
2181
+
2182
+ # *IMPORTANT* This call can take up to 20 seconds to timeout because it is using DNS
2183
+ # resolution to find the device. the timeout is a system-level value, so it cannot be
2184
+ # controlled by this api!
2185
+
2186
+ # create connection object to retrieve spotify connect device information (via dns alias).
2187
+ zconn:ZeroconfConnect = ZeroconfConnect(
2188
+ zeroconfDiscoveryResult.Server,
2189
+ zeroconfDiscoveryResult.HostIpPort,
2190
+ zeroconfDiscoveryResult.SpotifyConnectCPath,
2191
+ zeroconfDiscoveryResult.SpotifyConnectVersion,
2192
+ useSSL=False,
2193
+ tokenStorageDir=self.SpotifyClientInstance.TokenStorageDir,
2194
+ tokenStorageFile=self.SpotifyClientInstance.TokenStorageFile
2195
+ )
2196
+
2197
+ # retrieve initial spotify connect device information (by dns alias).
2198
+ scDevice.DeviceInfo = zconn.GetInformation()
2199
+ scDevice.Id = scDevice.DeviceInfo.DeviceId
2200
+ scDevice.Name = scDevice.DeviceInfo.RemoteName
2201
+
2202
+ # if remote name was not specified, then set device name to first alias name.
2203
+ # note that we will not reset the RemoteName, as the "" value indicates an alias is in use.
2204
+ if ((scDevice.DeviceInfo.RemoteName + "").strip() == ""):
2205
+ if (scDevice.DeviceInfo.HasAliases):
2206
+ _logsi.LogVerbose("Spotify Connect Zeroconf GetInformation alias name will be utilized for Zeroconf Discovery Result: \"%s\" (%s)" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name))
2207
+ scDevice.Name = scDevice.DeviceInfo.Aliases[0].Name
2208
+
2209
+ # update HostIpAddress in discovery result so it knows to use the dns alias
2210
+ # instead of the ip address.
2211
+ _logsi.LogVerbose("Spotify Connect Zeroconf GetInformation call for Instance Name \"%s\" (%s) was resolved using DNS Server alias; HostIpAddress will be updated with the resolved ip address" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Server))
2212
+ resolvedIpAddress = socket.gethostbyname(zeroconfDiscoveryResult.Server)
2213
+ zeroconfDiscoveryResult.HostIpAddress = resolvedIpAddress
2214
+
2215
+ except Exception as ex:
2216
+
2217
+ # trace.
2218
+ _logsi.LogException("Spotify Connect Zeroconf GetInformation call failed for device instance \"%s\" (ip=%s:%s): %s" % (scDevice.Name, zeroconfDiscoveryResult.HostIpAddress, zeroconfDiscoveryResult.HostIpPort, str(ex)), ex, logToSystemLogger=False)
2219
+
2220
+ # if Getinformation call failed, then default device information.
2221
+ scDevice.DeviceInfo = ZeroconfGetInfo()
2222
+ scDevice.DeviceInfo.DeviceId = "getInfoError"
2223
+ scDevice.DeviceInfo.RemoteName = zeroconfDiscoveryResult.DeviceName
2224
+ scDevice.DeviceInfo.ResponseSource = "spotifywebapiPython"
2225
+ scDevice.DeviceInfo.Status = 9999
2226
+ scDevice.DeviceInfo.StatusString = str(ex)
2227
+ scDevice.Id = scDevice.DeviceInfo.DeviceId
2228
+
2229
+ # did this device change from a dynamic device (e.g. non-zeroconf) to a zeroconf device?
2230
+ # this can happen if the device is initially added as a dynamic device, then the zeroconf
2231
+ # device is discovered. if so, we will remove the dynamic device entry.
2232
+ spDynamicDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.GetDeviceById(scDevice.DeviceInfo.DeviceId)
2233
+ if (spDynamicDevice is not None):
2234
+
2235
+ # trace.
2236
+ _logsi.LogVerbose("Spotify Connect Zeroconf is converting SpotifyConnectDevice entry %s from dynamic to zeroconf" % (spDynamicDevice.Title))
2237
+
2238
+ # copy real-time status dynamic properties to zerconf object.
2239
+ scDevice.IsActiveDevice = spDynamicDevice.IsActiveDevice
2240
+ scDevice.IsInDeviceList = spDynamicDevice.IsInDeviceList
2241
+
2242
+ # at this point we will remove the device from the collection so that it can be
2243
+ # replaced with the static device entry; this will also remove any duplicate
2244
+ # device id entries, which can appear for some manufacturers when devices are
2245
+ # grouped, zoned, or stereo paired (e.g. Bose).
2246
+ dynamicOnly:bool = True
2247
+ if (scDevice.DiscoveryResult.IsDynamicDevice == False) and (spDynamicDevice.DiscoveryResult.IsDynamicDevice == False):
2248
+ dynamicOnly = False
2249
+ self.RemoveDevice(spDynamicDevice.Id, dynamicDeviceOnly=dynamicOnly)
2250
+
2251
+ # add new Spotify Connect Device to devices collection.
2252
+ self._SpotifyConnectDevices.Items.append(scDevice)
2253
+ self._SpotifyConnectDevices.DateLastRefreshed = datetime.utcnow().timestamp()
2254
+
2255
+ # trace.
2256
+ _logsi.LogObject(SILevel.Verbose, "Spotify Connect Zeroconf added SpotifyConnectDevices collection entry: \"%s\" (%s)" % (scDevice.Name, scDevice.DiscoveryResult.Name), scDevice, excludeNonPublic=True, colorValue=SIColors.ForestGreen)
2257
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DeviceInfo / getInfo)" % (scDevice.Title), scDevice.DeviceInfo, excludeNonPublic=True)
2258
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DiscoveryResult) [%s]" % (scDevice.Title, scDevice.DiscoveryResult.HostIpTitle), scDevice.DiscoveryResult, excludeNonPublic=True)
2259
+
2260
+ # sort devices collection by device name.
2261
+ if (len(self._SpotifyConnectDevices.Items) > 0):
2262
+ self._SpotifyConnectDevices.Items.sort(key=lambda x: (x.Name or "").lower(), reverse=False)
2263
+
2264
+ try:
2265
+
2266
+ # is this a Sonos device?
2267
+ if (scDevice.IsSonos):
2268
+
2269
+ # create a Sonos Controller instance for the device, retrieve the Sonos speaker information,
2270
+ # and add it to the Sonos players collection. use device ip address as the key, as the
2271
+ # Spotify Web API reports "id=null" for restricted devices in playerstate!
2272
+ _logsi.LogVerbose("Sonos device detected; creating Sonos Controller instance for device: %s (ip=%s)" % (scDevice.Title, zeroconfDiscoveryResult.HostIpAddress))
2273
+ sonosPlayer:SoCo = SoCo(zeroconfDiscoveryResult.HostIpAddress)
2274
+ self._SonosPlayers[zeroconfDiscoveryResult.HostIpAddress] = sonosPlayer
2275
+
2276
+ try:
2277
+ sonosPlayer.get_speaker_info()
2278
+ except Exception as ex:
2279
+ _logsi.LogException("Could not get speaker info for Sonos Controller instance: %s" % (str(ex)), ex, logToSystemLogger=False)
2280
+
2281
+ # trace.
2282
+ if (_logsi.IsOn(SILevel.Verbose)):
2283
+ _logsi.LogObject(SILevel.Verbose, "Sonos Controller instance for device: %s" % (scDevice.Title), sonosPlayer)
2284
+ _logsi.LogDictionary(SILevel.Verbose, "Sonos Controller instance for device: %s (speaker_info)" % (scDevice.Title), sonosPlayer.speaker_info)
2285
+ _logsi.LogObject(SILevel.Verbose, "Sonos Controller instance for device: %s (group)" % (scDevice.Title), sonosPlayer.group)
2286
+ _logsi.LogObject(SILevel.Verbose, "Sonos Controller instance for device: %s (zone_group_state)" % (scDevice.Title), sonosPlayer.zone_group_state)
2287
+ _logsi.LogEnumerable(SILevel.Verbose, "Sonos Controller instance for device: %s (all_zones)" % (scDevice.Title), sonosPlayer.all_zones)
2288
+
2289
+ except:
2290
+
2291
+ # trace.
2292
+ _logsi.LogException("%s - Could not create Sonos Control API reference: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
2293
+
2294
+ # ignore exceptions, as there is nothing we can do at this point.
2295
+
2296
+ # raise event.
2297
+ self._RaiseDeviceAdded(scDevice)
2298
+
2299
+ else:
2300
+
2301
+ # trace.
2302
+ _logsi.LogDebug("Updating existing SpotifyConnectDevice instance from ServiceInfo data: \"%s\" (%s)" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name))
2303
+
2304
+ # get existing Spotify Connect Device instance.
2305
+ scDevice = self._SpotifyConnectDevices.Items[idx]
2306
+
2307
+ # were any changes made to the zeroconf discovery results?
2308
+ if (not scDevice.DiscoveryResult.Equals(zeroconfDiscoveryResult)):
2309
+
2310
+ # trace.
2311
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: \"%s\" (OLD DeviceInfo / getInfo)" % (scDevice.Title), scDevice.DeviceInfo, excludeNonPublic=True)
2312
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: \"%s\" (OLD DiscoveryResult) [%s]" % (scDevice.Title, scDevice.DiscoveryResult.HostIpTitle), scDevice.DiscoveryResult, excludeNonPublic=True)
2313
+
2314
+ # set zeroconf discovery result properties.
2315
+ scDevice.DiscoveryResult = zeroconfDiscoveryResult
2316
+
2317
+ # did the device name change? if so, then update name and id properties,
2318
+ # as well as the corresponding getInfo properties.
2319
+ if (scDevice.Name != zeroconfDiscoveryResult.DeviceName):
2320
+ newDeviceName:str = zeroconfDiscoveryResult.DeviceName
2321
+ newDeviceId:str = self.GetSpotifyDeviceIDFromName(zeroconfDiscoveryResult.DeviceName)
2322
+ _logsi.LogVerbose("Spotify Connect Zeroconf SpotifyConnectDevice entry name and id changed from %s to \"%s\" (%s)" % (scDevice.Title, newDeviceName, newDeviceId))
2323
+ scDevice.Name = newDeviceName
2324
+ scDevice.Id = newDeviceId
2325
+ scDevice.DeviceInfo.DeviceId = newDeviceId
2326
+ scDevice.DeviceInfo.RemoteName = newDeviceName
2327
+
2328
+ # update existing Spotify Connect Device in devices collection.
2329
+ self._SpotifyConnectDevices.Items[idx] = scDevice
2330
+ self._SpotifyConnectDevices.DateLastRefreshed = datetime.utcnow().timestamp()
2331
+
2332
+ # trace.
2333
+ _logsi.LogObject(SILevel.Verbose, "Spotify Connect Zeroconf updated SpotifyConnectDevices collection entry: \"%s\" (%s)" % (scDevice.Name, scDevice.DiscoveryResult.Name), scDevice, excludeNonPublic=True, colorValue=SIColors.ForestGreen)
2334
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DeviceInfo / getInfo)" % (scDevice.Title), scDevice.DeviceInfo, excludeNonPublic=True)
2335
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DiscoveryResult) [%s]" % (scDevice.Title, scDevice.DiscoveryResult.HostIpTitle), scDevice.DiscoveryResult, excludeNonPublic=True)
2336
+
2337
+ # sort devices collection by device name.
2338
+ if (len(self._SpotifyConnectDevices.Items) > 0):
2339
+ self._SpotifyConnectDevices.Items.sort(key=lambda x: (x.Name or "").lower(), reverse=False)
2340
+
2341
+ # raise event.
2342
+ self._RaiseDeviceUpdated(scDevice)
2343
+
2344
+ else:
2345
+
2346
+ # trace.
2347
+ _logsi.LogObject(SILevel.Verbose, "SpotifyConnectDevice info: \"%s\" (ignored; DiscoveryResult did not change)" % (scDevice.Name), scDevice.DiscoveryResult, excludeNonPublic=True)
2348
+
2349
+ except Exception as ex:
2350
+
2351
+ # trace.
2352
+ _logsi.LogException("%s - Exception: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
2353
+
2354
+ # ignore exceptions, as there is nothing we can do at this point.
2355
+
2356
+ finally:
2357
+
2358
+ # trace.
2359
+ _logsi.LeaveMethod(SILevel.Debug)
2360
+
2361
+
2362
+ def OnServiceInfoRemovedSpotifyConnect(
2363
+ self,
2364
+ zeroconfDiscoveryResult:ZeroconfDiscoveryResult
2365
+ ) -> None:
2366
+ """
2367
+ Called when an mDNS service is lost.
2368
+
2369
+ Args:
2370
+ zeroconfDiscoveryResult (ZeroconfDiscoveryResult):
2371
+ A `ZeroconfDiscoveryResult` object that contains discovery details.
2372
+
2373
+ This method will be called in a thread-safe manner, as the caller is using
2374
+ the `_Zeroconf_RLock` object to control access.
2375
+ """
2376
+ # syncronize access via lock, as we are accessing the collection.
2377
+ with self._SpotifyConnectDevices_RLock:
2378
+
2379
+ try:
2380
+
2381
+ # trace.
2382
+ _logsi.EnterMethod(SILevel.Debug)
2383
+
2384
+ # remove discovered results by serviceinfo key value.
2385
+ # we use the serviceinfo NAME value (not KEY) since that is the only information available on
2386
+ # a remove serviceinfo request.
2387
+ idx:int = self._SpotifyConnectDevices.GetDeviceIndexByDiscoveryName(zeroconfDiscoveryResult.Name)
2388
+ if (idx == -1):
2389
+
2390
+ # trace.
2391
+ _logsi.LogVerbose("SpotifyConnectDevice instance could not be found to remove: \"%s\" (%s)" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name))
2392
+
2393
+ else:
2394
+
2395
+ # trace.
2396
+ _logsi.LogVerbose("Removing existing SpotifyConnectDevice instance from ServiceInfo data: \"%s\" (%s)" % (zeroconfDiscoveryResult.DeviceName, zeroconfDiscoveryResult.Name))
2397
+
2398
+ # remove existing Spotify Connect Device instance from devices collection.
2399
+ scDevice:SpotifyConnectDevice = self._SpotifyConnectDevices.Items.pop(idx)
2400
+ self._SpotifyConnectDevices.DateLastRefreshed = datetime.utcnow().timestamp()
2401
+
2402
+ # trace.
2403
+ _logsi.LogObject(SILevel.Verbose, "Spotify Connect Zeroconf removed SpotifyConnectDevices collection entry: \"%s\" (%s)" % (scDevice.Name, scDevice.DiscoveryResult.Name), scDevice, excludeNonPublic=True, colorValue=SIColors.DarkOrange)
2404
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DeviceInfo / getInfo)" % (scDevice.Title), scDevice.DeviceInfo, excludeNonPublic=True)
2405
+ _logsi.LogObject(SILevel.Debug, "SpotifyConnectDevice info: %s (DiscoveryResult) [%s]" % (scDevice.Title, scDevice.DiscoveryResult.HostIpTitle), scDevice.DiscoveryResult, excludeNonPublic=True)
2406
+
2407
+ # raise event.
2408
+ self._RaiseDeviceRemoved(scDevice)
2409
+
2410
+ except Exception as ex:
2411
+
2412
+ # trace.
2413
+ _logsi.LogException("%s - Exception: %s" % (self.name, str(ex)), ex, logToSystemLogger=False)
2414
+
2415
+ # ignore exceptions, as there is nothing we can do at this point.
2416
+
2417
+ finally:
2418
+
2419
+ # trace.
2420
+ _logsi.LeaveMethod(SILevel.Debug)
2421
+
2422
+
2423
+ def _RaiseDeviceAdded(
2424
+ self,
2425
+ device:SpotifyConnectDevice
2426
+ ) -> None:
2427
+ """
2428
+ Raises the `DeviceAdded` event.
2429
+
2430
+ Args:
2431
+ device (SpotifyConnectDevice):
2432
+ Spotify Connect device that was added.
2433
+ """
2434
+ try:
2435
+
2436
+ # raise event.
2437
+ args:SpotifyConnectDeviceEventArgs = SpotifyConnectDeviceEventArgs(device)
2438
+ self.DeviceAdded(self, args)
2439
+
2440
+ except Exception as ex:
2441
+
2442
+ # ignore exceptions.
2443
+ pass
2444
+
2445
+
2446
+ @abstractmethod
2447
+ def OnDeviceAdded(
2448
+ self,
2449
+ sender:object,
2450
+ e:SpotifyConnectDeviceEventArgs
2451
+ ) -> None:
2452
+ """
2453
+ Method that will handle the `DeviceAdded` event.
2454
+ Inheriting classes can override this method to handle the event.
2455
+
2456
+ Args:
2457
+ sender (object):
2458
+ The object which fired the event.
2459
+ e (SpotifyConnectDeviceEventArgs):
2460
+ Arguments that contain detailed information related to the event.
2461
+
2462
+ This event is fired when a Spotify Connect device has been added to the
2463
+ SpotifyConnectDevices collection.
2464
+
2465
+ This method will be called in a thread-safe manner, as the caller is using
2466
+ the `SpotifyConnectDevices_RLock` object to control access.
2467
+ """
2468
+ pass
2469
+
2470
+
2471
+ def _RaiseDeviceRemoved(
2472
+ self,
2473
+ device:SpotifyConnectDevice
2474
+ ) -> None:
2475
+ """
2476
+ Raises the `DeviceRemoved` event.
2477
+
2478
+ Args:
2479
+ device (SpotifyConnectDevice):
2480
+ Spotify Connect device that was removed.
2481
+ """
2482
+ try:
2483
+
2484
+ # raise event.
2485
+ args:SpotifyConnectDeviceEventArgs = SpotifyConnectDeviceEventArgs(device)
2486
+ self.DeviceRemoved(self, args)
2487
+
2488
+ except Exception as ex:
2489
+
2490
+ # ignore exceptions.
2491
+ pass
2492
+
2493
+
2494
+ @abstractmethod
2495
+ def OnDeviceRemoved(
2496
+ self,
2497
+ sender:object,
2498
+ e:SpotifyConnectDeviceEventArgs
2499
+ ) -> None:
2500
+ """
2501
+ Method that will handle the `DeviceRemoved` event.
2502
+ Inheriting classes can override this method to handle the event.
2503
+
2504
+ Args:
2505
+ sender (object):
2506
+ The object which fired the event.
2507
+ e (SpotifyConnectDeviceEventArgs):
2508
+ Arguments that contain detailed information related to the event.
2509
+
2510
+ This event is fired when a Spotify Connect device has been removed from the
2511
+ SpotifyConnectDevices collection.
2512
+
2513
+ This method will be called in a thread-safe manner, as the caller is using
2514
+ the `SpotifyConnectDevices_RLock` object to control access.
2515
+ """
2516
+ pass
2517
+
2518
+
2519
+ def _RaiseDeviceUpdated(
2520
+ self,
2521
+ device:SpotifyConnectDevice
2522
+ ) -> None:
2523
+ """
2524
+ Raises the `DeviceUpdated` event.
2525
+
2526
+ Args:
2527
+ device (SpotifyConnectDevice):
2528
+ Spotify Connect device that was updated.
2529
+ """
2530
+ try:
2531
+
2532
+ # raise event.
2533
+ args:SpotifyConnectDeviceEventArgs = SpotifyConnectDeviceEventArgs(device)
2534
+ self.DeviceUpdated(self, args)
2535
+
2536
+ except Exception as ex:
2537
+
2538
+ # ignore exceptions.
2539
+ pass
2540
+
2541
+
2542
+ @abstractmethod
2543
+ def OnDeviceUpdated(
2544
+ self,
2545
+ sender:object,
2546
+ e:SpotifyConnectDeviceEventArgs
2547
+ ) -> None:
2548
+ """
2549
+ Method that will handle the `DeviceUpdated` event.
2550
+ Inheriting classes can override this method to handle the event.
2551
+
2552
+ Args:
2553
+ sender (object):
2554
+ The object which fired the event.
2555
+ e (SpotifyConnectDeviceEventArgs):
2556
+ Arguments that contain detailed information related to the event.
2557
+
2558
+ This event is fired when a Spotify Connect device has been updated in the
2559
+ SpotifyConnectDevices collection.
2560
+
2561
+ This method will be called in a thread-safe manner, as the caller is using
2562
+ the `SpotifyConnectDevices_RLock` object to control access.
2563
+ """
2564
+ pass