pax_global_header00006660000000000000000000000064137437573450014534gustar00rootroot0000000000000052 comment=9cf1dc08aba9e626f5fda6b9abc44f4368c13785 uvjustin-pyforked-daapd-9cf1dc0/000077500000000000000000000000001374375734500170065ustar00rootroot00000000000000uvjustin-pyforked-daapd-9cf1dc0/CHANGES.txt000066400000000000000000000031631374375734500206220ustar00rootroot00000000000000v0.1.0, 2020-02-18 -- Initial release. v0.1.1, 2020-02-19 -- Minor style changes. v0.1.2, 2020-02-19 -- Minor bug fixes. v0.1.3, 2020-04-15 -- Add helper function for post requests. Add queue manipulation functions and rename clear_playlist to clear_queue. Add basic auth for passworded connections. Improve websocket handler. Force number to int and bool to string for aiohttp params. v0.1.4, 2020-04-23 -- Add test_connection function Remove ForkedDaapdData class Remove redundant get functions Change some websocket handler log messages from error to warning v0.1.5, 2020-04-27 -- Add full_url method Add option for callback on websocket disconnection v0.1.6, 2020-05-05 -- Fix conversion of bool params Add get_pipes and get_playlists v0.1.7, 2020-05-11 -- Add hash function to get Machine ID Change test_connection function to return Machine ID on success v0.1.8, 2020-05-12 -- Remove hash function to get Machine ID Change test connection function to return Machine Name on success v0.1.9, 2020-05-20 -- Add None response handling to get_pipes and get_playlists Convert f-strings with no placeholders to strings v0.1.10, 2020-06-01 -- Change full_url to handle absolute url inputs v0.1.11, 2020-10-08 -- Add case for 403 error in test_connection Add library browsing functions Modify get_request to take paramsuvjustin-pyforked-daapd-9cf1dc0/LICENSE.txt000066400000000000000000000020771374375734500206370ustar00rootroot00000000000000MIT License Copyright (c) 2020 Justin Wong Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.uvjustin-pyforked-daapd-9cf1dc0/README.md000066400000000000000000000007461374375734500202740ustar00rootroot00000000000000pyforked-daapd Python Library =============================== version number: 0.1.11 author: Justin Wong Overview -------- A simple library to interface with a forked-daapd server. Installation / Usage -------------------- To install use pip: $ pip install pyforked-daapd Or clone the repo: $ git clone https://github.com/uvjustin/pyforked-daapd.git $ python setup.py install Contributing ------------ TBD Example ------- TBDuvjustin-pyforked-daapd-9cf1dc0/pyforked_daapd/000077500000000000000000000000001374375734500217625ustar00rootroot00000000000000uvjustin-pyforked-daapd-9cf1dc0/pyforked_daapd/__init__.py000066400000000000000000000354421374375734500241030ustar00rootroot00000000000000"""This library wraps the forked-daapd API for use with Home Assistant.""" __version__ = "0.1.11" import asyncio import concurrent import logging from urllib.parse import urljoin import aiohttp _LOGGER = logging.getLogger(__name__) class ForkedDaapdAPI: """Class for interfacing with forked-daapd API.""" def __init__(self, websession, ip_address, api_port, api_password): """Initialize the ForkedDaapdAPI object.""" self._ip_address = ip_address self._api_port = api_port self._websession = websession self._auth = ( aiohttp.BasicAuth(login="admin", password=api_password) if api_password else None ) self._api_password = api_password @staticmethod async def test_connection(websession, host, port, password): """Validate the user input.""" try: url = f"http://{host}:{port}/api/config" auth = ( aiohttp.BasicAuth(login="admin", password=password) if password else None ) # _LOGGER.debug("Trying to connect to %s with auth %s", url, auth) async with websession.get( url=url, auth=auth, timeout=aiohttp.ClientTimeout(total=5) ) as resp: json = await resp.json() # _LOGGER.debug("JSON %s", json) if json["websocket_port"] == 0: return ["websocket_not_enabled"] return ["ok", json["library_name"]] except ( aiohttp.ClientConnectionError, asyncio.TimeoutError, # pylint: disable=protected-access concurrent.futures._base.TimeoutError, # maybe related to https://github.com/aio-libs/aiohttp/issues/1207 aiohttp.InvalidURL, ): return ["wrong_host_or_port"] except (aiohttp.ClientResponseError, KeyError): if resp.status == 401: return ["wrong_password"] if resp.status == 403: return ["forbidden"] return ["wrong_server_type"] finally: pass return ["unknown_error"] async def get_request(self, endpoint, params=None) -> dict: """Get request from endpoint.""" url = f"http://{self._ip_address}:{self._api_port}/api/{endpoint}" # get params not working so add params ourselves if params: url += "?" + "&".join(f"{k}={v}" for k, v in params.items()) try: async with self._websession.get(url=url, auth=self._auth) as resp: json = await resp.json() except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Can not get %s with params %s", url, params) return None return json async def put_request(self, endpoint, params=None, json=None) -> int: """Put request to endpoint.""" url = f"http://{self._ip_address}:{self._api_port}/api/{endpoint}" _LOGGER.debug( "PUT request to %s with params %s, json payload %s.", url, params, json ) if params: # convert bool to text params = { key: str(value).lower() if isinstance(value, bool) else value for key, value in params.items() } response = await self._websession.put( url=url, params=params, json=json, auth=self._auth ) return response.status async def post_request(self, endpoint, params=None, json=None) -> int: """Post request to endpoint.""" url = f"http://{self._ip_address}:{self._api_port}/api/{endpoint}" _LOGGER.debug( "POST request to %s with params %s, data payload %s.", url, params, json ) if params: # convert bool to text params = { key: str(value).lower() if isinstance(value, bool) else value for key, value in params.items() } response = await self._websession.post( url=url, params=params, json=json, auth=self._auth ) return response.status async def start_websocket_handler( self, ws_port, event_types, update_callback, websocket_reconnect_time, disconnected_callback=None, ) -> None: """Websocket handler daemon.""" _LOGGER.debug("Starting websocket handler") if ws_port == 0: _LOGGER.error( "This library requires a forked-daapd instance with websocket enabled." ) raise Exception("forked-daapd websocket not enabled.") url = f"http://{self._ip_address}:{ws_port}/" while True: try: async with self._websession.ws_connect( url, protocols=("notify",), heartbeat=websocket_reconnect_time ) as websocket: await update_callback( event_types ) # send all requested updates once await websocket.send_json(data={"notify": event_types}) _LOGGER.debug("Sent notify to %s", url) async for msg in websocket: updates = msg.json()["notify"] _LOGGER.debug("Message JSON: %s", msg.json()) await update_callback(updates) _LOGGER.debug("Done with callbacks %s", updates) except (asyncio.TimeoutError, aiohttp.ClientError) as exception: _LOGGER.warning( "Can not connect to WebSocket at %s, will retry in %s seconds.", url, websocket_reconnect_time, ) _LOGGER.warning("Error %s", repr(exception)) if disconnected_callback: disconnected_callback() await asyncio.sleep(websocket_reconnect_time) continue async def start_playback(self) -> int: """Start playback.""" status = await self.put_request(endpoint="player/play") if status != 204: _LOGGER.debug("Unable to start playback.") return status async def pause_playback(self) -> int: """Pause playback.""" status = await self.put_request(endpoint="player/pause") if status != 204: _LOGGER.debug("Unable to pause playback.") return status async def stop_playback(self) -> int: """Stop playback.""" status = await self.put_request(endpoint="player/stop") if status != 204: _LOGGER.debug("Unable to stop playback.") return status async def previous_track(self) -> int: """Previous track.""" status = await self.put_request(endpoint="player/previous") if status != 204: _LOGGER.debug("Unable to skip to previous track.") return status async def next_track(self) -> int: """Next track.""" status = await self.put_request(endpoint="player/next") if status != 204: _LOGGER.debug("Unable to skip to next track.") return status async def seek(self, **kwargs) -> int: """Seek.""" if "position_ms" in kwargs: params = {"position_ms": int(kwargs["position_ms"])} elif "seek_ms" in kwargs: params = {"seek_ms": int(kwargs["seek_ms"])} else: _LOGGER.error("seek needs either position_ms or seek_ms") return -1 status = await self.put_request(endpoint="player/seek", params=params) if status != 204: _LOGGER.debug( "Unable to seek to %s of %s.", next(iter(params.keys())), next(iter(params.values())), ) return status async def shuffle(self, shuffle) -> int: """Shuffle.""" status = await self.put_request( endpoint="player/shuffle", params={"state": shuffle}, ) if status != 204: _LOGGER.debug("Unable to set shuffle to %s.", shuffle) return status async def set_enabled_outputs(self, output_ids) -> int: """Set enabled outputs.""" status = await self.put_request( endpoint="outputs/set", json={"outputs": output_ids} ) if status != 204: _LOGGER.debug("Unable to set enabled outputs for %s.", output_ids) return status async def set_volume(self, **kwargs) -> int: """Set volume.""" if "volume" in kwargs: params = {"volume": int(kwargs["volume"])} elif "step" in kwargs: params = {"step": int(kwargs["step"])} else: _LOGGER.error("set_volume needs either volume or step") return if "output_id" in kwargs: params = {**params, **{"output_id": kwargs["output_id"]}} status = await self.put_request(endpoint="player/volume", params=params) if status != 204: _LOGGER.debug("Unable to set volume.") return status async def get_track_info(self, track_id) -> dict: """Get track info.""" return await self.get_request(endpoint=f"library/tracks/{track_id}") async def change_output(self, output_id, selected=None, volume=None) -> int: """Change output.""" json = {} if selected is None else {"selected": selected} json = json if volume is None else {**json, **{"volume": int(volume)}} status = await self.put_request(endpoint=f"outputs/{output_id}", json=json) if status != 204: _LOGGER.debug( "%s: Unable to change output %s to %s.", status, output_id, json ) return status async def add_to_queue(self, uris=None, expression=None, **kwargs) -> int: """Add item to queue.""" if not (uris or expression): _LOGGER.error("Either uris or expression must be set.") return if uris: params = {"uris": uris} else: params = {"expression": expression} for field in [ "playback", "playback_from_position", "clear", "shuffle", ]: if field in kwargs: params[field] = kwargs[field] if "position" in kwargs: params["position"] = int(kwargs["position"]) status = await self.post_request(endpoint="queue/items/add", params=params) if status != 200: _LOGGER.debug("%s: Unable to add items to queue.", status) return status async def clear_queue(self) -> int: """Clear queue.""" status = await self.put_request(endpoint="queue/clear") if status != 204: _LOGGER.debug("%s: Unable to clear queue.", status) return status def full_url(self, url): """Get full url (including basic auth) of urls such as artwork_url.""" creds = f"admin:{self._api_password}@" if self._api_password else "" return urljoin(f"http://{creds}{self._ip_address}:{self._api_port}", url) async def get_pipes(self, **kwargs) -> []: """Get list of pipes.""" pipes = await self.get_request( "search", params={"type": "tracks", "expression": "data_kind+is+pipe", **kwargs}, ) if pipes: return pipes["tracks"]["items"] return None async def get_playlists(self, **kwargs) -> []: """Get list of playlists.""" playlists = await self.get_request("library/playlists", params=kwargs) return playlists.get("items") async def get_artists(self, **kwargs) -> []: """Get a list of artists.""" artists = await self.get_request("library/artists", params=kwargs) return artists.get("items") async def get_albums(self, artist_id=None, **kwargs) -> []: """Get a list of albums.""" if artist_id: albums = await self.get_request( f"library/artists/{artist_id}/albums", params=kwargs ) else: albums = await self.get_request("library/albums", params=kwargs) return albums.get("items") async def get_genres(self, **kwargs) -> []: """Get a list of genres in library.""" genres = await self.get_request("library/genres", params=kwargs) return genres.get("items") async def get_genre(self, genre, media_type=None, **kwargs) -> []: """Get artists, albums, or tracks in a given genre.""" params = { "expression": f'genre+is+"{genre}"', "type": media_type or "artist,album,track", **kwargs, } result = await self.get_request("search", params=params) return [ item for sublist in [items_by_type["items"] for items_by_type in result.values()] for item in sublist ] async def get_directory(self, **kwargs) -> []: """Get directory contents.""" return await self.get_request("library/files", params=kwargs) async def get_tracks(self, album_id=None, playlist_id=None, **kwargs) -> []: """Get a list of tracks from an album or playlist or by genre.""" item_id = album_id or playlist_id if item_id is None: return [] tracks = await self.get_request( f"library/{'albums' if album_id else 'playlists'}/{item_id}/tracks", params=kwargs, ) return tracks.get("items") async def get_track(self, track_id) -> {}: """Get track.""" track = await self.get_request(f"library/tracks/{track_id}") return track # not used by HA async def consume(self, consume) -> int: """Consume.""" status = await self.put_request( endpoint="player/consume", params={"state": consume}, ) if status != 204: _LOGGER.debug("Unable to set consume to %s.", consume) return status async def repeat(self, repeat) -> int: """Repeat. Takes string argument of 'off','all', or 'single'.""" status = await self.put_request( endpoint="player/repeat", params={"state": repeat} ) if status != 204: _LOGGER.debug("Unable to set repeat to %s.", repeat) return status async def toggle_playback(self) -> int: """Toggle playback.""" status = await self.put_request(endpoint="player/toggle") if status != 204: _LOGGER.debug("Unable to toggle playback.") return status uvjustin-pyforked-daapd-9cf1dc0/requirements.txt000066400000000000000000000000111374375734500222620ustar00rootroot00000000000000aiohttp uvjustin-pyforked-daapd-9cf1dc0/setup.py000066400000000000000000000026101374375734500205170ustar00rootroot00000000000000"""setup.py for forked-daapd package.""" from __future__ import print_function import io from setuptools import setup import pyforked_daapd def read(*filenames, **kwargs): """Read helper.""" encoding = kwargs.get("encoding", "utf-8") sep = kwargs.get("sep", "\n") buf = [] for filename in filenames: with io.open(filename, encoding=encoding) as file: buf.append(file.read()) return sep.join(buf) LONG_DESCRIPTION = read("README.md") setup( name="pyforked-daapd", version=pyforked_daapd.__version__, url="http://github.com/uvjustin/pyforked-daapd/", author="Justin Wong", install_requires=["aiohttp"], author_email="46082645+uvjustin@users.noreply.github.com", description="Python Interface for forked-daapd", long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", packages=["pyforked_daapd"], include_package_data=True, platforms="any", classifiers=[ "Programming Language :: Python", "Development Status :: 4 - Beta", "Natural Language :: English", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Python Modules", ], )