Turns out plexapi lib already supports this, so we just need to pass through the command.
638 lines
22 KiB
Python
638 lines
22 KiB
Python
"""Support to interface with the Plex API."""
|
|
import json
|
|
import logging
|
|
|
|
import plexapi.exceptions
|
|
import requests.exceptions
|
|
|
|
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, MediaPlayerEntity
|
|
from homeassistant.components.media_player.const import (
|
|
MEDIA_TYPE_MOVIE,
|
|
MEDIA_TYPE_MUSIC,
|
|
MEDIA_TYPE_TVSHOW,
|
|
MEDIA_TYPE_VIDEO,
|
|
SUPPORT_BROWSE_MEDIA,
|
|
SUPPORT_NEXT_TRACK,
|
|
SUPPORT_PAUSE,
|
|
SUPPORT_PLAY,
|
|
SUPPORT_PLAY_MEDIA,
|
|
SUPPORT_PREVIOUS_TRACK,
|
|
SUPPORT_SEEK,
|
|
SUPPORT_STOP,
|
|
SUPPORT_VOLUME_MUTE,
|
|
SUPPORT_VOLUME_SET,
|
|
)
|
|
from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING
|
|
from homeassistant.core import callback
|
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
|
from homeassistant.helpers.entity_registry import async_get_registry
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from .const import (
|
|
COMMON_PLAYERS,
|
|
CONF_SERVER_IDENTIFIER,
|
|
DISPATCHERS,
|
|
DOMAIN as PLEX_DOMAIN,
|
|
NAME_FORMAT,
|
|
PLEX_NEW_MP_SIGNAL,
|
|
PLEX_UPDATE_MEDIA_PLAYER_SIGNAL,
|
|
SERVERS,
|
|
)
|
|
from .media_browser import browse_media
|
|
|
|
LIVE_TV_SECTION = "-4"
|
|
PLAYLISTS_BROWSE_PAYLOAD = {
|
|
"title": "Playlists",
|
|
"media_content_id": "all",
|
|
"media_content_type": "playlists",
|
|
"can_play": False,
|
|
"can_expand": True,
|
|
}
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
|
"""Set up Plex media_player from a config entry."""
|
|
server_id = config_entry.data[CONF_SERVER_IDENTIFIER]
|
|
registry = await async_get_registry(hass)
|
|
|
|
@callback
|
|
def async_new_media_players(new_entities):
|
|
_async_add_entities(
|
|
hass, registry, config_entry, async_add_entities, server_id, new_entities
|
|
)
|
|
|
|
unsub = async_dispatcher_connect(
|
|
hass, PLEX_NEW_MP_SIGNAL.format(server_id), async_new_media_players
|
|
)
|
|
hass.data[PLEX_DOMAIN][DISPATCHERS][server_id].append(unsub)
|
|
_LOGGER.debug("New entity listener created")
|
|
|
|
|
|
@callback
|
|
def _async_add_entities(
|
|
hass, registry, config_entry, async_add_entities, server_id, new_entities
|
|
):
|
|
"""Set up Plex media_player entities."""
|
|
_LOGGER.debug("New entities: %s", new_entities)
|
|
entities = []
|
|
plexserver = hass.data[PLEX_DOMAIN][SERVERS][server_id]
|
|
for entity_params in new_entities:
|
|
plex_mp = PlexMediaPlayer(plexserver, **entity_params)
|
|
entities.append(plex_mp)
|
|
|
|
# Migration to per-server unique_ids
|
|
old_entity_id = registry.async_get_entity_id(
|
|
MP_DOMAIN, PLEX_DOMAIN, plex_mp.machine_identifier
|
|
)
|
|
if old_entity_id is not None:
|
|
new_unique_id = f"{server_id}:{plex_mp.machine_identifier}"
|
|
_LOGGER.debug(
|
|
"Migrating unique_id from [%s] to [%s]",
|
|
plex_mp.machine_identifier,
|
|
new_unique_id,
|
|
)
|
|
registry.async_update_entity(old_entity_id, new_unique_id=new_unique_id)
|
|
|
|
async_add_entities(entities, True)
|
|
|
|
|
|
class PlexMediaPlayer(MediaPlayerEntity):
|
|
"""Representation of a Plex device."""
|
|
|
|
def __init__(self, plex_server, device, player_source, session=None):
|
|
"""Initialize the Plex device."""
|
|
self.plex_server = plex_server
|
|
self.device = device
|
|
self.session = session
|
|
self.player_source = player_source
|
|
self._app_name = ""
|
|
self._available = False
|
|
self._device_protocol_capabilities = None
|
|
self._is_player_active = False
|
|
self._machine_identifier = device.machineIdentifier
|
|
self._make = ""
|
|
self._device_platform = None
|
|
self._device_product = None
|
|
self._device_title = None
|
|
self._device_version = None
|
|
self._name = None
|
|
self._player_state = "idle"
|
|
self._previous_volume_level = 1 # Used in fake muting
|
|
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
|
|
# 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_summary = 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
|
|
|
|
async def async_added_to_hass(self):
|
|
"""Run when about to be added to hass."""
|
|
server_id = self.plex_server.machine_identifier
|
|
|
|
_LOGGER.debug("Added %s [%s]", self.entity_id, self.unique_id)
|
|
unsub = async_dispatcher_connect(
|
|
self.hass,
|
|
PLEX_UPDATE_MEDIA_PLAYER_SIGNAL.format(self.unique_id),
|
|
self.async_refresh_media_player,
|
|
)
|
|
self.hass.data[PLEX_DOMAIN][DISPATCHERS][server_id].append(unsub)
|
|
|
|
@callback
|
|
def async_refresh_media_player(self, device, session):
|
|
"""Set instance objects and trigger an entity state update."""
|
|
_LOGGER.debug("Refreshing %s [%s / %s]", self.entity_id, device, session)
|
|
self.device = device
|
|
self.session = session
|
|
self.async_schedule_update_ha_state(True)
|
|
|
|
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_summary = 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 update(self):
|
|
"""Refresh key device data."""
|
|
self._clear_media_details()
|
|
|
|
self._available = self.device or self.session
|
|
|
|
if self.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._device_platform = self.device.platform
|
|
self._device_product = self.device.product
|
|
self._device_title = self.device.title
|
|
self._device_version = self.device.version
|
|
self._device_protocol_capabilities = self.device.protocolCapabilities
|
|
self._player_state = self.device.state
|
|
|
|
if not self.session:
|
|
self.force_idle()
|
|
else:
|
|
session_device = next(
|
|
(
|
|
p
|
|
for p in self.session.players
|
|
if p.machineIdentifier == self.device.machineIdentifier
|
|
),
|
|
None,
|
|
)
|
|
if session_device:
|
|
self._make = session_device.device or ""
|
|
self._player_state = session_device.state
|
|
self._device_platform = self._device_platform or session_device.platform
|
|
self._device_product = self._device_product or session_device.product
|
|
self._device_title = self._device_title or session_device.title
|
|
self._device_version = self._device_version or session_device.version
|
|
else:
|
|
_LOGGER.warning("No player associated with active session")
|
|
|
|
if self.session.usernames:
|
|
self._session_username = self.session.usernames[0]
|
|
|
|
# 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)
|
|
|
|
name_parts = [self._device_product, self._device_title or self._device_platform]
|
|
if (self._device_product in COMMON_PLAYERS) and self.make:
|
|
# Add more context in name for likely duplicates
|
|
name_parts.append(self.make)
|
|
if self.username and self.username != self.plex_server.owner:
|
|
# Prepend username for shared/managed clients
|
|
name_parts.insert(0, self.username)
|
|
self._name = NAME_FORMAT.format(" - ".join(name_parts))
|
|
self._set_player_state()
|
|
|
|
if self._is_player_active and self.session is not None:
|
|
self._session_type = self.session.type
|
|
if self.session.duration:
|
|
self._media_duration = int(self.session.duration / 1000)
|
|
else:
|
|
self._media_duration = None
|
|
# title (movie name, tv episode name, music song name)
|
|
self._media_summary = self.session.summary
|
|
self._media_title = self.session.title
|
|
# media type
|
|
self._set_media_type()
|
|
if self.session.librarySectionID == LIVE_TV_SECTION:
|
|
self._app_name = "Live TV"
|
|
else:
|
|
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.plex_server.option_use_episode_art
|
|
):
|
|
if self.session.librarySectionID == LIVE_TV_SECTION:
|
|
thumb_url = self.session.grandparentThumb
|
|
else:
|
|
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.name
|
|
)
|
|
thumb_url = self.session.url(self.session.art)
|
|
|
|
self._media_image_url = thumb_url
|
|
|
|
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 == "episode":
|
|
self._media_content_type = MEDIA_TYPE_TVSHOW
|
|
|
|
# season number (00)
|
|
self._media_season = self.session.seasonNumber
|
|
# show name
|
|
self._media_series_title = self.session.grandparentTitle
|
|
# episode number (00)
|
|
if self.session.index is not None:
|
|
self._media_episode = self.session.index
|
|
|
|
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 += f" ({self.session.year!s})"
|
|
|
|
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.name,
|
|
)
|
|
self._media_artist = self._media_album_artist
|
|
|
|
elif self._session_type == "clip":
|
|
_LOGGER.debug(
|
|
"Clip content type detected, compatibility may vary: %s", self.name
|
|
)
|
|
self._media_content_type = MEDIA_TYPE_VIDEO
|
|
|
|
def force_idle(self):
|
|
"""Force client to idle."""
|
|
self._player_state = STATE_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 f"{self.plex_server.machine_identifier}:{self._machine_identifier}"
|
|
|
|
@property
|
|
def machine_identifier(self):
|
|
"""Return the Plex-provided identifier 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 username(self):
|
|
"""Return the username of the client owner."""
|
|
return self._session_username
|
|
|
|
@property
|
|
def app_name(self):
|
|
"""Return the library name of playing media."""
|
|
return self._app_name
|
|
|
|
@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."""
|
|
return self._media_content_type
|
|
|
|
@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_summary(self):
|
|
"""Return the summary of current playing media."""
|
|
return self._media_summary
|
|
|
|
@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 self.device and "playback" in self._device_protocol_capabilities:
|
|
return (
|
|
SUPPORT_PAUSE
|
|
| SUPPORT_PREVIOUS_TRACK
|
|
| SUPPORT_NEXT_TRACK
|
|
| SUPPORT_STOP
|
|
| SUPPORT_SEEK
|
|
| SUPPORT_VOLUME_SET
|
|
| SUPPORT_PLAY
|
|
| SUPPORT_PLAY_MEDIA
|
|
| SUPPORT_VOLUME_MUTE
|
|
| SUPPORT_BROWSE_MEDIA
|
|
)
|
|
|
|
return SUPPORT_BROWSE_MEDIA | SUPPORT_PLAY_MEDIA
|
|
|
|
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 media_seek(self, position):
|
|
"""Send the seek command."""
|
|
if self.device and "playback" in self._device_protocol_capabilities:
|
|
self.device.seekTo(position * 1000, self._active_media_plexapi_type)
|
|
|
|
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):
|
|
_LOGGER.debug(
|
|
"Client is not currently accepting playback controls: %s", self.name
|
|
)
|
|
return
|
|
|
|
src = json.loads(media_id)
|
|
if isinstance(src, int):
|
|
src = {"plex_key": src}
|
|
|
|
shuffle = src.pop("shuffle", 0)
|
|
media = self.plex_server.lookup_media(media_type, **src)
|
|
|
|
if media is None:
|
|
_LOGGER.error("Media could not be found: %s", media_id)
|
|
return
|
|
|
|
_LOGGER.debug("Attempting to play %s on %s", media, self.name)
|
|
|
|
playqueue = self.plex_server.create_playqueue(media, shuffle=shuffle)
|
|
try:
|
|
self.device.playMedia(playqueue)
|
|
except requests.exceptions.ConnectTimeout:
|
|
_LOGGER.error("Timed out playing on %s", self.name)
|
|
|
|
@property
|
|
def device_state_attributes(self):
|
|
"""Return the scene state attributes."""
|
|
return {
|
|
"media_content_rating": self._media_content_rating,
|
|
"session_username": self.username,
|
|
"media_library_name": self._app_name,
|
|
"summary": self.media_summary,
|
|
"player_source": self.player_source,
|
|
}
|
|
|
|
@property
|
|
def device_info(self):
|
|
"""Return a device description for device registry."""
|
|
if self.machine_identifier is None:
|
|
return None
|
|
|
|
return {
|
|
"identifiers": {(PLEX_DOMAIN, self.machine_identifier)},
|
|
"manufacturer": self._device_platform or "Plex",
|
|
"model": self._device_product or self.make,
|
|
"name": self.name,
|
|
"sw_version": self._device_version,
|
|
"via_device": (PLEX_DOMAIN, self.plex_server.machine_identifier),
|
|
}
|
|
|
|
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
|
"""Implement the websocket media browsing helper."""
|
|
return await self.hass.async_add_executor_job(
|
|
browse_media,
|
|
self.entity_id,
|
|
self.plex_server,
|
|
media_content_type,
|
|
media_content_id,
|
|
)
|