core/homeassistant/components/plex/media_player.py

935 lines
32 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.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_time_interval
from homeassistant.util import dt as dt_util
from homeassistant.util.json import load_json, save_json
_CONFIGURING = {}
_LOGGER = logging.getLogger(__name__)
NAME_FORMAT = "Plex {}"
PLEX_CONFIG_FILE = "plex.conf"
PLEX_DATA = "plex"
CONF_USE_EPISODE_ART = "use_episode_art"
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_USE_EPISODE_ART, 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 = f"{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(
f"{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_time_interval(hass, lambda now: update_devices(), timedelta(seconds=10))
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
)
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)
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
for machine_identifier, (session, player) in plex_sessions.items():
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
)
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 client not in new_plex_clients:
client.schedule_update_ha_state()
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)
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_ssl")),
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):
"""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
# 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)
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 = NAME_FORMAT.format(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 = NAME_FORMAT.format(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 should_poll(self):
"""Return True if entity has to be polled for state."""
return False
@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
@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
self.update_devices()
@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)
self.update_devices()
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)
self.update_devices()
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)
self.update_devices()
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)
self.update_devices()
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)
self.update_devices()
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 = f"{self.entity_id} - {show_name} Episodes"
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,
),
)
self.update_devices()
@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