hamcws 0.0.4__tar.gz

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.
hamcws-0.0.4/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Matt Khan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
hamcws-0.0.4/PKG-INFO ADDED
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.1
2
+ Name: hamcws
3
+ Version: 0.0.4
4
+ Summary: homeassistant friendly wrapper for J River MCWS
5
+ Home-page: http://github.com/3ll3d00d/hamcws
6
+ License: MIT
7
+ Author: 3ll3d00d
8
+ Author-email: mattkhan+hamcws@gmail.com
9
+ Requires-Python: >=3.11,<3.12
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Requires-Dist: aiohttp (>=3.9.1,<4.0.0)
14
+ Project-URL: Repository, http://github.com/3ll3d00d/hamcws
15
+ Description-Content-Type: text/markdown
16
+
17
+ # hamcws
18
+
19
+ A python based async wrapper around J River [MCWS](https://wiki.jriver.com/index.php/Web_Service_Interface) designed for use
20
+ by a [Home Assistant](https://www.home-assistant.io/) integration.
21
+
22
+
hamcws-0.0.4/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # hamcws
2
+
3
+ A python based async wrapper around J River [MCWS](https://wiki.jriver.com/index.php/Web_Service_Interface) designed for use
4
+ by a [Home Assistant](https://www.home-assistant.io/) integration.
5
+
@@ -0,0 +1 @@
1
+ from .hamcws import get_mcws_connection, MediaServer, Zone, CannotConnectError, InvalidAuthError
@@ -0,0 +1,329 @@
1
+ """Implementation of a MCWS inteface."""
2
+ from typing import Tuple, List
3
+ from xml.etree import ElementTree as et
4
+
5
+ from aiohttp import ClientSession, ClientResponseError, BasicAuth
6
+
7
+
8
+ class Zone:
9
+ def __init__(self, content: dict, zone_index: int, active_zone_id: int):
10
+ self.index = zone_index
11
+ self.id = int(content[f"ZoneID{self.index}"])
12
+ self.name = content[f"ZoneName{self.index}"]
13
+ self.guid = content[f"ZoneGUID{self.index}"]
14
+ self.is_dlna = True if (content[f"ZoneDLNA{self.index}"] == "1") else False
15
+ self.active = self.id == active_zone_id
16
+
17
+ def __identifier(self):
18
+ if self.id is not None:
19
+ return self.id
20
+ if self.name is not None:
21
+ return self.name
22
+ if self.index is not None:
23
+ return self.index
24
+
25
+ def __identifier_type(self):
26
+ if self.id is not None:
27
+ return "ID"
28
+ if self.name is not None:
29
+ return "Name"
30
+ if self.index is not None:
31
+ return "Index"
32
+
33
+ def as_query_params(self) -> dict:
34
+ return {
35
+ 'Zone': self.__identifier(),
36
+ 'ZoneType': self.__identifier_type()
37
+ }
38
+
39
+ def __str__(self):
40
+ return self.name
41
+
42
+
43
+ def get_mcws_connection(host: str, port: int, username: str | None = None, password: str | None = None,
44
+ ssl: bool = False, timeout: int = 5, session: ClientSession = None):
45
+ """Returns a MCWS connection."""
46
+ return MediaServerConnection(host, port, username, password, ssl, timeout, session)
47
+
48
+
49
+ class MediaServerConnection:
50
+ """A connection to MCWS."""
51
+
52
+ def __init__(self, host: str, port: int, username: str | None, password: str | None, ssl: bool, timeout: int,
53
+ session: ClientSession | None):
54
+ self._session = session
55
+ self._close_session_on_exit = False
56
+ if self._session is None:
57
+ self._session = ClientSession()
58
+ self._close_session_on_exit = True
59
+
60
+ self._timeout = timeout
61
+ self._auth = BasicAuth(username, password) if username is not None else None
62
+ self._base_url = f"http{'s' if ssl else ''}://{host}:{port}/MCWS/v1"
63
+
64
+ async def get(self, path: str, params: dict | None = None) -> Tuple[bool, dict]:
65
+ async with self._session.get(f'{self._base_url}/{path}', params=params, timeout=self._timeout,
66
+ auth=self._auth) as resp:
67
+ try:
68
+ resp.raise_for_status()
69
+ return self.__to_dict(await resp.text())
70
+ except ClientResponseError as e:
71
+ if e.status == 401:
72
+ raise InvalidAuthError from e
73
+ else:
74
+ raise CannotConnectError from e
75
+
76
+ async def close(self):
77
+ """Close the connection if necessary."""
78
+ if self._close_session_on_exit and self._session is not None:
79
+ await self._session.close()
80
+ self._session = None
81
+ self._close_session_on_exit = False
82
+
83
+ @staticmethod
84
+ def __to_dict(content: str, try_int_cast: bool = False) -> Tuple[bool, dict]:
85
+ """
86
+ Converts the relatively unstructured XML responses from MCWS into a dictionary with a flag to indicate
87
+ if the response was "OK".
88
+ """
89
+ result: dict = {}
90
+ root = et.fromstring(content)
91
+ for child in root:
92
+ result[child.attrib["Name"]] = child.text
93
+ if try_int_cast:
94
+ for key in result:
95
+ if key:
96
+ try:
97
+ result[key] = int(result[key])
98
+ except ValueError:
99
+ pass # passing is ok: If untranslatable, leave alone
100
+ return root.attrib['Status'] == 'OK', result
101
+
102
+
103
+ class MediaServer:
104
+ """A high level interface for MCWS."""
105
+
106
+ def __init__(self, connection: MediaServerConnection):
107
+ self._conn = connection
108
+
109
+ async def close(self):
110
+ await self._conn.close()
111
+
112
+ async def can_connect(self) -> bool:
113
+ """ returns true if server allows the connection. """
114
+ ok, resp = await self._conn.get('Authenticate')
115
+ return 'Token' in resp.keys()
116
+
117
+ async def get_zones(self) -> List[Zone]:
118
+ """ all known zones """
119
+ ok, resp = await self._conn.get("Playback/Zones")
120
+ num_zones = int(resp["NumberZones"])
121
+ active_zone_id = resp['CurrentZoneID']
122
+ return [Zone(resp, i, active_zone_id) for i in range(num_zones)]
123
+
124
+ async def get_playback_info(self, zone: Zone | None = None, extra_fields: List[str] | None = None) -> dict:
125
+ params = self.__zone_params(zone)
126
+ if extra_fields:
127
+ params['Fields'] = ';'.join(extra_fields)
128
+ ok, resp = await self._conn.get("Playback/Info")
129
+ if 'Playback Info' in extra_fields:
130
+ pass # parse into a nested dict
131
+ return resp
132
+
133
+ @staticmethod
134
+ def __zone_params(zone: Zone | None = None) -> dict:
135
+ return zone.as_query_params() if zone else {}
136
+
137
+ async def volume_up(self, step: float = 0.1, zone: Zone | None = None) -> None:
138
+ """Send volume up command."""
139
+ await self._conn.get('Playback/Volume', params={'Level': step, 'Relative': 1, **self.__zone_params(zone)})
140
+
141
+ async def volume_down(self, step: float = 0.1, zone: Zone | None = None) -> None:
142
+ """Send volume down command."""
143
+ await self._conn.get('Playback/Volume', params={'Level': f'{"-" if step > 0 else ""}{step}', 'Relative': 1,
144
+ **self.__zone_params(zone)})
145
+
146
+ async def set_volume_level(self, volume: float, zone: Zone | None = None) -> None:
147
+ """Set volume level, range 0-100."""
148
+ if volume < 0:
149
+ raise ValueError(f'{volume} not in range 0-100')
150
+ if volume > 100:
151
+ raise ValueError(f'{volume} not in range 0-100')
152
+ volume = volume / 100
153
+ await self._conn.get('Playback/Volume', params={'Level': volume, **self.__zone_params(zone)})
154
+
155
+ async def mute(self, mute: bool, zone: Zone | None = None) -> None:
156
+ """Send (un)mute command."""
157
+ await self._conn.get('Playback/Mute', params={'Set': '1' if mute else '0', **self.__zone_params(zone)})
158
+
159
+ async def play_pause(self, zone: Zone | None = None) -> None:
160
+ """Send play/pause command."""
161
+ await self._conn.get('Playback/PlayPause', params=self.__zone_params(zone))
162
+
163
+ async def play(self, zone: Zone | None = None) -> None:
164
+ """Send play command."""
165
+ await self._conn.get('Playback/Play', params=self.__zone_params(zone))
166
+
167
+ async def pause(self, zone: Zone | None = None) -> None:
168
+ """Send pause command."""
169
+ await self._conn.get('Playback/Pause', params=self.__zone_params(zone))
170
+
171
+ async def stop(self, zone: Zone | None = None) -> None:
172
+ """Send stop command."""
173
+ await self._conn.get('Playback/Stop', params=self.__zone_params(zone))
174
+
175
+ async def next_track(self, zone: Zone | None = None) -> None:
176
+ """Send next track command."""
177
+ await self._conn.get('Playback/Next', params=self.__zone_params(zone))
178
+
179
+ async def previous_track(self, zone: Zone | None = None) -> None:
180
+ """Send previous track command."""
181
+ # TODO does it go to the start of the current track?
182
+ await self._conn.get('Playback/Previous', params=self.__zone_params(zone))
183
+
184
+ async def media_seek(self, position: float, zone: Zone | None = None) -> None:
185
+ """seek to a specified position in ms."""
186
+ await self._conn.get('Playback/Position', params={'Position': position, **self.__zone_params(zone)})
187
+
188
+ async def play_item(self, item: str, zone: Zone | None = None) -> None:
189
+ await self._conn.get('Playback/PlayByKey', params={'Key': item, **self.__zone_params(zone)})
190
+
191
+ async def play_playlist(self, playlist_id: str, zone: Zone | None = None) -> None:
192
+ """Play the given playlist."""
193
+ await self._conn.get('Playback/PlayPlaylist', params={'Playlist': playlist_id, **self.__zone_params(zone)})
194
+
195
+ async def play_file(self, file: str, zone: Zone | None = None) -> None:
196
+ """Play the given file."""
197
+ await self._conn.get('Playback/PlayByFilename', params={'Filenames': file, **self.__zone_params(zone)})
198
+
199
+ async def set_shuffle(self, shuffle: bool, zone: Zone | None = None) -> None:
200
+ """Set shuffle mode, for the first player."""
201
+ await self._conn.get('Control/MCC', params={'Command': '10005', 'Parameter': '4' if shuffle else '3',
202
+ **self.__zone_params(zone)})
203
+
204
+ async def _add_item_to_playlist(self, item):
205
+ # await self._server.Playlist.Add(**{"playlistid": 0, "item": item})
206
+ raise NotImplementedError
207
+
208
+ async def add_song_to_playlist(self, song_id):
209
+ """Add song to default playlist (i.e. playlistid=0)."""
210
+ # await self._add_item_to_playlist({"songid": song_id})
211
+ raise NotImplementedError
212
+
213
+ async def add_album_to_playlist(self, album_id):
214
+ """Add album to default playlist (i.e. playlistid=0)."""
215
+ # await self._add_item_to_playlist({"albumid": album_id})
216
+ raise NotImplementedError
217
+
218
+ async def add_artist_to_playlist(self, artist_id):
219
+ """Add album to default playlist (i.e. playlistid=0)."""
220
+ # await self._add_item_to_playlist({"artistid": artist_id})
221
+ raise NotImplementedError
222
+
223
+ async def clear_playlist(self, zone: Zone | None = None):
224
+ """Clear default playlist."""
225
+ await self._conn.get('Playback/ClearPlaylist', params=self.__zone_params(zone))
226
+
227
+ async def get_artists(self, properties=None):
228
+ """Get artists list."""
229
+ # return await self._server.AudioLibrary.GetArtists(
230
+ # **_build_query(properties=properties)
231
+ # )
232
+ raise NotImplementedError
233
+
234
+ async def get_artist_details(self, artist_id=None, properties=None):
235
+ """Get artist details."""
236
+ # return await self._server.AudioLibrary.GetArtistDetails(
237
+ # **_build_query(artistid=artist_id, properties=properties)
238
+ # )
239
+ raise NotImplementedError
240
+
241
+ async def get_albums(self, artist_id=None, album_id=None, properties=None):
242
+ """Get albums list."""
243
+ # filter = {}
244
+ # if artist_id:
245
+ # filter["artistid"] = artist_id
246
+ # if album_id:
247
+ # filter["albumid"] = album_id
248
+ #
249
+ # return await self._server.AudioLibrary.GetAlbums(
250
+ # **_build_query(filter=filter, properties=properties)
251
+ # )
252
+ raise NotImplementedError
253
+
254
+ async def get_album_details(self, album_id, properties=None):
255
+ """Get album details."""
256
+ # return await self._server.AudioLibrary.GetAlbumDetails(
257
+ # **_build_query(albumid=album_id, properties=properties)
258
+ # )
259
+ raise NotImplementedError
260
+
261
+ async def get_songs(self, artist_id=None, album_id=None, properties=None):
262
+ """Get songs list."""
263
+ # filter = {}
264
+ # if artist_id:
265
+ # filter["artistid"] = artist_id
266
+ # if album_id:
267
+ # filter["albumid"] = album_id
268
+ #
269
+ # return await self._server.AudioLibrary.GetSongs(
270
+ # **_build_query(filter=filter, properties=properties)
271
+ # )
272
+ raise NotImplementedError
273
+
274
+ async def get_movies(self, properties=None):
275
+ """Get movies list."""
276
+ # return await self._server.VideoLibrary.GetMovies(
277
+ # **_build_query(properties=properties)
278
+ # )
279
+ raise NotImplementedError
280
+
281
+ async def get_movie_details(self, movie_id, properties=None):
282
+ """Get movie details."""
283
+ # return await self._server.VideoLibrary.GetMovieDetails(
284
+ # **_build_query(movieid=movie_id, properties=properties)
285
+ # )
286
+ raise NotImplementedError
287
+
288
+ async def get_seasons(self, tv_show_id, properties=None):
289
+ """Get seasons list."""
290
+ # return await self._server.VideoLibrary.GetSeasons(
291
+ # **_build_query(tvshowid=tv_show_id, properties=properties)
292
+ # )
293
+ raise NotImplementedError
294
+
295
+ async def get_season_details(self, season_id, properties=None):
296
+ """Get songs list."""
297
+ # return await self._server.VideoLibrary.GetSeasonDetails(
298
+ # **_build_query(seasonid=season_id, properties=properties)
299
+ # )
300
+ raise NotImplementedError
301
+
302
+ async def get_episodes(self, tv_show_id, season_id, properties=None):
303
+ """Get episodes list."""
304
+ # return await self._server.VideoLibrary.GetEpisodes(
305
+ # **_build_query(tvshowid=tv_show_id, season=season_id, properties=properties)
306
+ # )
307
+ raise NotImplementedError
308
+
309
+ async def get_tv_shows(self, properties=None):
310
+ """Get tv shows list."""
311
+ # return await self._server.VideoLibrary.GetTVShows(
312
+ # **_build_query(properties=properties)
313
+ # )
314
+ raise NotImplementedError
315
+
316
+ async def get_tv_show_details(self, tv_show_id=None, properties=None):
317
+ """Get songs list."""
318
+ # return await self._server.VideoLibrary.GetTVShowDetails(
319
+ # **_build_query(tvshowid=tv_show_id, properties=properties)
320
+ # )
321
+ raise NotImplementedError
322
+
323
+
324
+ class CannotConnectError(Exception):
325
+ """Exception to indicate an error in connection."""
326
+
327
+
328
+ class InvalidAuthError(Exception):
329
+ """Exception to indicate an error in authentication."""
@@ -0,0 +1,28 @@
1
+ [tool.poetry]
2
+ name = "hamcws"
3
+ version = "0.0.4"
4
+ description = "homeassistant friendly wrapper for J River MCWS"
5
+ authors = ["3ll3d00d <mattkhan+hamcws@gmail.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ homepage = "http://github.com/3ll3d00d/hamcws"
9
+ repository = "http://github.com/3ll3d00d/hamcws"
10
+ include = [
11
+ "LICENSE",
12
+ ]
13
+ packages = [
14
+ { include = "hamcws" }
15
+ ]
16
+
17
+ [tool.poetry.dependencies]
18
+ python = "~3.11"
19
+ aiohttp = "^3.9.1"
20
+
21
+ [tool.poetry.group.dev.dependencies]
22
+ pytest = "*"
23
+ pytest-aiohttp = "*"
24
+ pytest-cov = "*"
25
+
26
+ [build-system]
27
+ requires = ["poetry-core"]
28
+ build-backend = "poetry.core.masonry.api"