diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 27e61a7863b..f5677b271a3 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -10,16 +10,35 @@ import os from datetime import timedelta from urllib.parse import urlparse -import homeassistant.util as util +import requests +import voluptuous as vol +from homeassistant import util from homeassistant.components.media_player import ( - MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, - SUPPORT_PREVIOUS_TRACK, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_VOLUME_SET, - SUPPORT_PLAY, MediaPlayerDevice) + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_TVSHOW, + MEDIA_TYPE_VIDEO, + PLATFORM_SCHEMA, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + MediaPlayerDevice, +) from homeassistant.const import ( - DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN) + DEVICE_DEFAULT_NAME, + STATE_IDLE, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import track_utc_time_change from homeassistant.loader import get_component -from homeassistant.helpers.event import (track_utc_time_change) + REQUIREMENTS = ['plexapi==2.0.2'] MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) @@ -27,13 +46,24 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) PLEX_CONFIG_FILE = 'plex.conf' +CONF_INCLUDE_NON_CLIENTS = 'include_non_clients' +CONF_USE_EPISODE_ART = 'use_episode_art' +CONF_USE_CUSTOM_ENTITY_IDS = 'use_custom_entity_ids' +CONF_SHOW_ALL_CONTROLS = 'show_all_controls' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_INCLUDE_NON_CLIENTS, default=False): + cv.boolean, + vol.Optional(CONF_USE_EPISODE_ART, default=False): + cv.boolean, + vol.Optional(CONF_USE_CUSTOM_ENTITY_IDS, default=False): + cv.boolean, +}) + # Map ip to request id for configuring _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ - SUPPORT_STOP | SUPPORT_VOLUME_SET | SUPPORT_PLAY - def config_from_file(filename, config=None): """Small configuration file management function.""" @@ -62,10 +92,12 @@ def config_from_file(filename, config=None): def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup the Plex platform.""" - config = config_from_file(hass.config.path(PLEX_CONFIG_FILE)) - if len(config): + # get config from plex.conf + file_config = config_from_file(hass.config.path(PLEX_CONFIG_FILE)) + + if len(file_config): # Setup a configured PlexServer - host, token = config.popitem() + host, token = file_config.popitem() token = token['token'] # Via discovery elif discovery_info is not None: @@ -79,22 +111,22 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): else: return - setup_plexserver(host, token, hass, add_devices_callback) + setup_plexserver(host, token, hass, config, add_devices_callback) -def setup_plexserver(host, token, hass, add_devices_callback): +def setup_plexserver(host, token, hass, config, add_devices_callback): """Setup a plexserver based on host parameter.""" import plexapi.server import plexapi.exceptions try: plexserver = plexapi.server.PlexServer('http://%s' % host, token) - except (plexapi.exceptions.BadRequest, - plexapi.exceptions.Unauthorized, + except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized, plexapi.exceptions.NotFound) as error: _LOGGER.info(error) # No token or wrong token - request_configuration(host, hass, add_devices_callback) + request_configuration(host, hass, config, + add_devices_callback) return # If we came here and configuring this host, mark as done @@ -106,8 +138,9 @@ def setup_plexserver(host, token, hass, add_devices_callback): # Save config if not config_from_file( - hass.config.path(PLEX_CONFIG_FILE), - {host: {'token': token}}): + hass.config.path(PLEX_CONFIG_FILE), {host: { + 'token': token + }}): _LOGGER.error('failed to save config file') _LOGGER.info('Connected to: http://%s', host) @@ -117,6 +150,7 @@ def setup_plexserver(host, token, hass, add_devices_callback): track_utc_time_change(hass, lambda now: update_devices(), second=30) @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + # pylint: disable=too-many-branches def update_devices(): """Update the devices objects.""" try: @@ -125,8 +159,8 @@ def setup_plexserver(host, token, hass, add_devices_callback): _LOGGER.exception('Error listing plex devices') return except OSError: - _LOGGER.error( - 'Could not connect to plex server at http://%s', host) + _LOGGER.error('Could not connect to plex server at http://%s', + host) return new_plex_clients = [] @@ -136,12 +170,31 @@ def setup_plexserver(host, token, hass, add_devices_callback): continue if device.machineIdentifier not in plex_clients: - new_client = PlexClient(device, plex_sessions, update_devices, + new_client = PlexClient(config, device, None, + plex_sessions, update_devices, update_sessions) plex_clients[device.machineIdentifier] = new_client new_plex_clients.append(new_client) else: - plex_clients[device.machineIdentifier].set_device(device) + plex_clients[device.machineIdentifier].refresh(device, None) + + # add devices with a session and no client (ex. PlexConnect Apple TV's) + if config.get(CONF_INCLUDE_NON_CLIENTS): + for machine_identifier, session in plex_sessions.items(): + if (machine_identifier not in plex_clients + and machine_identifier is not None): + new_client = PlexClient(config, None, session, + plex_sessions, update_devices, + update_sessions) + plex_clients[machine_identifier] = new_client + new_plex_clients.append(new_client) + else: + plex_clients[machine_identifier].refresh(None, session) + + for machine_identifier, client in plex_clients.items(): + # force devices to idle that do not have a valid session + if client.session is None: + client.force_idle() if new_plex_clients: add_devices_callback(new_plex_clients) @@ -157,99 +210,333 @@ def setup_plexserver(host, token, hass, add_devices_callback): plex_sessions.clear() for session in sessions: - plex_sessions[session.player.machineIdentifier] = session + if (session.player is not None and + session.player.machineIdentifier is not None): + plex_sessions[session.player.machineIdentifier] = session - update_devices() update_sessions() + update_devices() -def request_configuration(host, hass, add_devices_callback): +def request_configuration(host, hass, config, 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.') + configurator.notify_errors(_CONFIGURING[host], + 'Failed to register, please try again.') return def plex_configuration_callback(data): """The actions to do when our configuration callback is called.""" - setup_plexserver(host, data.get('token'), hass, add_devices_callback) + setup_plexserver(host, + data.get('token'), hass, config, + add_devices_callback) _CONFIGURING[host] = configurator.request_config( - hass, 'Plex Media Server', plex_configuration_callback, + hass, + 'Plex Media Server', + plex_configuration_callback, description=('Enter the X-Plex-Token'), entity_picture='/static/images/logo_plex_mediaserver.png', submit_caption='Confirm', - fields=[{'id': 'token', 'name': 'X-Plex-Token', 'type': ''}] - ) + fields=[{ + 'id': 'token', + 'name': 'X-Plex-Token', + 'type': '' + }]) +# pylint: disable=too-many-instance-attributes, too-many-public-methods class PlexClient(MediaPlayerDevice): """Representation of a Plex device.""" - # pylint: disable=attribute-defined-outside-init - def __init__(self, device, plex_sessions, update_devices, update_sessions): + # pylint: disable=too-many-arguments + def __init__(self, config, device, session, plex_sessions, + update_devices, update_sessions): """Initialize the Plex device.""" from plexapi.utils import NA - + self._app_name = '' + self._device = None + self._device_protocol_capabilities = None + self._is_player_active = False + self._is_player_available = False + self._machine_identifier = None + self._make = '' + self._media_content_id = None + self._media_content_type = None + self._media_duration = None + self._media_image_url = None + self._media_title = None + self._name = None + self._player_state = 'idle' + self._previous_volume_level = 1 # Used in fake muting + self._session = None + self._session_type = None + self._state = STATE_IDLE + self._volume_level = 1 # since we can't retrieve remotely + self._volume_muted = False # since we can't retrieve remotely self.na_type = NA + self.config = config self.plex_sessions = plex_sessions self.update_devices = update_devices self.update_sessions = update_sessions - self.set_device(device) - self._season = None - def set_device(self, device): - """Set the device property.""" - self.device = device + # Music + self._media_album_artist = None + self._media_album_name = None + self._media_artist = None + self._media_track = None + + # TV Show + self._media_episode = None + self._media_season = None + self._media_series_title = None + + self.refresh(device, session) + + # Assign custom entity ID if desired + if self.config.get(CONF_USE_CUSTOM_ENTITY_IDS): + prefix = '' + # allow for namespace prefixing when using custom entity names + if config.get("entity_namespace"): + prefix = config.get("entity_namespace") + '_' + + # rename the entity id + if self.machine_identifier: + self.entity_id = "%s.%s%s" % ( + 'media_player', prefix, + self.machine_identifier.lower().replace('-', '_')) + else: + if self.name: + self.entity_id = "%s.%s%s" % ( + 'media_player', prefix, + self.name.lower().replace('-', '_')) + + # pylint: disable=too-many-branches, too-many-statements + def refresh(self, device, session): + """Refresh key device data.""" + # new data refresh + if session: + self._session = session + if device: + self._device = device + self._session = None + + if self._device: + self._machine_identifier = self._convert_na_to_none( + self._device.machineIdentifier) + self._name = self._convert_na_to_none( + self._device.title) or DEVICE_DEFAULT_NAME + self._device_protocol_capabilities = ( + self._device.protocolCapabilities) + + # set valid session, preferring device session + if self._device and self.plex_sessions.get( + self._device.machineIdentifier, None): + self._session = self._convert_na_to_none(self.plex_sessions.get( + self._device.machineIdentifier, None)) + + if self._session: + self._media_position = self._convert_na_to_none( + self._session.viewOffset) + self._media_content_id = self._convert_na_to_none( + self._session.ratingKey) + else: + self._media_position = None + self._media_content_id = None + + # player dependent data + if self._session and self._session.player: + self._is_player_available = True + self._machine_identifier = self._convert_na_to_none( + self._session.player.machineIdentifier) + self._name = self._convert_na_to_none(self._session.player.title) + self._player_state = self._session.player.state + self._make = self._convert_na_to_none(self._session.player.device) + else: + self._is_player_available = False + + if self._player_state == 'playing': + self._is_player_active = True + self._state = STATE_PLAYING + elif self._player_state == 'paused': + self._is_player_active = True + self._state = STATE_PAUSED + elif self.device: + self._is_player_active = False + self._state = STATE_IDLE + else: + self._is_player_active = False + self._state = STATE_OFF + + if self._is_player_active and self._session is not None: + self._session_type = self._session.type + self._media_duration = self._convert_na_to_none( + self._session.duration) + else: + self._session_type = None + self._media_duration = None + + # media type + if self._session_type == 'clip': + _LOGGER.debug('Clip content type detected, ' + 'compatibility may vary: %s', + self.entity_id) + self._media_content_type = MEDIA_TYPE_TVSHOW + elif self._session_type == 'episode': + self._media_content_type = MEDIA_TYPE_TVSHOW + elif self._session_type == 'movie': + self._media_content_type = MEDIA_TYPE_VIDEO + elif self._session_type == 'track': + self._media_content_type = MEDIA_TYPE_MUSIC + else: + self._media_content_type = None + + # title (movie name, tv episode name, music song name) + if self._session: + self._media_title = self._convert_na_to_none(self._session.title) + + # Movies + if (self.media_content_type == MEDIA_TYPE_VIDEO and + self._convert_na_to_none(self._session.year) is not None): + self._media_title += ' (' + str(self._session.year) + ')' + + # TV Show + if (self._is_player_active and + self._media_content_type is MEDIA_TYPE_TVSHOW): + + # season number (00) + if callable(self._convert_na_to_none(self._session.seasons)): + self._media_season = self._convert_na_to_none( + self._session.seasons()[0].index).zfill(2) + elif self._convert_na_to_none( + self._session.parentIndex) is not None: + self._media_season = self._session.parentIndex.zfill(2) + else: + self._media_season = None + + # show name + self._media_series_title = self._convert_na_to_none( + self._session.grandparentTitle) + + # episode number (00) + if self._convert_na_to_none( + self._session.index) is not None: + self._media_episode = str(self._session.index).zfill(2) + else: + self._media_season = None + self._media_series_title = None + self._media_episode = None + + # Music + if (self._is_player_active and + self._media_content_type == MEDIA_TYPE_MUSIC): + self._media_album_name = self._convert_na_to_none( + self._session.parentTitle) + self._media_album_artist = self._convert_na_to_none( + self._session.grandparentTitle) + self._media_track = self._convert_na_to_none(self._session.index) + self._media_artist = self._convert_na_to_none( + self._session.originalTitle) + # use album artist if track artist is missing + if self._media_artist is None: + _LOGGER.debug( + 'Using album artist because track artist ' + 'was not found: %s', self.entity_id) + self._media_artist = self._media_album_artist + else: + self._media_album_name = None + self._media_album_artist = None + self._media_track = None + self._media_artist = None + + # set app name to library name + if (self._session is not None + and self._session.librarySectionID is not None): + self._app_name = self._convert_na_to_none( + self._session.server.library.sectionByID( + self._session.librarySectionID).title) + else: + self._app_name = '' + + # media image url + if self._session is not None: + thumb_url = self._get_thumbnail_url(self._session.thumb) + if (self.media_content_type is MEDIA_TYPE_TVSHOW + and not self.config.get(CONF_USE_EPISODE_ART)): + thumb_url = self._get_thumbnail_url( + self._session.grandparentThumb) + + if thumb_url is None: + _LOGGER.debug('Using media art because media thumb ' + 'was not found: %s', self.entity_id) + thumb_url = self._get_thumbnail_url(self._session.art) + + self._media_image_url = thumb_url + else: + self._media_image_url = None + + def _get_thumbnail_url(self, property_value): + """Return full URL (if exists) for a thumbnail property.""" + if self._convert_na_to_none(property_value) is None: + return None + + if self._session is None or self._session.server is None: + return None + + url = self._session.server.url(property_value) + response = requests.get(url, verify=False) + if response and response.status_code == 200: + return url + + def force_idle(self): + """Force client to idle.""" + self._state = STATE_IDLE + self._session = None @property def unique_id(self): """Return the id of this plex client.""" - return '{}.{}'.format( - self.__class__, self.device.machineIdentifier or self.device.name) + return '{}.{}'.format(self.__class__, self.machine_identifier or + self.name) @property def name(self): """Return the name of the device.""" - return self.device.title or DEVICE_DEFAULT_NAME + return self._name + + @property + def machine_identifier(self): + """Return the machine identifier of the device.""" + return self._machine_identifier + + @property + def app_name(self): + """Return the library name of playing media.""" + return self._app_name + + @property + def device(self): + """Return the device, if any.""" + return self._device @property def session(self): """Return the session, if any.""" - return self.plex_sessions.get(self.device.machineIdentifier, None) + return self._session @property def state(self): """Return the state of the device.""" - if self.session and self.session.player: - state = self.session.player.state - if state == 'playing': - return STATE_PLAYING - elif state == 'paused': - return STATE_PAUSED - # This is nasty. Need to find a way to determine alive - elif self.device: - return STATE_IDLE - else: - return STATE_OFF - - return STATE_UNKNOWN + return self._state def update(self): """Get the latest details.""" - from plexapi.video import Show - self.update_devices(no_throttle=True) self.update_sessions(no_throttle=True) - if isinstance(self.session, Show): - self._season = self._convert_na_to_none( - self.session.seasons()[0].index) - # pylint: disable=no-self-use, singleton-comparison def _convert_na_to_none(self, value): """Convert PlexAPI _NA() instances to None.""" @@ -272,93 +559,301 @@ class PlexClient(MediaPlayerDevice): @property def media_content_id(self): """Content ID of current playing media.""" - if self.session is not None: - return self._convert_na_to_none(self.session.ratingKey) + return self._media_content_id @property def media_content_type(self): """Content type of current playing media.""" - if self.session is None: - return None - media_type = self.session.type - if media_type == 'episode': + if self._session_type == 'clip': + _LOGGER.debug('Clip content type detected, ' + 'compatibility may vary: %s', + self.entity_id) return MEDIA_TYPE_TVSHOW - elif media_type == 'movie': + elif self._session_type == 'episode': + return MEDIA_TYPE_TVSHOW + elif self._session_type == 'movie': return MEDIA_TYPE_VIDEO - elif media_type == 'track': + elif self._session_type == 'track': return MEDIA_TYPE_MUSIC - return None + else: + return None + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._media_artist + + @property + def media_album_name(self): + """Album name of current playing media, music track only.""" + return self._media_album_name + + @property + def media_album_artist(self): + """Album artist of current playing media, music track only.""" + return self._media_album_artist + + @property + def media_track(self): + """Track number of current playing media, music track only.""" + return self._media_track @property def media_duration(self): """Duration of current playing media in seconds.""" - if self.session is not None: - return self._convert_na_to_none(self.session.duration) + return self._media_duration @property def media_image_url(self): """Image url of current playing media.""" - if self.session is not None: - thumb_url = self._convert_na_to_none(self.session.thumbUrl) - if str(self.na_type) in thumb_url: - # Audio tracks build their thumb urls internally before passing - # back a URL with the PlexAPI _NA type already converted to a - # string and embedded into a malformed URL - thumb_url = None - return thumb_url + return self._media_image_url @property def media_title(self): """Title of current playing media.""" - # find a string we can use as a title - if self.session is not None: - return self._convert_na_to_none(self.session.title) + return self._media_title @property def media_season(self): """Season of curent playing media (TV Show only).""" - return self._season + return self._media_season @property def media_series_title(self): """The title of the series of current playing media (TV Show only).""" - from plexapi.video import Show - if isinstance(self.session, Show): - return self._convert_na_to_none(self.session.grandparentTitle) + return self._media_series_title @property def media_episode(self): """Episode of current playing media (TV Show only).""" - from plexapi.video import Show - if isinstance(self.session, Show): - return self._convert_na_to_none(self.session.index) + return self._media_episode + + @property + def make(self): + """The make of the device (ex. SHIELD Android TV).""" + return self._make @property def supported_features(self): """Flag media player features that are supported.""" - return SUPPORT_PLEX + features = None + if not self._is_player_active: + features = None + + # force show all controls + if self.config.get(CONF_SHOW_ALL_CONTROLS): + features = (SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | + SUPPORT_NEXT_TRACK | SUPPORT_STOP | + SUPPORT_VOLUME_SET | SUPPORT_PLAY | + SUPPORT_TURN_OFF | SUPPORT_VOLUME_MUTE) + + # only show controls when we know what device is connecting + if not self._make: + features = None + # no mute support + elif self.make.lower() == "shield android tv": + _LOGGER.debug( + 'Shield Android TV client detected, disabling mute ' + 'controls: %s', self.entity_id) + features = (SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | + SUPPORT_NEXT_TRACK | SUPPORT_STOP | + SUPPORT_VOLUME_SET | SUPPORT_PLAY | + SUPPORT_TURN_OFF) + # Only supports play,pause,stop (and off which really is stop) + elif self.make.lower().startswith("tivo"): + _LOGGER.debug( + 'Tivo client detected, only enabling pause, play, ' + 'stop, and off controls: %s', self.entity_id) + features = (SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_STOP | + SUPPORT_TURN_OFF) + # Not all devices support playback functionality + # Playback includes volume, stop/play/pause, etc. + elif self.device and 'playback' in self._device_protocol_capabilities: + features = (SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | + SUPPORT_NEXT_TRACK | SUPPORT_STOP | + SUPPORT_VOLUME_SET | SUPPORT_PLAY | + SUPPORT_TURN_OFF | SUPPORT_VOLUME_MUTE) + else: + features = None + + return features + + def _local_client_control_fix(self): + """Detect if local client and adjust url to allow control.""" + if self.device is None: + return + + # if this device's machineIdentifier matches an active client + # with a loopback address, the device must be local or casting + for client in self.device.server.clients(): + if ("127.0.0.1" in client.baseurl and + client.machineIdentifier == self.device.machineIdentifier): + # point controls to server since that's where the + # playback is occuring + _LOGGER.debug( + 'Local client detected, redirecting controls to ' + 'Plex server: %s', self.entity_id) + server_url = self.device.server.baseurl + client_url = self.device.baseurl + self.device.baseurl = "{}://{}:{}".format( + urlparse(client_url).scheme, + urlparse(server_url).hostname, + str(urlparse(client_url).port)) def set_volume_level(self, volume): """Set volume level, range 0..1.""" - self.device.setVolume(int(volume * 100), - self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self._local_client_control_fix() + self.device.setVolume( + int(volume * 100), self._active_media_plexapi_type) + self._volume_level = volume # store since we can't retrieve + + @property + def volume_level(self): + """Return the volume level of the client (0..1).""" + if (self._is_player_active and self.device and + 'playback' in self._device_protocol_capabilities): + return self._volume_level + + @property + def is_volume_muted(self): + """Return boolean if volume is currently muted.""" + if self._is_player_active and self.device: + return self._volume_muted + + def mute_volume(self, mute): + """Mute the volume. + + Since we can't actually mute, we'll: + - On mute, store volume and set volume to 0 + - On unmute, set volume to previously stored volume + """ + if not (self.device and + 'playback' in self._device_protocol_capabilities): + return + + self._volume_muted = mute + if mute: + self._previous_volume_level = self._volume_level + self.set_volume_level(0) + else: + self.set_volume_level(self._previous_volume_level) def media_play(self): """Send play command.""" - self.device.play(self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self._local_client_control_fix() + self.device.play(self._active_media_plexapi_type) def media_pause(self): """Send pause command.""" - self.device.pause(self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self._local_client_control_fix() + self.device.pause(self._active_media_plexapi_type) def media_stop(self): """Send stop command.""" - self.device.stop(self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self._local_client_control_fix() + self.device.stop(self._active_media_plexapi_type) + + def turn_off(self): + """Turn the client off.""" + # Fake it since we can't turn the client off + self.media_stop() def media_next_track(self): """Send next track command.""" - self.device.skipNext(self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self._local_client_control_fix() + self.device.skipNext(self._active_media_plexapi_type) def media_previous_track(self): """Send previous track command.""" - self.device.skipPrevious(self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self._local_client_control_fix() + self.device.skipPrevious(self._active_media_plexapi_type) + + # pylint: disable=W0613 + def play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + if not (self.device and + 'playback' in self._device_protocol_capabilities): + return + + src = json.loads(media_id) + + media = None + if media_type == 'MUSIC': + media = self.device.server.library.section( + src['library_name']).get(src['artist_name']).album( + src['album_name']).get(src['track_name']) + elif media_type == 'EPISODE': + media = self._get_episode( + src['library_name'], src['show_name'], + src['season_number'], src['episode_number']) + elif media_type == 'PLAYLIST': + media = self.device.server.playlist(src['playlist_name']) + elif media_type == 'VIDEO': + media = self.device.server.library.section( + src['library_name']).get(src['video_name']) + + if media: + self._client_play_media(media, shuffle=src['shuffle']) + + def _get_episode(self, library_name, show_name, season_number, + episode_number): + """Find TV episode and return a Plex media object.""" + target_season = None + target_episode = None + + seasons = self.device.server.library.section(library_name).get( + show_name).seasons() + for season in seasons: + if int(season.seasonNumber) == int(season_number): + target_season = season + break + + if target_season is None: + _LOGGER.error('Season not found: %s\\%s - S%sE%s', library_name, + show_name, + str(season_number).zfill(2), + str(episode_number).zfill(2)) + else: + for episode in target_season.episodes(): + if int(episode.index) == int(episode_number): + target_episode = episode + break + + if target_episode is None: + _LOGGER.error('Episode not found: %s\\%s - S%sE%s', + library_name, show_name, + str(season_number).zfill(2), + str(episode_number).zfill(2)) + + return target_episode + + def _client_play_media(self, media, **params): + """Instruct Plex client to play a piece of media.""" + if not (self.device and + 'playback' in self._device_protocol_capabilities): + _LOGGER.error('Client cannot play media: %s', self.entity_id) + return + + import plexapi.playqueue + server_url = media.server.baseurl.split(':') + playqueue = plexapi.playqueue.PlayQueue.create(self.device.server, + media, **params) + self._local_client_control_fix() + self.device.sendCommand('playback/playMedia', **dict({ + 'machineIdentifier': + self.device.server.machineIdentifier, + 'address': + server_url[1].strip('/'), + 'port': + server_url[-1], + 'key': + media.key, + 'containerKey': + '/playQueues/%s?window=100&own=1' % playqueue.playQueueID, + }, **params))