vsslctrl 0.1.0.dev1__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.
vsslctrl/track.py ADDED
@@ -0,0 +1,337 @@
1
+ import logging
2
+ import base64
3
+ from . import zone
4
+ from typing import Dict, Union
5
+ from urllib.parse import urlparse, urlunparse
6
+ from .data_structure import VsslIntEnum, ZoneDataClass, TrackMetadataExtKeys
7
+
8
+
9
+ class TrackMetadata(ZoneDataClass):
10
+ AIRPLAY_COVER_ART = "coverart.jpg"
11
+
12
+ class Keys:
13
+ TITLE = "title"
14
+ ALBUM = "album"
15
+ ARTIST = "artist"
16
+ GENRE = "genre"
17
+ DURATION = "duration"
18
+ PROGRESS = "progress"
19
+ COVER_ART_URL = "cover_art_url"
20
+ SOURCE = "source"
21
+ URL = "url"
22
+
23
+ #
24
+ # Stream Sources
25
+ #
26
+ # DO NOT CHANGE - VSSL Defined
27
+ #
28
+ class Sources(VsslIntEnum):
29
+ NOT_STREAMING = 0
30
+ AIRPLAY = 1
31
+ SPOTIFY = 4
32
+ TUNEIN = 9
33
+ ANALOG_IN = 15
34
+ APPLE_DEVICE = 16
35
+ DIRECT_URL = 17 # e.g play_url
36
+ BLUETOOTH = 19
37
+ TIDAL = 22
38
+ GOOGLECAST = 24
39
+ EXTERNAL = 25
40
+
41
+ #
42
+ # Transport Events
43
+ #
44
+ class Events:
45
+ PREFIX = "track."
46
+ CHANGE = PREFIX + "change"
47
+ UPDATES = PREFIX + "updates"
48
+ TITLE_CHANGE = PREFIX + "title_change"
49
+ ALBUM_CHANGE = PREFIX + "album_change"
50
+ ARTIST_CHANGE = PREFIX + "artist_change"
51
+ GENRE_CHANGE = PREFIX + "genre_change"
52
+ DURATION_CHANGE = PREFIX + "duration_change"
53
+ PROGRESS_CHANGE = PREFIX + "progress_change"
54
+ COVER_ART_URL_CHANGE = PREFIX + "cover_art_url_change"
55
+ SOURCE_CHANGE = PREFIX + "source_change"
56
+ URL_CHANGE = PREFIX + "url_change"
57
+
58
+ DEFAULTS = {
59
+ Keys.TITLE: None,
60
+ Keys.ALBUM: None,
61
+ Keys.ARTIST: None,
62
+ Keys.GENRE: None,
63
+ Keys.DURATION: 0,
64
+ Keys.PROGRESS: 0,
65
+ Keys.COVER_ART_URL: None,
66
+ Keys.SOURCE: Sources.NOT_STREAMING,
67
+ Keys.URL: None,
68
+ }
69
+
70
+ KEY_MAP = {
71
+ TrackMetadataExtKeys.DURATION: Keys.DURATION,
72
+ TrackMetadataExtKeys.TITLE: Keys.TITLE,
73
+ TrackMetadataExtKeys.ALBUM: Keys.ALBUM,
74
+ TrackMetadataExtKeys.ARTIST: Keys.ARTIST,
75
+ TrackMetadataExtKeys.COVER_ART_URL: Keys.COVER_ART_URL,
76
+ TrackMetadataExtKeys.SOURCE: Keys.SOURCE,
77
+ TrackMetadataExtKeys.GENRE: Keys.GENRE,
78
+ TrackMetadataExtKeys.URL: Keys.URL,
79
+ }
80
+
81
+ def __init__(self, zone: "zone.Zone"):
82
+ self.zone = zone
83
+
84
+ self._title: str = None
85
+ self._album: str = None
86
+ self._artist: str = None
87
+ self._genre: str = None
88
+ self._duration: int = 0
89
+ self._progress: int = 0
90
+ self._cover_art_url: str = None
91
+ self._source = self.Sources.NOT_STREAMING
92
+ self._url: str = None
93
+
94
+ def as_dict(self):
95
+ dic = super().as_dict()
96
+ dic["progress_display"] = self.progress_display
97
+ return dic
98
+
99
+ #
100
+ # VSSL doenst clear some vars on stopping of the stream, so we will do it
101
+ #
102
+ # Doing this will fire the change events on the bus. Instead of conditionally
103
+ # using the getter functions since we want the changes to be propogated
104
+ #
105
+ # VSSL has a happit of caching the last song played, so we need to clear it
106
+ #
107
+ def set_defaults(self):
108
+ for key, default_value in self.DEFAULTS.items():
109
+ self._update_property(key, default_value, True)
110
+
111
+ #
112
+ # Updade a property and emit and event if changed
113
+ #
114
+ # Transport state is ignored when part of a group for its initial pull from master
115
+ #
116
+ def _update_property(self, key: str, new_value, ignore_transport_state=False):
117
+ # Default if stopped
118
+ if self.zone.transport.is_stopped and not ignore_transport_state:
119
+ new_value = self.DEFAULTS[key]
120
+
121
+ if getattr(self, key) != new_value:
122
+ setattr(self, f"_{key}", new_value) # set private var
123
+ new_set_value = getattr(self, key)
124
+
125
+ self.zone._event_publish(
126
+ getattr(self.Events, f"{key.upper()}_CHANGE"), new_set_value
127
+ )
128
+ self.zone._event_publish(self.Events.CHANGE, (key, new_set_value))
129
+
130
+ #
131
+ # Update the track properties from a group master when part of a group.
132
+ # This is handled via the Eventbus
133
+ #
134
+ async def _update_property_from_group_master(
135
+ self, data: Dict[str, int], *args, **kwargs
136
+ ) -> None:
137
+ if hasattr(self, data[0]):
138
+ setattr(self, data[0], data[1])
139
+
140
+ #
141
+ # Update from a JSON dict passed
142
+ #
143
+ def _map_response_dict(self, track_data: Dict[str, int]) -> None:
144
+ """Ignore the track data if zone is part of a group.
145
+
146
+ VSSL has a happit of caching old track meta when part of a group
147
+
148
+ """
149
+ if not self.zone.group.is_member:
150
+ for track_data_key, metadata_key in self.KEY_MAP.items():
151
+ if track_data_key in track_data:
152
+ setattr(self, metadata_key, track_data[track_data_key])
153
+
154
+ """ TODO!
155
+
156
+ Now we added the child, lets propage the track to the new member.
157
+ This only needs to happen once, as after wards the track data will
158
+ be updated by the event bus
159
+
160
+ Sometime, the VSSL responses with old cached track metadata, on the member zones
161
+ but generally it responds with a BrowseView when its a member of a group
162
+
163
+ When a group is created, the child will get the group index first (generally this will be the same
164
+ as the index_id) then its transport state will be
165
+ updated on the VSSL side.
166
+ When the transport state is changed, the zone will request the track meta. VSSL will respond
167
+ with the last cached metadata from the zone and not the correct meta from the current zone master.
168
+
169
+ """
170
+
171
+ def _pull_from_zone(self, zone: int) -> None:
172
+ master = self.zone.vssl.get_zone(zone)
173
+
174
+ if not master:
175
+ self.zone._log_error(
176
+ f"Zone {zone} was not avaiable on VSSL, maybe we are not managing it or it has not be initialised yet"
177
+ )
178
+ return
179
+
180
+ for key, default_value in self.DEFAULTS.items():
181
+ if hasattr(master.track, key):
182
+ self._update_property(key, getattr(master.track, key), True)
183
+
184
+ #
185
+ # Track Title
186
+ #
187
+ @property
188
+ def title(self) -> str:
189
+ return self._title
190
+
191
+ @title.setter
192
+ def title(self, value: str) -> None:
193
+ self._update_property(self.Keys.TITLE, value)
194
+
195
+ #
196
+ # Track Album
197
+ #
198
+ @property
199
+ def album(self) -> str:
200
+ return self._album
201
+
202
+ @album.setter
203
+ def album(self, value: str) -> None:
204
+ self._update_property(self.Keys.ALBUM, value)
205
+
206
+ #
207
+ # Track Artist
208
+ #
209
+ @property
210
+ def artist(self) -> str:
211
+ return self._artist
212
+
213
+ @artist.setter
214
+ def artist(self, value: str) -> None:
215
+ self._update_property(self.Keys.ARTIST, value)
216
+
217
+ #
218
+ # Track Genre
219
+ #
220
+ @property
221
+ def genre(self) -> str:
222
+ return self._genre
223
+
224
+ @genre.setter
225
+ def genre(self, value: str) -> None:
226
+ self._update_property(self.Keys.GENRE, value)
227
+
228
+ #
229
+ # Track Duration
230
+ #
231
+ @property
232
+ def duration(self) -> int:
233
+ return self._duration
234
+
235
+ @duration.setter
236
+ def duration(self, value: int) -> None:
237
+ self._update_property(self.Keys.DURATION, value)
238
+
239
+ #
240
+ # Track Duration
241
+ #
242
+ @property
243
+ def progress(self) -> int:
244
+ return self._progress
245
+
246
+ @progress.setter
247
+ def progress(self, value: int) -> None:
248
+ self._update_property(self.Keys.PROGRESS, value)
249
+
250
+ @property
251
+ def progress_display(self):
252
+ milliseconds = self.progress if self.progress != None else 0
253
+
254
+ seconds, milliseconds = divmod(milliseconds, 1000)
255
+ minutes, seconds = divmod(seconds, 60)
256
+ hours, minutes = divmod(minutes, 60)
257
+
258
+ formatted_time = ""
259
+
260
+ if hours > 0:
261
+ formatted_time += "{}:".format(hours)
262
+
263
+ formatted_time += (
264
+ "{:02}:{:02}".format(minutes, seconds)
265
+ if hours > 0
266
+ else "{}:{:02}".format(minutes, seconds)
267
+ )
268
+
269
+ return formatted_time
270
+
271
+ #
272
+ # Track Cover Art
273
+ #
274
+ @property
275
+ def cover_art_url(self) -> str:
276
+ return self._cover_art_url
277
+
278
+ @cover_art_url.setter
279
+ def cover_art_url(self, value: str) -> None:
280
+ self._update_property(self.Keys.COVER_ART_URL, self.add_host_if_not_url(value))
281
+
282
+ def add_host_if_not_url(self, url):
283
+ """Using airplay, the cover_art will return a relative url of "coverart.jpg", so we will
284
+ need to add the host.
285
+
286
+ """
287
+ if not url:
288
+ return None
289
+ # Parse the URL
290
+ parsed_url = urlparse(url)
291
+ default_host = f"http://{self.zone.host}"
292
+ query = ""
293
+
294
+ # Check if the scheme and netloc are missing
295
+ if not parsed_url.scheme or not parsed_url.netloc:
296
+ # Add the default host if it's not a URL
297
+ # Join the default host with the original URL path
298
+ if not default_host.endswith("/") and not url.startswith("/"):
299
+ default_host += "/"
300
+
301
+ # Lets add a query string to the coverart.jpg URL so the latest coverart
302
+ # will always be downloaded if the album changes
303
+ if url == self.AIRPLAY_COVER_ART and self.album:
304
+ encoded_album = (
305
+ base64.urlsafe_b64encode(self.album.encode()).decode().rstrip("=")
306
+ )[:6]
307
+ query = f"?album={encoded_album}"
308
+
309
+ return default_host + url + query
310
+
311
+ # Return the original URL if it is already a valid URL
312
+ return url
313
+
314
+ #
315
+ # Track Source
316
+ #
317
+ @property
318
+ def source(self) -> Sources:
319
+ return self._source
320
+
321
+ @source.setter
322
+ def source(self, src: Sources) -> None:
323
+ if self.Sources.is_valid(src):
324
+ self._update_property(self.Keys.SOURCE, self.Sources(src))
325
+ else:
326
+ self.zone._log_error(f"TrackMetadata.Sources {src} doesnt exist")
327
+
328
+ #
329
+ # Track URL
330
+ #
331
+ @property
332
+ def url(self) -> str:
333
+ return self._url
334
+
335
+ @url.setter
336
+ def url(self, value: str) -> None:
337
+ self._update_property(self.Keys.URL, value)
vsslctrl/transport.py ADDED
@@ -0,0 +1,242 @@
1
+ import logging
2
+ from typing import Dict, Union
3
+ from .track import TrackMetadata
4
+ from .utils import clamp_volume
5
+ from .data_structure import VsslIntEnum, ZoneDataClass
6
+
7
+
8
+ class ZoneTransport(ZoneDataClass):
9
+ class States(VsslIntEnum):
10
+ """Transport States
11
+
12
+ DO NOT CHANGE - VSSL Defined
13
+ """
14
+
15
+ STOP = 0
16
+ PLAY = 1
17
+ PAUSE = 2
18
+
19
+ class Repeat(VsslIntEnum):
20
+ """Repeat
21
+
22
+ DO NOT CHANGE - VSSL Defined
23
+ """
24
+
25
+ OFF = 0 # No repeat
26
+ ONE = 1 # Repeat single track
27
+ ALL = 2 # Repeat queue / playlist / album
28
+
29
+ class Events:
30
+ """Transport Events"""
31
+
32
+ PREFIX = "zone.transport."
33
+ STATE_CHANGE = PREFIX + "state_change"
34
+ STATE_CHANGE_STOP = PREFIX + "state_change.stop"
35
+ STATE_CHANGE_PLAY = PREFIX + "state_change.play"
36
+ STATE_CHANGE_PAUSE = PREFIX + "state_change.pause"
37
+
38
+ IS_REPEAT_CHANGE = PREFIX + "is_repeat_change"
39
+ IS_SHUFFLE_CHANGE = PREFIX + "is_shuffle_change"
40
+ HAS_NEXT_CHANGE = PREFIX + "has_next_change"
41
+ HAS_PREV_CHANGE = PREFIX + "has_prev_change"
42
+
43
+ DEFAULTS = {
44
+ "state": States.STOP,
45
+ "is_repeat": Repeat.OFF,
46
+ "is_shuffle": False,
47
+ "has_next": False,
48
+ "has_prev": False,
49
+ }
50
+
51
+ KEY_MAP = {
52
+ "Next": "has_next",
53
+ "Prev": "has_prev",
54
+ "Shuffle": "is_shuffle",
55
+ "Repeat": "is_repeat",
56
+ }
57
+
58
+ def __init__(self, zone: "zone.Zone"):
59
+ self.zone = zone
60
+
61
+ self._state = self.States.STOP
62
+ self._is_repeat = self.Repeat.OFF
63
+ self._is_shuffle = False
64
+ self._has_next = False
65
+ self._has_prev = False
66
+
67
+ def set_defaults(self):
68
+ """VSSL doenst clear some vars on stopping of the stream, so we will do it
69
+
70
+ Doing this will fire the change events on the bus. Instead of conditionally
71
+ using the getter functions since we want the changes to be propogated
72
+
73
+ VSSL has a happit of caching the last song played, so we need to clear it
74
+ """
75
+ for key, default_value in self.DEFAULTS.items():
76
+ set_func = f"_set_{key}"
77
+ if hasattr(self, set_func):
78
+ getattr(self, set_func)(default_value)
79
+
80
+ def _default_on_state_stop(self, value, default=None):
81
+ """Set value based on transport state."""
82
+ return default if self.state == self.States.STOP else value
83
+
84
+ def _map_response_dict(self, track_data: Dict[str, int]) -> None:
85
+ """Update from a JSON dict"""
86
+ for track_data_key, metadata_key in self.KEY_MAP.items():
87
+ if track_data_key in track_data:
88
+ self._set_property(metadata_key, track_data[track_data_key])
89
+
90
+ def _set_bool_property(self, prop_key: str, new_value: int, event: str) -> None:
91
+ """Set a bool property"""
92
+ new_value = self._default_on_state_stop(
93
+ not not new_value, self.DEFAULTS[prop_key]
94
+ )
95
+ cur_value = getattr(self, prop_key)
96
+
97
+ if cur_value != new_value:
98
+ setattr(self, f"_{prop_key}", new_value)
99
+ return True
100
+
101
+ #
102
+ # Transport State
103
+ #
104
+ @property
105
+ def state(self):
106
+ return self._state
107
+
108
+ @state.setter
109
+ def state(self, state: int):
110
+ if self.States.is_valid(state):
111
+ state = self.States(state)
112
+ self.zone.api_alpha.request_action_3D(state)
113
+
114
+ # Changing state wont work if we are part of a group
115
+ # So if we are wanting to stop, leave the group
116
+ if state == self.States.STOP and self.zone.group.is_member:
117
+ self.zone.group.leave()
118
+ else:
119
+ self.zone._log_error(f"ZoneTransport.States {state} doesnt exist")
120
+
121
+ def _set_state(self, state: int):
122
+ if self.state != state:
123
+ if self.States.is_valid(state):
124
+ self._state = self.States(state)
125
+ self.zone._event_publish(
126
+ getattr(self.Events, f"STATE_CHANGE_{self.state.name.upper()}"),
127
+ True,
128
+ )
129
+ return True
130
+ else:
131
+ self.zone._log_warning(f"ZoneTransport.States {state} doesnt exist")
132
+
133
+ #
134
+ # Transport Commands
135
+ #
136
+ def play(self):
137
+ self.state = self.States.PLAY
138
+
139
+ def stop(self):
140
+ """note: stopping a stream will disconnect the client as will pausing an Airplay stream.
141
+
142
+ note2: streams (Airplay, Chromecast, Spotify.Connect) have higher priority than analog inputs.
143
+ Therefore, if both input types are playing to a zone then the higher priority will play. Likewise,
144
+ if both types of input are playing to a zone and the stream is stopped then the zone will switch
145
+ to playing the lower priority content.
146
+
147
+ ref: https://vssl.gitbook.io/vssl-rest-api/zone-control/play-control
148
+ """
149
+ self.state = self.States.STOP
150
+
151
+ def pause(self):
152
+ self.state = self.States.PAUSE
153
+
154
+ #
155
+ # Transport States
156
+ #
157
+ @property
158
+ def is_playing(self):
159
+ return self.state == self.States.PLAY
160
+
161
+ @property
162
+ def is_stopped(self):
163
+ return self.state == self.States.STOP
164
+
165
+ @property
166
+ def is_paused(self):
167
+ return self.state == self.States.PAUSE
168
+
169
+ #
170
+ # Track Control
171
+ #
172
+ def next(self):
173
+ self.zone.api_bravo.request_action_40_next()
174
+
175
+ def prev(self):
176
+ self.zone.api_bravo.request_action_40_prev()
177
+
178
+ def back(self):
179
+ self.prev()
180
+ self.prev()
181
+
182
+ #
183
+ # Is the next button enabled
184
+ #
185
+ @property
186
+ def has_next(self):
187
+ return self._has_next
188
+
189
+ @has_next.setter
190
+ def set_has_next(self, val: int):
191
+ pass # read-only
192
+
193
+ def _set_has_next(self, val: int):
194
+ return self._set_bool_property("has_next", val, self.Events.HAS_NEXT_CHANGE)
195
+
196
+ #
197
+ # Track Prev_flag
198
+ #
199
+ @property
200
+ def has_prev(self):
201
+ return self._has_prev
202
+
203
+ @has_prev.setter
204
+ def has_prev(self, val: int):
205
+ pass # read-only
206
+
207
+ def _set_has_prev(self, val: int):
208
+ return self._set_bool_property("has_prev", val, self.Events.HAS_PREV_CHANGE)
209
+
210
+ #
211
+ # Track Shuffle
212
+ #
213
+ @property
214
+ def is_shuffle(self):
215
+ return self._is_shuffle
216
+
217
+ @is_shuffle.setter
218
+ def is_shuffle(self, val: int):
219
+ pass # read-only
220
+
221
+ def _set_is_shuffle(self, val: int):
222
+ return self._set_bool_property("is_shuffle", val, self.Events.IS_SHUFFLE_CHANGE)
223
+
224
+ #
225
+ # Track Repeat
226
+ #
227
+ @property
228
+ def is_repeat(self):
229
+ return self._is_repeat
230
+
231
+ @is_repeat.setter
232
+ def is_repeat(self, val: int):
233
+ pass # read-only
234
+
235
+ def _set_is_repeat(self, val: int):
236
+ val = self._default_on_state_stop(val, self.DEFAULTS["is_repeat"])
237
+ if self.is_repeat != val:
238
+ if self.Repeat.is_valid(val):
239
+ self._is_repeat = self.Repeat(val)
240
+ return True
241
+ else:
242
+ self.zone._log_error(f"ZoneTransport.Repeat {val} doesnt exist")
vsslctrl/utils.py ADDED
@@ -0,0 +1,75 @@
1
+ import asyncio
2
+
3
+
4
+ #
5
+ # Hex to Int
6
+ #
7
+ def hex_to_int(str: str, base: int = 16):
8
+ return int(str, base)
9
+
10
+
11
+ #
12
+ # Clamp Volume
13
+ #
14
+ def clamp_volume(vol: int):
15
+ return max(0, min(int(vol), 100))
16
+
17
+
18
+ #
19
+ # Cancel a task if it exsists
20
+ #
21
+ def cancel_task(task: asyncio.Task):
22
+ if isinstance(task, asyncio.Task) and not task.done():
23
+ try:
24
+ task.cancel()
25
+ except asyncio.CancelledError:
26
+ pass
27
+
28
+
29
+ #
30
+ # Groups dicts by property
31
+ #
32
+ def group_list_by_property(input_list, property_key):
33
+ grouped_dict = {}
34
+ for item in input_list:
35
+ property_value = item.get(property_key)
36
+ if property_value is not None:
37
+ grouped_dict.setdefault(property_value, []).append(item)
38
+ return grouped_dict
39
+
40
+
41
+ #
42
+ # Repeat Timer
43
+ #
44
+ class RepeatTimer:
45
+ def __init__(self, interval, function, start_delay=False, *args, **kwargs):
46
+ self.interval = interval
47
+ self.function = function
48
+ self.start_delay = start_delay
49
+ self.args = args
50
+ self.kwargs = kwargs
51
+ self.task = None
52
+ self.running = False
53
+
54
+ async def _run(self):
55
+ while self.running:
56
+ if self.start_delay:
57
+ await asyncio.sleep(self.interval)
58
+
59
+ if asyncio.iscoroutinefunction(self.function):
60
+ await self.function(*self.args, **self.kwargs)
61
+ else:
62
+ self.function(*self.args, **self.kwargs)
63
+
64
+ if not self.start_delay:
65
+ await asyncio.sleep(self.interval)
66
+
67
+ def start(self):
68
+ if not self.running:
69
+ self.running = True
70
+ self.task = asyncio.create_task(self._run())
71
+
72
+ def cancel(self):
73
+ if self.running:
74
+ self.running = False
75
+ self.task.cancel()