If the updater is running at the same time, this can result in this dict changing size during iteration, which Python does not like.
896 lines
33 KiB
Python
896 lines
33 KiB
Python
"""Support to interface with the Plex API."""
|
|
from datetime import timedelta
|
|
import json
|
|
import logging
|
|
|
|
import requests
|
|
import voluptuous as vol
|
|
|
|
from homeassistant import util
|
|
from homeassistant.components.media_player import (
|
|
MediaPlayerDevice, PLATFORM_SCHEMA)
|
|
from homeassistant.components.media_player.const import (
|
|
MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW,
|
|
SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK,
|
|
SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
|
|
from homeassistant.const import (
|
|
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.util import dt as dt_util
|
|
from homeassistant.util.json import load_json, save_json
|
|
|
|
_CONFIGURING = {}
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
|
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
|
|
|
|
PLEX_CONFIG_FILE = 'plex.conf'
|
|
PLEX_DATA = 'plex'
|
|
|
|
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'
|
|
CONF_REMOVE_UNAVAILABLE_CLIENTS = 'remove_unavailable_clients'
|
|
CONF_CLIENT_REMOVE_INTERVAL = 'client_remove_interval'
|
|
|
|
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,
|
|
vol.Optional(CONF_SHOW_ALL_CONTROLS, default=False): cv.boolean,
|
|
vol.Optional(CONF_REMOVE_UNAVAILABLE_CLIENTS, default=True): cv.boolean,
|
|
vol.Optional(CONF_CLIENT_REMOVE_INTERVAL, default=timedelta(seconds=600)):
|
|
vol.All(cv.time_period, cv.positive_timedelta),
|
|
})
|
|
|
|
|
|
def setup_platform(hass, config, add_entities_callback, discovery_info=None):
|
|
"""Set up the Plex platform."""
|
|
if PLEX_DATA not in hass.data:
|
|
hass.data[PLEX_DATA] = {}
|
|
|
|
# get config from plex.conf
|
|
file_config = load_json(hass.config.path(PLEX_CONFIG_FILE))
|
|
|
|
if file_config:
|
|
# Setup a configured PlexServer
|
|
host, host_config = file_config.popitem()
|
|
token = host_config['token']
|
|
try:
|
|
has_ssl = host_config['ssl']
|
|
except KeyError:
|
|
has_ssl = False
|
|
try:
|
|
verify_ssl = host_config['verify']
|
|
except KeyError:
|
|
verify_ssl = True
|
|
|
|
# Via discovery
|
|
elif discovery_info is not None:
|
|
# Parse discovery data
|
|
host = discovery_info.get('host')
|
|
port = discovery_info.get('port')
|
|
host = '%s:%s' % (host, port)
|
|
_LOGGER.info("Discovered PLEX server: %s", host)
|
|
|
|
if host in _CONFIGURING:
|
|
return
|
|
token = None
|
|
has_ssl = False
|
|
verify_ssl = True
|
|
else:
|
|
return
|
|
|
|
setup_plexserver(
|
|
host, token, has_ssl, verify_ssl,
|
|
hass, config, add_entities_callback
|
|
)
|
|
|
|
|
|
def setup_plexserver(
|
|
host, token, has_ssl, verify_ssl, hass, config, add_entities_callback):
|
|
"""Set up a plexserver based on host parameter."""
|
|
import plexapi.server
|
|
import plexapi.exceptions
|
|
|
|
cert_session = None
|
|
http_prefix = 'https' if has_ssl else 'http'
|
|
if has_ssl and (verify_ssl is False):
|
|
_LOGGER.info("Ignoring SSL verification")
|
|
cert_session = requests.Session()
|
|
cert_session.verify = False
|
|
try:
|
|
plexserver = plexapi.server.PlexServer(
|
|
'%s://%s' % (http_prefix, host),
|
|
token, cert_session
|
|
)
|
|
_LOGGER.info("Discovery configuration done (no token needed)")
|
|
except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized,
|
|
plexapi.exceptions.NotFound) as error:
|
|
_LOGGER.info(error)
|
|
# No token or wrong token
|
|
request_configuration(host, hass, config, add_entities_callback)
|
|
return
|
|
|
|
# If we came here and configuring this host, mark as done
|
|
if host in _CONFIGURING:
|
|
request_id = _CONFIGURING.pop(host)
|
|
configurator = hass.components.configurator
|
|
configurator.request_done(request_id)
|
|
_LOGGER.info("Discovery configuration done")
|
|
|
|
# Save config
|
|
save_json(
|
|
hass.config.path(PLEX_CONFIG_FILE), {host: {
|
|
'token': token,
|
|
'ssl': has_ssl,
|
|
'verify': verify_ssl,
|
|
}})
|
|
|
|
_LOGGER.info('Connected to: %s://%s', http_prefix, host)
|
|
|
|
plex_clients = hass.data[PLEX_DATA]
|
|
plex_sessions = {}
|
|
track_utc_time_change(hass, lambda now: update_devices(), second=30)
|
|
|
|
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
|
|
def update_devices():
|
|
"""Update the devices objects."""
|
|
try:
|
|
devices = plexserver.clients()
|
|
except plexapi.exceptions.BadRequest:
|
|
_LOGGER.exception("Error listing plex devices")
|
|
return
|
|
except requests.exceptions.RequestException as ex:
|
|
_LOGGER.warning(
|
|
"Could not connect to plex server at http://%s (%s)", host, ex)
|
|
return
|
|
|
|
new_plex_clients = []
|
|
available_client_ids = []
|
|
for device in devices:
|
|
# For now, let's allow all deviceClass types
|
|
if device.deviceClass in ['badClient']:
|
|
continue
|
|
|
|
available_client_ids.append(device.machineIdentifier)
|
|
|
|
if device.machineIdentifier not in plex_clients:
|
|
new_client = PlexClient(
|
|
config, device, None, plex_sessions, update_devices,
|
|
update_sessions)
|
|
plex_clients[device.machineIdentifier] = new_client
|
|
_LOGGER.debug("New device: %s", device.machineIdentifier)
|
|
new_plex_clients.append(new_client)
|
|
else:
|
|
_LOGGER.debug("Refreshing device: %s",
|
|
device.machineIdentifier)
|
|
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):
|
|
# To avoid errors when plex sessions created during iteration
|
|
sessions = list(plex_sessions.items())
|
|
for machine_identifier, (session, player) in sessions:
|
|
if machine_identifier in available_client_ids:
|
|
# Avoid using session if already added as a device.
|
|
_LOGGER.debug("Skipping session, device exists: %s",
|
|
machine_identifier)
|
|
continue
|
|
|
|
if (machine_identifier not in plex_clients
|
|
and machine_identifier is not None):
|
|
new_client = PlexClient(
|
|
config, player, session, plex_sessions, update_devices,
|
|
update_sessions)
|
|
plex_clients[machine_identifier] = new_client
|
|
_LOGGER.debug("New session: %s", machine_identifier)
|
|
new_plex_clients.append(new_client)
|
|
else:
|
|
_LOGGER.debug("Refreshing session: %s", machine_identifier)
|
|
plex_clients[machine_identifier].refresh(None, session)
|
|
|
|
clients_to_remove = []
|
|
for client in plex_clients.values():
|
|
# force devices to idle that do not have a valid session
|
|
if client.session is None:
|
|
client.force_idle()
|
|
|
|
client.set_availability(client.machine_identifier
|
|
in available_client_ids
|
|
or client.machine_identifier
|
|
in plex_sessions)
|
|
|
|
if not config.get(CONF_REMOVE_UNAVAILABLE_CLIENTS) \
|
|
or client.available:
|
|
continue
|
|
|
|
if (dt_util.utcnow() - client.marked_unavailable) >= \
|
|
(config.get(CONF_CLIENT_REMOVE_INTERVAL)):
|
|
hass.add_job(client.async_remove())
|
|
clients_to_remove.append(client.machine_identifier)
|
|
|
|
while clients_to_remove:
|
|
del plex_clients[clients_to_remove.pop()]
|
|
|
|
if new_plex_clients:
|
|
add_entities_callback(new_plex_clients)
|
|
|
|
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
|
|
def update_sessions():
|
|
"""Update the sessions objects."""
|
|
try:
|
|
sessions = plexserver.sessions()
|
|
except plexapi.exceptions.BadRequest:
|
|
_LOGGER.exception("Error listing plex sessions")
|
|
return
|
|
except requests.exceptions.RequestException as ex:
|
|
_LOGGER.warning(
|
|
"Could not connect to plex server at http://%s (%s)", host, ex)
|
|
return
|
|
|
|
plex_sessions.clear()
|
|
for session in sessions:
|
|
for player in session.players:
|
|
plex_sessions[player.machineIdentifier] = session, player
|
|
|
|
update_sessions()
|
|
update_devices()
|
|
|
|
|
|
def request_configuration(host, hass, config, add_entities_callback):
|
|
"""Request configuration steps from the user."""
|
|
configurator = hass.components.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):
|
|
"""Handle configuration changes."""
|
|
setup_plexserver(
|
|
host, data.get('token'),
|
|
cv.boolean(data.get('has_ssl')),
|
|
cv.boolean(data.get('do_not_verify')),
|
|
hass, config, add_entities_callback
|
|
)
|
|
|
|
_CONFIGURING[host] = configurator.request_config(
|
|
'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': ''
|
|
}, {
|
|
'id': 'has_ssl',
|
|
'name': 'Use SSL',
|
|
'type': ''
|
|
}, {
|
|
'id': 'do_not_verify_ssl',
|
|
'name': 'Do not verify SSL',
|
|
'type': ''
|
|
}])
|
|
|
|
|
|
class PlexClient(MediaPlayerDevice):
|
|
"""Representation of a Plex device."""
|
|
|
|
def __init__(self, config, device, session, plex_sessions,
|
|
update_devices, update_sessions):
|
|
"""Initialize the Plex device."""
|
|
self._app_name = ''
|
|
self._device = None
|
|
self._available = False
|
|
self._marked_unavailable = None
|
|
self._device_protocol_capabilities = None
|
|
self._is_player_active = False
|
|
self._is_player_available = False
|
|
self._player = None
|
|
self._machine_identifier = None
|
|
self._make = ''
|
|
self._name = None
|
|
self._player_state = 'idle'
|
|
self._previous_volume_level = 1 # Used in fake muting
|
|
self._session = None
|
|
self._session_type = None
|
|
self._session_username = 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.config = config
|
|
self.plex_sessions = plex_sessions
|
|
self.update_devices = update_devices
|
|
self.update_sessions = update_sessions
|
|
# General
|
|
self._media_content_id = None
|
|
self._media_content_rating = None
|
|
self._media_content_type = None
|
|
self._media_duration = None
|
|
self._media_image_url = None
|
|
self._media_title = None
|
|
self._media_position = None
|
|
self._media_position_updated_at = None
|
|
# 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('-', '_'))
|
|
|
|
def _clear_media_details(self):
|
|
"""Set all Media Items to None."""
|
|
# General
|
|
self._media_content_id = None
|
|
self._media_content_rating = None
|
|
self._media_content_type = None
|
|
self._media_duration = None
|
|
self._media_image_url = None
|
|
self._media_title = None
|
|
# 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
|
|
|
|
# Clear library Name
|
|
self._app_name = ''
|
|
|
|
def refresh(self, device, session):
|
|
"""Refresh key device data."""
|
|
import plexapi.exceptions
|
|
|
|
# new data refresh
|
|
self._clear_media_details()
|
|
|
|
if session: # Not being triggered by Chrome or FireTablet Plex App
|
|
self._session = session
|
|
if device:
|
|
self._device = device
|
|
try:
|
|
device_url = self._device.url("/")
|
|
except plexapi.exceptions.BadRequest:
|
|
device_url = '127.0.0.1'
|
|
if "127.0.0.1" in device_url:
|
|
self._device.proxyThroughServer()
|
|
self._session = None
|
|
self._machine_identifier = self._device.machineIdentifier
|
|
self._name = self._device.title or DEVICE_DEFAULT_NAME
|
|
self._device_protocol_capabilities = (
|
|
self._device.protocolCapabilities)
|
|
|
|
# set valid session, preferring device session
|
|
if self._device.machineIdentifier in self.plex_sessions:
|
|
self._session = self.plex_sessions.get(
|
|
self._device.machineIdentifier, [None, None])[0]
|
|
|
|
if self._session:
|
|
if self._device is not None and\
|
|
self._device.machineIdentifier is not None and \
|
|
self._session.players:
|
|
self._is_player_available = True
|
|
self._player = [p for p in self._session.players
|
|
if p.machineIdentifier ==
|
|
self._device.machineIdentifier][0]
|
|
self._name = self._player.title
|
|
self._player_state = self._player.state
|
|
self._session_username = self._session.usernames[0]
|
|
self._make = self._player.device
|
|
else:
|
|
self._is_player_available = False
|
|
|
|
# Calculate throttled position for proper progress display.
|
|
position = int(self._session.viewOffset / 1000)
|
|
now = dt_util.utcnow()
|
|
if self._media_position is not None:
|
|
pos_diff = (position - self._media_position)
|
|
time_diff = now - self._media_position_updated_at
|
|
if (pos_diff != 0 and
|
|
abs(time_diff.total_seconds() - pos_diff) > 5):
|
|
self._media_position_updated_at = now
|
|
self._media_position = position
|
|
else:
|
|
self._media_position_updated_at = now
|
|
self._media_position = position
|
|
|
|
self._media_content_id = self._session.ratingKey
|
|
self._media_content_rating = getattr(
|
|
self._session, 'contentRating', None)
|
|
|
|
self._set_player_state()
|
|
|
|
if self._is_player_active and self._session is not None:
|
|
self._session_type = self._session.type
|
|
self._media_duration = int(self._session.duration / 1000)
|
|
# title (movie name, tv episode name, music song name)
|
|
self._media_title = self._session.title
|
|
# media type
|
|
self._set_media_type()
|
|
self._app_name = self._session.section().title \
|
|
if self._session.section() is not None else ''
|
|
self._set_media_image()
|
|
else:
|
|
self._session_type = None
|
|
|
|
def _set_media_image(self):
|
|
thumb_url = self._session.thumbUrl
|
|
if (self.media_content_type is MEDIA_TYPE_TVSHOW
|
|
and not self.config.get(CONF_USE_EPISODE_ART)):
|
|
thumb_url = self._session.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.session.url(self._session.art)
|
|
|
|
self._media_image_url = thumb_url
|
|
|
|
def set_availability(self, available):
|
|
"""Set the device as available/unavailable noting time."""
|
|
if not available:
|
|
self._clear_media_details()
|
|
if self._marked_unavailable is None:
|
|
self._marked_unavailable = dt_util.utcnow()
|
|
else:
|
|
self._marked_unavailable = None
|
|
|
|
self._available = available
|
|
|
|
def _set_player_state(self):
|
|
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
|
|
|
|
def _set_media_type(self):
|
|
if self._session_type in ['clip', 'episode']:
|
|
self._media_content_type = MEDIA_TYPE_TVSHOW
|
|
|
|
# season number (00)
|
|
if callable(self._session.season):
|
|
self._media_season = str(
|
|
(self._session.season()).index).zfill(2)
|
|
elif 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._session.grandparentTitle
|
|
# episode number (00)
|
|
if self._session.index is not None:
|
|
self._media_episode = str(self._session.index).zfill(2)
|
|
|
|
elif self._session_type == 'movie':
|
|
self._media_content_type = MEDIA_TYPE_MOVIE
|
|
if self._session.year is not None and \
|
|
self._media_title is not None:
|
|
self._media_title += ' (' + str(self._session.year) + ')'
|
|
|
|
elif self._session_type == 'track':
|
|
self._media_content_type = MEDIA_TYPE_MUSIC
|
|
self._media_album_name = self._session.parentTitle
|
|
self._media_album_artist = self._session.grandparentTitle
|
|
self._media_track = self._session.index
|
|
self._media_artist = 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
|
|
|
|
def force_idle(self):
|
|
"""Force client to idle."""
|
|
self._state = STATE_IDLE
|
|
self._session = None
|
|
self._clear_media_details()
|
|
|
|
@property
|
|
def unique_id(self):
|
|
"""Return the id of this plex client."""
|
|
return self.machine_identifier
|
|
|
|
@property
|
|
def available(self):
|
|
"""Return the availability of the client."""
|
|
return self._available
|
|
|
|
@property
|
|
def name(self):
|
|
"""Return the name of the device."""
|
|
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 marked_unavailable(self):
|
|
"""Return time device was marked unavailable."""
|
|
return self._marked_unavailable
|
|
|
|
@property
|
|
def session(self):
|
|
"""Return the session, if any."""
|
|
return self._session
|
|
|
|
@property
|
|
def state(self):
|
|
"""Return the state of the device."""
|
|
return self._state
|
|
|
|
def update(self):
|
|
"""Get the latest details."""
|
|
self.update_devices(no_throttle=True)
|
|
self.update_sessions(no_throttle=True)
|
|
|
|
@property
|
|
def _active_media_plexapi_type(self):
|
|
"""Get the active media type required by PlexAPI commands."""
|
|
if self.media_content_type is MEDIA_TYPE_MUSIC:
|
|
return 'music'
|
|
|
|
return 'video'
|
|
|
|
@property
|
|
def media_content_id(self):
|
|
"""Return the content ID of current playing media."""
|
|
return self._media_content_id
|
|
|
|
@property
|
|
def media_content_type(self):
|
|
"""Return the content type of current playing media."""
|
|
if self._session_type == 'clip':
|
|
_LOGGER.debug("Clip content type detected, "
|
|
"compatibility may vary: %s", self.entity_id)
|
|
return MEDIA_TYPE_TVSHOW
|
|
if self._session_type == 'episode':
|
|
return MEDIA_TYPE_TVSHOW
|
|
if self._session_type == 'movie':
|
|
return MEDIA_TYPE_MOVIE
|
|
if self._session_type == 'track':
|
|
return MEDIA_TYPE_MUSIC
|
|
|
|
return None
|
|
|
|
@property
|
|
def media_artist(self):
|
|
"""Return the artist of current playing media, music track only."""
|
|
return self._media_artist
|
|
|
|
@property
|
|
def media_album_name(self):
|
|
"""Return the album name of current playing media, music track only."""
|
|
return self._media_album_name
|
|
|
|
@property
|
|
def media_album_artist(self):
|
|
"""Return the album artist of current playing media, music only."""
|
|
return self._media_album_artist
|
|
|
|
@property
|
|
def media_track(self):
|
|
"""Return the track number of current playing media, music only."""
|
|
return self._media_track
|
|
|
|
@property
|
|
def media_duration(self):
|
|
"""Return the duration of current playing media in seconds."""
|
|
return self._media_duration
|
|
|
|
@property
|
|
def media_position(self):
|
|
"""Return the duration of current playing media in seconds."""
|
|
return self._media_position
|
|
|
|
@property
|
|
def media_position_updated_at(self):
|
|
"""When was the position of the current playing media valid."""
|
|
return self._media_position_updated_at
|
|
|
|
@property
|
|
def media_image_url(self):
|
|
"""Return the image URL of current playing media."""
|
|
return self._media_image_url
|
|
|
|
@property
|
|
def media_title(self):
|
|
"""Return the title of current playing media."""
|
|
return self._media_title
|
|
|
|
@property
|
|
def media_season(self):
|
|
"""Return the season of current playing media (TV Show only)."""
|
|
return self._media_season
|
|
|
|
@property
|
|
def media_series_title(self):
|
|
"""Return the title of the series of current playing media."""
|
|
return self._media_series_title
|
|
|
|
@property
|
|
def media_episode(self):
|
|
"""Return the episode of current playing media (TV Show only)."""
|
|
return self._media_episode
|
|
|
|
@property
|
|
def make(self):
|
|
"""Return the make of the device (ex. SHIELD Android TV)."""
|
|
return self._make
|
|
|
|
@property
|
|
def supported_features(self):
|
|
"""Flag media player features that are supported."""
|
|
if not self._is_player_active:
|
|
return 0
|
|
|
|
# force show all controls
|
|
if self.config.get(CONF_SHOW_ALL_CONTROLS):
|
|
return (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:
|
|
return 0
|
|
# no mute support
|
|
if self.make.lower() == "shield android tv":
|
|
_LOGGER.debug(
|
|
"Shield Android TV client detected, disabling mute "
|
|
"controls: %s", self.entity_id)
|
|
return (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)
|
|
if self.make.lower().startswith("tivo"):
|
|
_LOGGER.debug(
|
|
"Tivo client detected, only enabling pause, play, "
|
|
"stop, and off controls: %s", self.entity_id)
|
|
return (SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_STOP |
|
|
SUPPORT_TURN_OFF)
|
|
# Not all devices support playback functionality
|
|
# Playback includes volume, stop/play/pause, etc.
|
|
if self.device and 'playback' in self._device_protocol_capabilities:
|
|
return (SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK |
|
|
SUPPORT_NEXT_TRACK | SUPPORT_STOP |
|
|
SUPPORT_VOLUME_SET | SUPPORT_PLAY |
|
|
SUPPORT_TURN_OFF | SUPPORT_VOLUME_MUTE)
|
|
|
|
return 0
|
|
|
|
def set_volume_level(self, volume):
|
|
"""Set volume level, range 0..1."""
|
|
if self.device and 'playback' in self._device_protocol_capabilities:
|
|
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."""
|
|
if self.device and 'playback' in self._device_protocol_capabilities:
|
|
self.device.play(self._active_media_plexapi_type)
|
|
|
|
def media_pause(self):
|
|
"""Send pause command."""
|
|
if self.device and 'playback' in self._device_protocol_capabilities:
|
|
self.device.pause(self._active_media_plexapi_type)
|
|
|
|
def media_stop(self):
|
|
"""Send stop command."""
|
|
if self.device and 'playback' in self._device_protocol_capabilities:
|
|
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."""
|
|
if self.device and 'playback' in self._device_protocol_capabilities:
|
|
self.device.skipNext(self._active_media_plexapi_type)
|
|
|
|
def media_previous_track(self):
|
|
"""Send previous track command."""
|
|
if self.device and 'playback' in self._device_protocol_capabilities:
|
|
self.device.skipPrevious(self._active_media_plexapi_type)
|
|
|
|
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_tv_media(
|
|
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'])
|
|
|
|
import plexapi.playlist
|
|
if (media and media_type == 'EPISODE' and
|
|
isinstance(media, plexapi.playlist.Playlist)):
|
|
# delete episode playlist after being loaded into a play queue
|
|
self._client_play_media(media=media, delete=True,
|
|
shuffle=src['shuffle'])
|
|
elif media:
|
|
self._client_play_media(media=media, shuffle=src['shuffle'])
|
|
|
|
def _get_tv_media(self, library_name, show_name, season_number,
|
|
episode_number):
|
|
"""Find TV media and return a Plex media object."""
|
|
target_season = None
|
|
target_episode = None
|
|
|
|
show = self.device.server.library.section(library_name).get(
|
|
show_name)
|
|
|
|
if not season_number:
|
|
playlist_name = "{} - {} Episodes".format(
|
|
self.entity_id, show_name)
|
|
return self.device.server.createPlaylist(
|
|
playlist_name, show.episodes())
|
|
|
|
for season in show.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:
|
|
if not episode_number:
|
|
playlist_name = "{} - {} Season {} Episodes".format(
|
|
self.entity_id, show_name, str(season_number))
|
|
return self.device.server.createPlaylist(
|
|
playlist_name, target_season.episodes())
|
|
|
|
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, delete=False, **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
|
|
playqueue = plexapi.playqueue.PlayQueue.create(
|
|
self.device.server, media, **params)
|
|
|
|
# Delete dynamic playlists used to build playqueue (ex. play tv season)
|
|
if delete:
|
|
media.delete()
|
|
|
|
server_url = self.device.server.baseurl.split(':')
|
|
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/{}?window=100&own=1'.format(
|
|
playqueue.playQueueID),
|
|
}, **params))
|
|
|
|
@property
|
|
def device_state_attributes(self):
|
|
"""Return the scene state attributes."""
|
|
attr = {
|
|
'media_content_rating': self._media_content_rating,
|
|
'session_username': self._session_username,
|
|
'media_library_name': self._app_name
|
|
}
|
|
|
|
return attr
|