pax_global_header00006660000000000000000000000064141347772660014533gustar00rootroot0000000000000052 comment=974fe2136a08c8cd54022b7adf6b43829f729b7d OnFreund-PyKodi-974fe21/000077500000000000000000000000001413477726600150135ustar00rootroot00000000000000OnFreund-PyKodi-974fe21/.gitignore000066400000000000000000000000751413477726600170050ustar00rootroot00000000000000__pycache__/ *.pyc build/ dist/ *.egg-info/ venv/ .vscode/ OnFreund-PyKodi-974fe21/LICENSE000066400000000000000000000020511413477726600160160ustar00rootroot00000000000000MIT License Copyright (c) 2020 On Freund 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.OnFreund-PyKodi-974fe21/MANIFEST.in000066400000000000000000000000311413477726600165430ustar00rootroot00000000000000include README.md LICENSEOnFreund-PyKodi-974fe21/README.md000066400000000000000000000017151413477726600162760ustar00rootroot00000000000000# PyKodi An async python interface for [Kodi](https://kodi.tv/) over JSON-RPC. This is mostly designed to integrate with HomeAssistant. If you have other needs, there might be better packages available. ## Installation You can install PyKodi from [PyPI](https://pypi.org/project/pykodi/): pip3 install pykodi Python 3.7 and above are supported. ## How to use ```python from pykodi import get_kodi_connection, Kodi kc = get_kodi_connection(, , , , , , , ) # if ws_port is None the connection will be over HTTP, otherwise over WebSocket. # ssl defaults to False (only relevant if you have a proxy), timeout to 5 (seconds) # session is generated if not passed in # you can also pass in your own session await kc.connect() kodi = Kodi(kc) await kodi.ping() properties = await kodi.get_application_properties(["name", "version"]) await kodi.play() await kodi.volume_up() await kodi.pause() ... ``` OnFreund-PyKodi-974fe21/pykodi/000077500000000000000000000000001413477726600163125ustar00rootroot00000000000000OnFreund-PyKodi-974fe21/pykodi/__init__.py000066400000000000000000000001221413477726600204160ustar00rootroot00000000000000from .kodi import get_kodi_connection, Kodi, CannotConnectError, InvalidAuthError OnFreund-PyKodi-974fe21/pykodi/kodi.py000066400000000000000000000334271413477726600176230ustar00rootroot00000000000000"""Implementation of a Kodi inteface.""" import aiohttp import urllib import asyncio import jsonrpc_base import jsonrpc_async import jsonrpc_websocket def get_kodi_connection( host, port, ws_port, username, password, ssl=False, timeout=5, session=None ): """Returns a Kodi connection.""" if ws_port is None: return KodiHTTPConnection(host, port, username, password, ssl, timeout, session) else: return KodiWSConnection( host, port, ws_port, username, password, ssl, timeout, session ) class KodiConnection: """A connection to Kodi interface.""" def __init__(self, host, port, username, password, ssl, timeout, session): """Initialize the object.""" self._session = session self._created_session = False if self._session is None: self._session = aiohttp.ClientSession() self._created_session = True self._kwargs = {"timeout": timeout, "session": self._session} if username is not None: self._kwargs["auth"] = aiohttp.BasicAuth(username, password) image_auth_string = f"{username}:{password}@" else: image_auth_string = "" http_protocol = "https" if ssl else "http" self._image_url = f"{http_protocol}://{image_auth_string}{host}:{port}/image" async def connect(self): """Connect to kodi.""" pass async def close(self): """Close the connection.""" if self._created_session and self._session is not None: await self._session.close() self._session = None self._created_session = False @property def server(self): raise NotImplementedError @property def connected(self): """Is the server connected.""" raise NotImplementedError @property def can_subscribe(self): return False def thumbnail_url(self, thumbnail): """Get the URL for a thumbnail.""" if thumbnail is None: return None url_components = urllib.parse.urlparse(thumbnail) if url_components.scheme == "image": return f"{self._image_url}/{urllib.parse.quote_plus(thumbnail)}" class KodiHTTPConnection(KodiConnection): """An HTTP connection to Kodi.""" def __init__(self, host, port, username, password, ssl, timeout, session): """Initialize the object.""" super().__init__(host, port, username, password, ssl, timeout, session) http_protocol = "https" if ssl else "http" http_url = f"{http_protocol}://{host}:{port}/jsonrpc" self._http_server = jsonrpc_async.Server(http_url, **self._kwargs) @property def connected(self): """Is the server connected.""" return True async def close(self): """Close the connection.""" self._http_server = None await super().close() @property def server(self): """Active server for json-rpc requests.""" return self._http_server class KodiWSConnection(KodiConnection): """A WS connection to Kodi.""" def __init__(self, host, port, ws_port, username, password, ssl, timeout, session): """Initialize the object.""" super().__init__(host, port, username, password, ssl, timeout, session) ws_protocol = "wss" if ssl else "ws" ws_url = f"{ws_protocol}://{host}:{ws_port}/jsonrpc" self._ws_server = jsonrpc_websocket.Server(ws_url, **self._kwargs) @property def connected(self): """Return whether websocket is connected.""" return self._ws_server.connected @property def can_subscribe(self): return True async def connect(self): """Connect to kodi over websocket.""" if self.connected: return try: await self._ws_server.ws_connect() except ( jsonrpc_base.jsonrpc.TransportError, asyncio.exceptions.CancelledError, ) as error: raise CannotConnectError from error async def close(self): """Close the connection.""" await self._ws_server.close() await super().close() @property def server(self): """Active server for json-rpc requests.""" return self._ws_server class Kodi: """A high level Kodi interface.""" def __init__(self, connection): """Initialize the object.""" self._conn = connection self._server = connection.server async def ping(self): """Ping the server.""" try: response = await self._server.JSONRPC.Ping() return response == "pong" except jsonrpc_base.jsonrpc.TransportError as error: if "401" in str(error): raise InvalidAuthError from error else: raise CannotConnectError from error async def get_application_properties(self, properties): """Get value of given properties.""" return await self._server.Application.GetProperties(properties) async def get_player_properties(self, player, properties): """Get value of given properties.""" return await self._server.Player.GetProperties(player["playerid"], properties) async def get_playing_item_properties(self, player, properties): """Get value of given properties.""" return (await self._server.Player.GetItem(player["playerid"], properties))[ "item" ] async def volume_up(self): """Send volume up command.""" await self._server.Input.ExecuteAction("volumeup") async def volume_down(self): """Send volume down command.""" await self._server.Input.ExecuteAction("volumedown") async def set_volume_level(self, volume): """Set volume level, range 0-100.""" await self._server.Application.SetVolume(volume) async def mute(self, mute): """Send (un)mute command.""" await self._server.Application.SetMute(mute) async def _set_play_state(self, state): players = await self.get_players() if players: await self._server.Player.PlayPause(players[0]["playerid"], state) async def play_pause(self): """Send toggle command command.""" await self._set_play_state("toggle") async def play(self): """Send play command.""" await self._set_play_state(True) async def pause(self): """Send pause command.""" await self._set_play_state(False) async def stop(self): """Send stop command.""" players = await self.get_players() if players: await self._server.Player.Stop(players[0]["playerid"]) async def _goto(self, direction): players = await self.get_players() if players: if direction == "previous": # First seek to position 0. Kodi goes to the beginning of the # current track if the current track is not at the beginning. await self._server.Player.Seek(players[0]["playerid"], {"percentage": 0}) await self._server.Player.GoTo(players[0]["playerid"], direction) async def next_track(self): """Send next track command.""" await self._goto("next") async def previous_track(self): """Send previous track command.""" await self._goto("previous") async def media_seek(self, position): """Send seek command.""" players = await self.get_players() time = {"milliseconds": int((position % 1) * 1000)} position = int(position) time["seconds"] = int(position % 60) position /= 60 time["minutes"] = int(position % 60) position /= 60 time["hours"] = int(position) if players: await self._server.Player.Seek(players[0]["playerid"], {"time": time}) async def play_item(self, item): await self._server.Player.Open(**{"item": item}) async def play_channel(self, channel_id): """Play the given channel.""" await self.play_item({"channelid": channel_id}) async def play_playlist(self, playlist_id): """Play the given playlist.""" await self.play_item({"playlistid": playlist_id}) async def play_directory(self, directory): """Play the given directory.""" await self.play_item({"directory": directory}) async def play_file(self, file): """Play the given file.""" await self.play_item({"file": file}) async def set_shuffle(self, shuffle): """Set shuffle mode, for the first player.""" players = await self.get_players() if players: await self._server.Player.SetShuffle( **{"playerid": players[0]["playerid"], "shuffle": shuffle} ) async def call_method(self, method, **kwargs): """Run Kodi JSONRPC API method with params.""" return await getattr(self._server, method)(**kwargs) async def _add_item_to_playlist(self, item): await self._server.Playlist.Add(**{"playlistid": 0, "item": item}) async def add_song_to_playlist(self, song_id): """Add song to default playlist (i.e. playlistid=0).""" await self._add_item_to_playlist({"songid": song_id}) async def add_album_to_playlist(self, album_id): """Add album to default playlist (i.e. playlistid=0).""" await self._add_item_to_playlist({"albumid": album_id}) async def add_artist_to_playlist(self, artist_id): """Add album to default playlist (i.e. playlistid=0).""" await self._add_item_to_playlist({"artistid": artist_id}) async def clear_playlist(self): """Clear default playlist (i.e. playlistid=0).""" await self._server.Playlist.Clear(**{"playlistid": 0}) async def get_artists(self, properties=None): """Get artists list.""" return await self._server.AudioLibrary.GetArtists( **_build_query(properties=properties) ) async def get_artist_details(self, artist_id=None, properties=None): """Get artist details.""" return await self._server.AudioLibrary.GetArtistDetails( **_build_query(artistid=artist_id, properties=properties) ) async def get_albums(self, artist_id=None, album_id=None, properties=None): """Get albums list.""" filter = {} if artist_id: filter["artistid"] = artist_id if album_id: filter["albumid"] = album_id return await self._server.AudioLibrary.GetAlbums( **_build_query(filter=filter, properties=properties) ) async def get_album_details(self, album_id, properties=None): """Get album details.""" return await self._server.AudioLibrary.GetAlbumDetails( **_build_query(albumid=album_id, properties=properties) ) async def get_songs(self, artist_id=None, album_id=None, properties=None): """Get songs list.""" filter = {} if artist_id: filter["artistid"] = artist_id if album_id: filter["albumid"] = album_id return await self._server.AudioLibrary.GetSongs( **_build_query(filter=filter, properties=properties) ) async def get_movies(self, properties=None): """Get movies list.""" return await self._server.VideoLibrary.GetMovies( **_build_query(properties=properties) ) async def get_movie_details(self, movie_id, properties=None): """Get movie details.""" return await self._server.VideoLibrary.GetMovieDetails( **_build_query(movieid=movie_id, properties=properties) ) async def get_seasons(self, tv_show_id, properties=None): """Get seasons list.""" return await self._server.VideoLibrary.GetSeasons( **_build_query(tvshowid=tv_show_id, properties=properties) ) async def get_season_details(self, season_id, properties=None): """Get songs list.""" return await self._server.VideoLibrary.GetSeasonDetails( **_build_query(seasonid=season_id, properties=properties) ) async def get_episodes(self, tv_show_id, season_id, properties=None): """Get episodes list.""" return await self._server.VideoLibrary.GetEpisodes( **_build_query(tvshowid=tv_show_id, season=season_id, properties=properties) ) async def get_tv_shows(self, properties=None): """Get tv shows list.""" return await self._server.VideoLibrary.GetTVShows( **_build_query(properties=properties) ) async def get_tv_show_details(self, tv_show_id=None, properties=None): """Get songs list.""" return await self._server.VideoLibrary.GetTVShowDetails( **_build_query(tvshowid=tv_show_id, properties=properties) ) async def get_channels(self, channel_group_id, properties=None): """Get channels list.""" return await self._server.PVR.GetChannels( **_build_query(channelgroupid=channel_group_id, properties=properties) ) async def get_players(self): """Return the active player objects.""" return await self._server.Player.GetActivePlayers() async def send_notification(self, title, message, icon="info", displaytime=10000): """Display on-screen message.""" await self._server.GUI.ShowNotification(title, message, icon, displaytime) def thumbnail_url(self, thumbnail): """Get the URL for a thumbnail.""" return self._conn.thumbnail_url(thumbnail) def _build_query(**kwargs): """Build query.""" query = {} for key, val in kwargs.items(): if val: query.update({key: val}) return query class CannotConnectError(Exception): """Exception to indicate an error in connection.""" class InvalidAuthError(Exception): """Exception to indicate an error in authentication.""" OnFreund-PyKodi-974fe21/setup.py000066400000000000000000000065451413477726600165370ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- # Note: To use the 'upload' functionality of this file, you must: # $ pipenv install twine --dev import io import os import sys from shutil import rmtree from setuptools import find_packages, setup, Command # Package meta-data. NAME = 'pykodi' DESCRIPTION = 'An async python interface for Kodi over JSON-RPC.' URL = 'https://github.com/OnFreund/PyKodi' EMAIL = 'onfreund@gmail.com' AUTHOR = 'On Freund' REQUIRES_PYTHON = '>=3.7.0' VERSION = '0.2.7' REQUIRED = ['jsonrpc-async>=2.0.0', 'jsonrpc-websocket>=3.0.0', 'aiohttp'] EXTRAS = {} here = os.path.abspath(os.path.dirname(__file__)) # Import the README and use it as the long-description. # Note: this will only work if 'README.md' is present in your MANIFEST.in file! try: with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: long_description = '\n' + f.read() except FileNotFoundError: long_description = DESCRIPTION # Load the package's __version__.py module as a dictionary. about = {} if not VERSION: project_slug = NAME.lower().replace("-", "_").replace(" ", "_") with open(os.path.join(here, project_slug, '__version__.py')) as f: exec(f.read(), about) else: about['__version__'] = VERSION class UploadCommand(Command): """Support setup.py upload.""" description = 'Build and publish the package.' user_options = [] @staticmethod def status(s): """Prints things in bold.""" print('\033[1m{0}\033[0m'.format(s)) def initialize_options(self): pass def finalize_options(self): pass def run(self): try: self.status('Removing previous builds…') rmtree(os.path.join(here, 'dist')) except OSError: pass self.status('Building Source and Wheel distribution…') os.system('{0} setup.py sdist bdist_wheel'.format(sys.executable)) self.status('Uploading the package to PyPI via Twine…') os.system('twine upload dist/*') self.status('Pushing git tags…') os.system('git tag v{0}'.format(about['__version__'])) os.system('git push --tags') sys.exit() # Where the magic happens: setup( name=NAME, version=about['__version__'], description=DESCRIPTION, long_description=long_description, long_description_content_type='text/markdown', author=AUTHOR, author_email=EMAIL, python_requires=REQUIRES_PYTHON, url=URL, packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), # If your package is a single module, use this instead of 'packages': # py_modules=['mypackage'], # entry_points={ # 'console_scripts': ['mycli=mymodule:cli'], # }, install_requires=REQUIRED, extras_require=EXTRAS, include_package_data=True, license='MIT', classifiers=[ # Trove classifiers # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 'License :: OSI Approved :: MIT License', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy' ], # $ setup.py publish support. cmdclass={ 'upload': UploadCommand, }, )