diff --git a/docs/source/conf.py b/docs/source/conf.py index 76545886..7deff591 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -195,6 +195,7 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', 'inputs', 'inotify', 'omxplayer', + 'plexapi', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/plugins/media/chromecast.py b/platypush/plugins/media/chromecast.py new file mode 100644 index 00000000..7e486690 --- /dev/null +++ b/platypush/plugins/media/chromecast.py @@ -0,0 +1,262 @@ +import pychromecast + +from platypush.plugins import Plugin, action + + +class MediaChromecastPlugin(Plugin): + """ + Plugin to interact with Chromecast devices + + Requires: + + * **pychromecast** (``pip install pychromecast``) + """ + + STREAM_TYPE_UNKNOWN = "UNKNOWN" + STREAM_TYPE_BUFFERED = "BUFFERED" + STREAM_TYPE_LIVE = "LIVE" + + def __init__(self, chromecast=None, *args, **kwargs): + """ + :param chromecast: Default Chromecast to cast to if no name is specified + :type chromecast: str + """ + + super().__init__(*args, **kwargs) + + self.chromecast = chromecast + self.chromecasts = {} + + + @action + def get_chromecasts(self): + """ + Get the list of Chromecast devices + """ + + return [ { + 'type': cc.cast_type, + 'name': cc.name, + 'manufacturer': cc.device.manufacturer, + 'model_name': cc.model_name, + 'uuid': str(cc.device.uuid), + 'address': cc.host, + 'port': cc.port, + + 'status': ({ + 'app': { + 'id': cc.app_id, + 'name': cc.app_display_name, + }, + + 'is_active_input': cc.status.is_active_input, + 'is_stand_by': cc.status.is_stand_by, + 'is_idle': cc.is_idle, + 'namespaces': cc.status.namespaces, + 'volume': round(100*cc.status.volume_level, 2), + 'muted': cc.status.volume_muted, + } if cc.status else {}), + } for cc in pychromecast.get_chromecasts() ] + + + def _get_chromecast(self, chromecast=None): + if not chromecast: + if not self.chromecast: + raise RuntimeError('No Chromecast specified nor default Chromecast configured') + chromecast = self.chromecast + + + if chromecast not in self.chromecasts: + chromecasts = pychromecast.get_chromecasts() + cast = next(cc for cc in pychromecast.get_chromecasts() + if cc.device.friendly_name == chromecast) + self.chromecasts[chromecast] = cast + else: + cast = self.chromecasts[chromecast] + + if not cast: + raise RuntimeError('No such Chromecast: {}'.format(chromecast)) + + return cast + + @action + def cast_media(self, media, content_type, chromecast=None, title=None, + image_url=None, autoplay=True, current_time=0, + stream_type=STREAM_TYPE_BUFFERED, subtitles=None, + subtitles_lang='en-US', subtitles_mime='text/vtt', + subtitle_id=1): + """ + Cast media to a visible Chromecast + + :param media: Media to cast + :type media: str + + :param content_type: Content type + :type content_type: str + + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :type chromecast: str + + :param title: Optional title + :type title: str + + :param image_url: URL of the image to use for the thumbnail + :type image_url: str + + :param autoplay: Set it to false if you don't want the content to start playing immediately (default: true) + :type autoplay: bool + + :param current_time: Time to start the playback in seconds (default: 0) + :type current_time: int + + :param stream_type: Type of stream to cast. Can be BUFFERED (default), LIVE or UNKNOWN + :type stream_type: str + + :param subtitles: URL of the subtitles to be shown + :type subtitles: str + + :param subtitles_lang: Subtitles language (default: en-US) + :type subtitles_lang: str + + :param subtitles_mime: Subtitles MIME type (default: text/vtt) + :type subtitles_mime: str + + :param subtitle_id: ID of the subtitles to be loaded (default: 1) + :type subtitle_id: int + """ + + cast = self._get_chromecast(chromecast) + cast.wait() + mc = cast.media_controller + mc.play_media(media, content_type, title=title, thumb=image_url, + current_time=current_time, autoplay=autoplay, + stream_type=stream_type, subtitles=subtitles, + subtitles_lang=subtitles_lang, subtitles_mime=subtitles_mime, + subtitle_id=subtitle_id) + + mc.block_until_active() + + @action + def disconnect(self, chromecast=None, timeout=None, blocking=True): + """ + Disconnect a Chromecast and wait for it to terminate + + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :type chromecast: str + + :param timeout: Number of seconds to wait for disconnection (default: None: block until termination) + :type timeout: float + + :param blocking: If set (default), then the code will wait until disconnection, otherwise it will return immediately. + :type blocking: bool + """ + + cast = self._get_chromecast(chromecast) + cast.disconnect(timeout=timeout, blocking=blocking) + + @action + def join(self, chromecast=None, timeout=None, blocking=True): + """ + Blocks the thread until the Chromecast connection is terminated. + + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :type chromecast: str + + :param timeout: Number of seconds to wait for disconnection (default: None: block until termination) + :type timeout: float + + :param blocking: If set (default), then the code will wait until disconnection, otherwise it will return immediately. + :type blocking: bool + """ + + cast = self._get_chromecast(chromecast) + cast.join(timeout=timeout, blocking=blocking) + + @action + def quit_app(self, chromecast=None): + """ + Exits the current app on the Chromecast + + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :type chromecast: str + """ + + cast = self._get_chromecast(chromecast) + cast.quit_app() + + @action + def reboot(self, chromecast=None): + """ + Reboots the Chromecast + + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :type chromecast: str + """ + + cast = self._get_chromecast(chromecast) + cast.reboot() + + @action + def set_volume(self, volume, chromecast=None): + """ + Set the Chromecast volume + + :param volume: Volume to be set, between 0 and 100 + :type volume: float + + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :type chromecast: str + """ + + cast = self._get_chromecast(chromecast) + cast.set_volume(volume/100) + + @action + def volume_up(self, chromecast=None, delta=10): + """ + Turn up the Chromecast volume by 10% or delta. + + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :type chromecast: str + + :param delta: Volume increment between 0 and 100 (default: 100%) + :type delta: float + """ + + cast = self._get_chromecast(chromecast) + delta /= 100 + cast.volume_up(min(delta, 1)) + + + @action + def volume_down(self, chromecast=None, delta=10): + """ + Turn down the Chromecast volume by 10% or delta. + + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :type chromecast: str + + :param delta: Volume decrement between 0 and 100 (default: 100%) + :type delta: float + """ + + cast = self._get_chromecast(chromecast) + delta /= 100 + cast.volume_down(max(delta, 0)) + + + @action + def toggle_mute(self, chromecast=None): + """ + Toggle the mute status on the Chromecast + + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :type chromecast: str + """ + + cast = self._get_chromecast(chromecast) + cast.set_volume_muted(not cast.status.volume_muted) + + +# vim:sw=4:ts=4:et: + diff --git a/platypush/plugins/media/kodi.py b/platypush/plugins/media/kodi.py index 2d3fad84..df5471fa 100644 --- a/platypush/plugins/media/kodi.py +++ b/platypush/plugins/media/kodi.py @@ -9,7 +9,7 @@ class MediaKodiPlugin(Plugin): Requires: - * **kodi-json** (``pip install kodi-rtmidi``) + * **kodi-json** (``pip install kodi-json``) """ def __init__(self, url, username=None, password=None, *args, **kwargs): diff --git a/platypush/plugins/media/plex.py b/platypush/plugins/media/plex.py new file mode 100644 index 00000000..8b3d5992 --- /dev/null +++ b/platypush/plugins/media/plex.py @@ -0,0 +1,171 @@ +from plexapi.myplex import MyPlexAccount +from plexapi.video import Movie, Show + +from platypush.plugins import Plugin, action + + +class MediaPlexPlugin(Plugin): + """ + Plugin to interact with a Plex media server + + Requires: + + * **plexapi** (``pip install plexapi``) + """ + + def __init__(self, server, username, password, *args, **kwargs): + """ + :param server: Plex server name + :type server: str + + :param username: Plex username + :type username: str + + :param password: Plex password + :type username: str + """ + + super().__init__(*args, **kwargs) + + self.resource = MyPlexAccount(username, password).resource(server) + self._plex = None + + + @property + def plex(self): + if not self._plex: + self._plex = self.resource.connect() + + return self._plex + + + @action + def get_clients(self): + """ + Get the list of active clients + """ + + return [{ + 'device': c.device, + 'device_class': c.deviceClass, + 'local': c.local, + 'model': c.model, + 'platform': c.platform, + 'platform_version': c.platformVersion, + 'product': c.product, + 'state': c.state, + 'title': c.title, + 'version': c.version, + } for c in self.plex.clients()] + + + def _get_client(self, name): + return self.plex.client(name) + + + @action + def search(self, section=None, title=None, **kwargs): + """ + Return all the items matching the search criteria (default: all library items) + + :param section: Section to search (Movies, Shows etc.) + :type section: str + + :param title: Full or partial title + :type title: str + + :param kwargs: Search criteria - includes e.g. title, unwatched, director, genre etc. + :type kwargs: dict + """ + + ret = [] + library = self.plex.library + + if section: + library = library.section(section) + + if title or kwargs: + items = library.search(title, **kwargs) + else: + items = library.all() + + for item in items: + video_item = { + 'summary': item.summary, + 'title': item.title, + 'type': item.type, + 'rating': item.rating, + } + + if isinstance(item, Movie): + video_item['is_watched'] = item.isWatched + video_item['view_offset'] = item.viewOffset + video_item['view_count'] = item.viewCount + video_item['year'] = item.year + + video_item['media'] = [ + { + 'duration': (item.media[i].duration or 0)/1000, + 'width': item.media[i].width, + 'height': item.media[i].height, + 'audio_channels': item.media[i].audioChannels, + 'audio_codec': item.media[i].audioCodec, + 'video_codec': item.media[i].videoCodec, + 'video_resolution': item.media[i].videoResolution, + 'video_frame_rate': item.media[i].videoFrameRate, + 'parts': [ + { + 'file': part.file, + 'duration': (part.duration or 0)/1000, + } for part in item.media[i].parts + ] + } for i in range(0, len(item.media)) + ] + elif isinstance(item, Show): + video_item['media'] = [ + { + 'title': season.title, + 'season_number': season.seasonNumber, + 'summary': season.summary, + 'episodes': [ + { + 'duration': episode.duration/1000, + 'index': episode.index, + 'year': episode.year, + 'season_number': episode.seasonNumber, + 'season_episode': episode.seasonEpisode, + 'summary': episode.summary, + 'is_watched': episode.isWatched, + 'view_count': episode.viewCount, + 'view_offset': episode.viewOffset, + 'media': [ + { + 'duration': episode.media[i].duration/1000, + 'width': episode.media[i].width, + 'height': episode.media[i].height, + 'audio_channels': episode.media[i].audioChannels, + 'audio_codec': episode.media[i].audioCodec, + 'video_codec': episode.media[i].videoCodec, + 'video_resolution': episode.media[i].videoResolution, + 'video_frame_rate': episode.media[i].videoFrameRate, + 'title': episode.title, + 'parts': [ + { + 'file': part.file, + 'duration': part.duration/1000, + } for part in episode.media[i].parts + ] + } for i in range(0, len(episode.locations)) + ] + } for episode in season.episodes() + ] + } for season in item.seasons() + ] + + ret.append(video_item) + + return ret + + +# vim:sw=4:ts=4:et: + diff --git a/requirements.txt b/requirements.txt index 0fdaab94..0f802c6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -99,3 +99,12 @@ pyHS100 # Support for joystick backend inputs +# Support for Kodi +kodi-json + +# Support for Plex +plexapi + +# Support for Chromecast +pychromecast + diff --git a/setup.py b/setup.py index f5d517d7..7fd13efd 100755 --- a/setup.py +++ b/setup.py @@ -88,6 +88,9 @@ setup( 'Support for smart cards detection': ['pyscard'], 'Support for ICal calendars': ['icalendar', 'python-dateutil'], 'Support for joystick backend': ['inputs'], + 'Support for Kodi plugin': ['kodi-json'], + 'Support for Plex plugin': ['plexapi'], + 'Support for Chromecast plugin': ['pychromecast'], # 'Support for Leap Motion backend': ['git+ssh://git@github.com:BlackLight/leap-sdk-python3.git'], # 'Support for Flic buttons': ['git+ssh://git@github.com/50ButtonsEach/fliclib-linux-hci'] },