From a236b87ccfb779cf4a41569843d41dc16383dcfb Mon Sep 17 00:00:00 2001 From: Tom Duijf Date: Tue, 13 Oct 2015 21:59:13 +0000 Subject: [PATCH 1/8] new attempt for PR --- homeassistant/components/media_player/plex.py | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 5fac9ecb0f0..33bab1954ee 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -16,9 +16,7 @@ from homeassistant.const import ( STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF, STATE_UNKNOWN) import homeassistant.util as util -REQUIREMENTS = ['https://github.com/adrienbrault/python-plexapi/archive/' - 'df2d0847e801d6d5cda920326d693cf75f304f1a.zip' - '#python-plexapi==1.0.2'] +REQUIREMENTS = ['plexapi==1.1.0'] MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) @@ -45,24 +43,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): def update_devices(): """ Updates the devices objects. """ try: - devices = plexuser.devices() + devices = plexserver.clients() except BadRequest: _LOGGER.exception("Error listing plex devices") return new_plex_clients = [] for device in devices: - if (all(x not in ['client', 'player'] for x in device.provides) - or 'PlexAPI' == device.product): + # For now, let's allow all deviceClass types + if device.deviceClass in []: continue - if device.clientIdentifier not in plex_clients: + if device.machineIdentifier not in plex_clients: new_client = PlexClient(device, plex_sessions, update_devices, update_sessions) - plex_clients[device.clientIdentifier] = new_client + plex_clients[device.machineIdentifier] = new_client new_plex_clients.append(new_client) else: - plex_clients[device.clientIdentifier].set_device(device) + plex_clients[device.machineIdentifier].set_device(device) if new_plex_clients: add_devices(new_plex_clients) @@ -101,10 +99,10 @@ class PlexClient(MediaPlayerDevice): @property def session(self): """ Returns the session, if any. """ - if self.device.clientIdentifier not in self.plex_sessions: + if self.device.machineIdentifier not in self.plex_sessions: return None - return self.plex_sessions[self.device.clientIdentifier] + return self.plex_sessions[self.device.machineIdentifier] @property def name(self): @@ -120,7 +118,8 @@ class PlexClient(MediaPlayerDevice): return STATE_PLAYING elif state == 'paused': return STATE_PAUSED - elif self.device.isReachable: + # This is nasty. Need ti find a way to determine alive + elif self.device: return STATE_IDLE else: return STATE_OFF @@ -196,16 +195,16 @@ class PlexClient(MediaPlayerDevice): def media_play(self): """ media_play media player. """ - self.device.play({'type': 'video'}) + self.device.play() def media_pause(self): """ media_pause media player. """ - self.device.pause({'type': 'video'}) + self.device.pause() def media_next_track(self): """ Send next track command. """ - self.device.skipNext({'type': 'video'}) + self.device.skipNext() def media_previous_track(self): """ Send previous track command. """ - self.device.skipPrevious({'type': 'video'}) + self.device.skipPrevious() From 8e9cafd29d02488a62026a22b880194338e90760 Mon Sep 17 00:00:00 2001 From: Tom Duijf Date: Fri, 16 Oct 2015 18:15:04 +0000 Subject: [PATCH 2/8] Updated requirements_all.txt. Added placeholder to the empty deviceClass filter. Will remove this if deemed unneeded, later --- homeassistant/components/media_player/plex.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 33bab1954ee..21890524348 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -51,7 +51,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): new_plex_clients = [] for device in devices: # For now, let's allow all deviceClass types - if device.deviceClass in []: + if device.deviceClass in ['badClient']: continue if device.machineIdentifier not in plex_clients: diff --git a/requirements_all.txt b/requirements_all.txt index c63eea25853..11d91043d12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -134,7 +134,7 @@ https://github.com/balloob/home-assistant-vera-api/archive/a8f823066ead6c7da6fb5 SoCo==0.11.1 # PlexAPI (media_player.plex) -https://github.com/adrienbrault/python-plexapi/archive/df2d0847e801d6d5cda920326d693cf75f304f1a.zip#python-plexapi==1.0.2 +plexapi==1.1.0 # SNMP (device_tracker.snmp) pysnmp==4.2.5 From db7e46abd1686194e3b0d61b8897d5a79704d061 Mon Sep 17 00:00:00 2001 From: Tom Duijf Date: Sun, 18 Oct 2015 20:02:18 +0000 Subject: [PATCH 3/8] Intermediate save --- homeassistant/components/discovery.py | 2 + homeassistant/components/media_player/plex.py | 71 +++++++++++++++++-- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 0b3cc1025cc..9fc7ee6651c 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -28,6 +28,7 @@ SERVICE_HUE = 'philips_hue' SERVICE_CAST = 'google_cast' SERVICE_NETGEAR = 'netgear_router' SERVICE_SONOS = 'sonos' +SERVICE_PLEX = 'plex' SERVICE_HANDLERS = { SERVICE_WEMO: "switch", @@ -35,6 +36,7 @@ SERVICE_HANDLERS = { SERVICE_HUE: "light", SERVICE_NETGEAR: 'device_tracker', SERVICE_SONOS: 'media_player', + SERVICE_PLEX: 'media_player', } diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 21890524348..b18814a8ced 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -20,6 +20,8 @@ REQUIREMENTS = ['plexapi==1.1.0'] MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +PLEX_CONFIG_FILE = 'plex.conf' + _LOGGER = logging.getLogger(__name__) SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK @@ -28,14 +30,73 @@ SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK # pylint: disable=abstract-method, unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """ Sets up the plex platform. """ + try: + # pylint: disable=unused-variable + from plexapi.myplex import MyPlexUser + from plexapi.exceptions import BadRequest + except ImportError: + _LOGGER.exception("Error while importing dependency plexapi.") + return + + if discovery_info is not None: + host = urlparse(discovery_info[1]).url + _LOGGER.error('Discovered PLEX server: %s'%host) + else: + # 'name' is currently used for plexserver + # This indicates old config method + host = config.get('name','') + + if host in _CONFIGURING: + return + + setup_plexserver(host, hass, add_devices) + + +def setup_plexserver(host, hass, add_devices): + ''' Setup a plexserver based on host parameter''' from plexapi.myplex import MyPlexUser + from plexapi.server import PlexServer from plexapi.exceptions import BadRequest - name = config.get('name', '') - user = config.get('user', '') - password = config.get('password', '') - plexuser = MyPlexUser.signin(user, password) - plexserver = plexuser.getResource(name).connect() + conf_file = hass.config.path(PHUE_CONFIG_FILE)) + + # Compatability mode. If there's name, user, etc set in + # configuration, let's use those, not to break anything + # We may want to use this method as option when HA's + # configuration options increase + if config.get('name', ''): + name = config.get('name', '') + user = config.get('user', '') + password = config.get('password', '') + plexuser = MyPlexUser.signin(user, password) + plexserver = plexuser.getResource(name).connect() + + # Discovery mode. Parse config file, attempt conenction + # Request configuration on connect fail + else: + + try: + # Get configuration from config file + # FIXME unauthenticated plex servers dont require + # a token, so config file isn't mandatory + with open(conf_file,'r') as f: + conf_dict = eval(f.read()) + + plexserver = PlexServer( + host, + conf_dict.get(host)['token']) + except IOError: # File not found + + except NotFound: # Wrong host was given or need token? + _LOGGER.exception("Error connecting to the Hue bridge at %s", host) + return + + except phue.PhueRegistrationException: + _LOGGER.warning("Connected to Hue at %s but not registered.", host) + + request_configuration(host, hass, add_devices_callback) + return + plex_clients = {} plex_sessions = {} From 6a82504e5e1890c67f8e08ce67a9f7e6ef9f19b4 Mon Sep 17 00:00:00 2001 From: Tom Duijf Date: Tue, 20 Oct 2015 16:59:22 +0000 Subject: [PATCH 4/8] further discovery integration into plex --- homeassistant/components/discovery.py | 2 +- .../components/media_player/__init__.py | 1 + homeassistant/components/media_player/plex.py | 45 ++++++++++++++----- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 9fc7ee6651c..1e04f20ea3e 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -28,7 +28,7 @@ SERVICE_HUE = 'philips_hue' SERVICE_CAST = 'google_cast' SERVICE_NETGEAR = 'netgear_router' SERVICE_SONOS = 'sonos' -SERVICE_PLEX = 'plex' +SERVICE_PLEX = 'plex_mediaserver' SERVICE_HANDLERS = { SERVICE_WEMO: "switch", diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 294fccbb1f5..8040ef9c067 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -28,6 +28,7 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' DISCOVERY_PLATFORMS = { discovery.SERVICE_CAST: 'cast', discovery.SERVICE_SONOS: 'sonos', + discovery.SERVICE_PLEX: 'plex', } SERVICE_YOUTUBE_VIDEO = 'play_youtube_video' diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index b18814a8ced..ae619e64355 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -8,6 +8,7 @@ https://home-assistant.io/components/media_player.plex.html """ import logging from datetime import timedelta +from urllib.parse import urlparse from homeassistant.components.media_player import ( MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, @@ -22,6 +23,8 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) PLEX_CONFIG_FILE = 'plex.conf' +# Map ip to request id for configuring +_CONFIGURING = {} _LOGGER = logging.getLogger(__name__) SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK @@ -39,7 +42,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return if discovery_info is not None: - host = urlparse(discovery_info[1]).url + host = urlparse(discovery_info[1]).netloc _LOGGER.error('Discovered PLEX server: %s'%host) else: # 'name' is currently used for plexserver @@ -49,16 +52,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if host in _CONFIGURING: return - setup_plexserver(host, hass, add_devices) + setup_plexserver(host, config, hass, add_devices) def setup_plexserver(host, hass, add_devices): ''' Setup a plexserver based on host parameter''' - from plexapi.myplex import MyPlexUser from plexapi.server import PlexServer from plexapi.exceptions import BadRequest - conf_file = hass.config.path(PHUE_CONFIG_FILE)) + conf_file = hass.config.path(PLEX_CONFIG_FILE) # Compatability mode. If there's name, user, etc set in # configuration, let's use those, not to break anything @@ -75,6 +77,7 @@ def setup_plexserver(host, hass, add_devices): # Request configuration on connect fail else: + print('WEEEEJ, host: %s'%host) try: # Get configuration from config file # FIXME unauthenticated plex servers dont require @@ -83,20 +86,16 @@ def setup_plexserver(host, hass, add_devices): conf_dict = eval(f.read()) plexserver = PlexServer( - host, + 'http://%s'%host, conf_dict.get(host)['token']) except IOError: # File not found + request_configuration(host, hass, add_devices_callback) + return except NotFound: # Wrong host was given or need token? _LOGGER.exception("Error connecting to the Hue bridge at %s", host) return - except phue.PhueRegistrationException: - _LOGGER.warning("Connected to Hue at %s but not registered.", host) - - request_configuration(host, hass, add_devices_callback) - return - plex_clients = {} plex_sessions = {} @@ -143,6 +142,30 @@ def setup_plexserver(host, hass, add_devices): update_sessions() +def request_configuration(host, hass, add_devices_callback): + """ Request configuration steps from the user. """ + configurator = get_component('configurator') + + # We got an error if this method is called while we are configuring + if host in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING[host], "Failed to register, please try again.") + + return + + def plex_configuration_callback(data): + """ Actions to do when our configuration callback is called. """ + setup_plexserrver(host, hass, add_devices_callback) + + _CONFIGURING[host] = configurator.request_config( + hass, "Plex Media Server", plex_configuration_callback, + description=("Enter the X-Plex-Token as descrobed here
" + 'Plex documentation'), + description_image="/static/images/config_plexserver.jpg", + submit_caption="I have pressed the button" + ) + + class PlexClient(MediaPlayerDevice): """ Represents a Plex device. """ From 884525df33ffb939b354177d6f99ead8ccb28840 Mon Sep 17 00:00:00 2001 From: Tom Duijf Date: Thu, 22 Oct 2015 21:16:04 +0000 Subject: [PATCH 5/8] Basic discovery works, added plex logo for configurator. Missing configurator support for fields. Todo: config save on successful connect --- .../images/config_plex_mediaserver.png | Bin 0 -> 18619 bytes homeassistant/components/media_player/plex.py | 102 ++++++++---------- 2 files changed, 47 insertions(+), 55 deletions(-) create mode 100644 homeassistant/components/frontend/www_static/images/config_plex_mediaserver.png diff --git a/homeassistant/components/frontend/www_static/images/config_plex_mediaserver.png b/homeassistant/components/frontend/www_static/images/config_plex_mediaserver.png new file mode 100644 index 0000000000000000000000000000000000000000..97a1b4b352cdbf2629e1612db15e30bcf2af18a4 GIT binary patch literal 18619 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" - 'Plex documentation'), - description_image="/static/images/config_plexserver.jpg", - submit_caption="I have pressed the button" + description=('Enter the X-Plex-Token'), + description_image="/static/images/config_plex_mediaserver.png", + submit_caption="Confirm", + fields=[{'Token':'token'}] ) From 847d9736aa002a6db025d0b4633eccf373231b56 Mon Sep 17 00:00:00 2001 From: Tom Duijf Date: Sun, 25 Oct 2015 10:45:15 +0000 Subject: [PATCH 6/8] Configurator works, config saving basic implementation --- homeassistant/components/media_player/plex.py | 129 +++++++++++------- 1 file changed, 83 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 4651a963fea..dc9b7e82070 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -6,7 +6,7 @@ Provides an interface to the Plex API. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.plex.html """ -import logging +import logging, json, os from datetime import timedelta from urllib.parse import urlparse @@ -31,60 +31,91 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK +def config_from_file(filename, config=None): + ''' Small configuration file management function''' + if config: + # We're writing configuration + try: + with open(filename,'w') as f: + f.write(json.dumps(config)) + except IOError as e: + _LOGGER.error('Saving config file failed: %s'%e) + return False + return True + else: + # We're reading config + if os.path.isfile(filename): + try: + with open(filename,'r') as f: + return json.loads(f.read()) + except IOError as e: + _LOGGER.error('Reading config file failed: %s'%e) + return False + else: + return {} + # pylint: disable=abstract-method, unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Sets up the plex platform. """ + # Via discovery if discovery_info is not None: # Parse discovery data host = urlparse(discovery_info[1]).netloc _LOGGER.info('Discovered PLEX server: %s'%host) - else: - host = config.get(CONF_HOST, None) - if host in _CONFIGURING: - return - - setup_plexserver(host, hass, add_devices_callback) - - -def setup_plexserver(host, hass, add_devices_callback): - ''' Setup a plexserver based on host parameter''' - import plexapi - - # Config parsing & discovery mix - conf_file = hass.config.path(PLEX_CONFIG_FILE) - try: - with open(conf_file,'r') as f: - conf_dict = eval(f.read()) - except IOError: # File not found - if host == None: - # No discovery, no config, quit here + if host in _CONFIGURING: return - conf_dict = {} - - if host == None: - # Called by module inclusion, let's only use config - host,token = conf_dict.popitem() - token = token['token'] - elif host not in conf_dict.keys(): - # Not in config - conf_dict[host] = { 'token' : '' } token = None + else: + # Setup a configured PlexServer + config = config_from_file(hass.config.path(PLEX_CONFIG_FILE)) + if len(config): + host,token = config.popitem() + token = token['token'] + else: + # Empty config file? + return + + setup_plexserver(host, token, hass, add_devices_callback) + + +def setup_plexserver(host, token, hass, add_devices_callback): + ''' Setup a plexserver based on host parameter''' + from plexapi.server import PlexServer + from plexapi.exceptions import BadRequest + import plexapi + _LOGGER.info('Connecting to: htts://%s using token: %s' % (host, token)) try: - plexserver = plexapi.PlexServer('http://%s'%host, token) - except Exception: + plexserver = plexapi.server.PlexServer('http://%s'%host, token) + except (plexapi.exceptions.BadRequest, + plexapi.exceptions.Unauthorized, + plexapi.exceptions.NotFound) as e: + _LOGGER.info(e) + # No token or wrong token request_configuration(host, hass, add_devices_callback) return - except plexapi.exceptions.BadRequest as e: - _LOGGER.error('BLABLA1') - request_configuration(host, hass, add_devices_callback) + except Exception as e: + _LOGGER.error('Misc Exception : %s'%e) return + # If we came here and configuring this host, mark as done + if host in _CONFIGURING: + request_id = _CONFIGURING.pop(host) + configurator = get_component('configurator') + configurator.request_done(request_id) + _LOGGER.info('Discovery configuration done!') + + # Save config + if not config_from_file( + hass.config.path(PLEX_CONFIG_FILE), + {host: {'token': token}}): + _LOGGER.error('failed to save config file') + _LOGGER.info('Connected to: htts://%s using token: %s' % (host, token)) @@ -96,7 +127,7 @@ def setup_plexserver(host, hass, add_devices_callback): """ Updates the devices objects. """ try: devices = plexserver.clients() - except BadRequest: + except plexapi.exceptions.BadRequest: _LOGGER.exception("Error listing plex devices") return @@ -115,14 +146,14 @@ def setup_plexserver(host, hass, add_devices_callback): plex_clients[device.machineIdentifier].set_device(device) if new_plex_clients: - add_devices(new_plex_clients) + add_devices_callback(new_plex_clients) @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update_sessions(): """ Updates the sessions objects. """ try: sessions = plexserver.sessions() - except BadRequest: + except plexapi.exceptions.BadRequest: _LOGGER.exception("Error listing plex sessions") return @@ -147,14 +178,14 @@ def request_configuration(host, hass, add_devices_callback): def plex_configuration_callback(data): """ Actions to do when our configuration callback is called. """ - setup_plexserver(host, hass, add_devices_callback) + setup_plexserver(host, data.get('token'), hass, add_devices_callback) _CONFIGURING[host] = configurator.request_config( hass, "Plex Media Server", plex_configuration_callback, description=('Enter the X-Plex-Token'), description_image="/static/images/config_plex_mediaserver.png", submit_caption="Confirm", - fields=[{'Token':'token'}] + fields=[{'id': 'token', 'name':'X-Plex-Token', 'type':''}] ) @@ -172,6 +203,17 @@ class PlexClient(MediaPlayerDevice): """ Sets the device property. """ self.device = device + @property + def unique_id(self): + """ Returns the id of this plex client """ + return "{}.{}".format( + self.__class__, self.device.machineIdentifier or self.device.name ) + + @property + def name(self): + """ Returns the name of the device. """ + return self.device.name or self.device.product or self.device.deviceClass + @property def session(self): """ Returns the session, if any. """ @@ -180,11 +222,6 @@ class PlexClient(MediaPlayerDevice): return self.plex_sessions[self.device.machineIdentifier] - @property - def name(self): - """ Returns the name of the device. """ - return self.device.name or self.device.product or self.device.device - @property def state(self): """ Returns the state of the device. """ @@ -194,7 +231,7 @@ class PlexClient(MediaPlayerDevice): return STATE_PLAYING elif state == 'paused': return STATE_PAUSED - # This is nasty. Need ti find a way to determine alive + # This is nasty. Need to find a way to determine alive elif self.device: return STATE_IDLE else: From 5b25d9ccd6e1fa8d82ce3971390b2677ac12fea2 Mon Sep 17 00:00:00 2001 From: Tom Duijf Date: Sun, 25 Oct 2015 17:00:54 +0000 Subject: [PATCH 7/8] flake8,pylint and other code cleanup --- homeassistant/components/discovery.py | 1 + homeassistant/components/media_player/plex.py | 57 +++++++++---------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 1e04f20ea3e..b75abc32e4b 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -90,6 +90,7 @@ def setup(hass, config): ATTR_DISCOVERED: info }) + # pylint: disable=unused-argument def start_discovery(event): """ Start discovering. """ netdisco = DiscoveryService(SCAN_INTERVAL) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index dc9b7e82070..eeed715b337 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -6,7 +6,9 @@ Provides an interface to the Plex API. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.plex.html """ -import logging, json, os +import os +import json +import logging from datetime import timedelta from urllib.parse import urlparse @@ -16,7 +18,7 @@ from homeassistant.components.media_player import ( MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO) from homeassistant.const import ( - CONF_HOST, DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_PLAYING, + DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF, STATE_UNKNOWN) REQUIREMENTS = ['plexapi==1.1.0'] @@ -31,29 +33,30 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + def config_from_file(filename, config=None): ''' Small configuration file management function''' if config: # We're writing configuration try: - with open(filename,'w') as f: - f.write(json.dumps(config)) - except IOError as e: - _LOGGER.error('Saving config file failed: %s'%e) + with open(filename, 'w') as fdesc: + fdesc.write(json.dumps(config)) + except IOError as error: + _LOGGER.error('Saving config file failed: %s', error) return False return True else: # We're reading config if os.path.isfile(filename): try: - with open(filename,'r') as f: - return json.loads(f.read()) - except IOError as e: - _LOGGER.error('Reading config file failed: %s'%e) + with open(filename, 'r') as fdesc: + return json.loads(fdesc.read()) + except IOError as error: + _LOGGER.error('Reading config file failed: %s', error) return False else: return {} - + # pylint: disable=abstract-method, unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): @@ -63,7 +66,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): if discovery_info is not None: # Parse discovery data host = urlparse(discovery_info[1]).netloc - _LOGGER.info('Discovered PLEX server: %s'%host) + _LOGGER.info('Discovered PLEX server: %s', host) if host in _CONFIGURING: return @@ -73,7 +76,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): # Setup a configured PlexServer config = config_from_file(hass.config.path(PLEX_CONFIG_FILE)) if len(config): - host,token = config.popitem() + host, token = config.popitem() token = token['token'] else: # Empty config file? @@ -82,25 +85,22 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): setup_plexserver(host, token, hass, add_devices_callback) +# pylint: disable=too-many-branches def setup_plexserver(host, token, hass, add_devices_callback): ''' Setup a plexserver based on host parameter''' - from plexapi.server import PlexServer - from plexapi.exceptions import BadRequest import plexapi - _LOGGER.info('Connecting to: htts://%s using token: %s' % - (host, token)) try: - plexserver = plexapi.server.PlexServer('http://%s'%host, token) + plexserver = plexapi.server.PlexServer('http://%s' % host, token) except (plexapi.exceptions.BadRequest, - plexapi.exceptions.Unauthorized, - plexapi.exceptions.NotFound) as e: - _LOGGER.info(e) + plexapi.exceptions.Unauthorized) as error: + _LOGGER.info(error) # No token or wrong token request_configuration(host, hass, add_devices_callback) return - except Exception as e: - _LOGGER.error('Misc Exception : %s'%e) + except plexapi.exceptions.NotFound: + # Host not found. Maybe it's off. Just log it and stop + _LOGGER.info(error) return # If we came here and configuring this host, mark as done @@ -116,8 +116,7 @@ def setup_plexserver(host, token, hass, add_devices_callback): {host: {'token': token}}): _LOGGER.error('failed to save config file') - _LOGGER.info('Connected to: htts://%s using token: %s' % - (host, token)) + _LOGGER.info('Connected to: htts://%s', host) plex_clients = {} plex_sessions = {} @@ -185,14 +184,14 @@ def request_configuration(host, hass, add_devices_callback): description=('Enter the X-Plex-Token'), description_image="/static/images/config_plex_mediaserver.png", submit_caption="Confirm", - fields=[{'id': 'token', 'name':'X-Plex-Token', 'type':''}] + fields=[{'id': 'token', 'name': 'X-Plex-Token', 'type': ''}] ) class PlexClient(MediaPlayerDevice): """ Represents a Plex device. """ - # pylint: disable=too-many-public-methods + # pylint: disable=too-many-public-methods, attribute-defined-outside-init def __init__(self, device, plex_sessions, update_devices, update_sessions): self.plex_sessions = plex_sessions self.update_devices = update_devices @@ -207,12 +206,12 @@ class PlexClient(MediaPlayerDevice): def unique_id(self): """ Returns the id of this plex client """ return "{}.{}".format( - self.__class__, self.device.machineIdentifier or self.device.name ) + self.__class__, self.device.machineIdentifier or self.device.name) @property def name(self): """ Returns the name of the device. """ - return self.device.name or self.device.product or self.device.deviceClass + return self.device.name or DEVICE_DEFAULT_NAME @property def session(self): From bc8c5766d4c320dd341ffc6b2169ae408ca06bb9 Mon Sep 17 00:00:00 2001 From: Tom Duijf Date: Sun, 25 Oct 2015 17:54:48 +0000 Subject: [PATCH 8/8] Logic fixes --- homeassistant/components/media_player/plex.py | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index eeed715b337..b8267d286d3 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -53,6 +53,7 @@ def config_from_file(filename, config=None): return json.loads(fdesc.read()) except IOError as error: _LOGGER.error('Reading config file failed: %s', error) + # This won't work yet return False else: return {} @@ -62,8 +63,13 @@ def config_from_file(filename, config=None): def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Sets up the plex platform. """ + config = config_from_file(hass.config.path(PLEX_CONFIG_FILE)) + if len(config): + # Setup a configured PlexServer + host, token = config.popitem() + token = token['token'] # Via discovery - if discovery_info is not None: + elif discovery_info is not None: # Parse discovery data host = urlparse(discovery_info[1]).netloc _LOGGER.info('Discovered PLEX server: %s', host) @@ -71,16 +77,8 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): if host in _CONFIGURING: return token = None - else: - # Setup a configured PlexServer - config = config_from_file(hass.config.path(PLEX_CONFIG_FILE)) - if len(config): - host, token = config.popitem() - token = token['token'] - else: - # Empty config file? - return + return setup_plexserver(host, token, hass, add_devices_callback) @@ -88,20 +86,18 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): # pylint: disable=too-many-branches def setup_plexserver(host, token, hass, add_devices_callback): ''' Setup a plexserver based on host parameter''' - import plexapi + import plexapi.server + import plexapi.exceptions try: plexserver = plexapi.server.PlexServer('http://%s' % host, token) except (plexapi.exceptions.BadRequest, - plexapi.exceptions.Unauthorized) as error: + plexapi.exceptions.Unauthorized, + plexapi.exceptions.NotFound) as error: _LOGGER.info(error) # No token or wrong token request_configuration(host, hass, add_devices_callback) return - except plexapi.exceptions.NotFound: - # Host not found. Maybe it's off. Just log it and stop - _LOGGER.info(error) - return # If we came here and configuring this host, mark as done if host in _CONFIGURING: