Discover controllable Plex clients using plex.tv (#36857)

This commit is contained in:
jjlawren 2020-06-17 14:04:47 -05:00 committed by GitHub
parent 94c8d74a66
commit d5cc3208af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 193 additions and 34 deletions

View File

@ -9,6 +9,8 @@ DEFAULT_PORT = 32400
DEFAULT_SSL = False DEFAULT_SSL = False
DEFAULT_VERIFY_SSL = True DEFAULT_VERIFY_SSL = True
PLEXTV_THROTTLE = 60
DEBOUNCE_TIMEOUT = 1 DEBOUNCE_TIMEOUT = 1
DISPATCHERS = "dispatchers" DISPATCHERS = "dispatchers"
PLATFORMS = frozenset(["media_player", "sensor"]) PLATFORMS = frozenset(["media_player", "sensor"])

View File

@ -1,6 +1,7 @@
"""Shared class to maintain Plex server instances.""" """Shared class to maintain Plex server instances."""
import logging import logging
import ssl import ssl
import time
from urllib.parse import urlparse from urllib.parse import urlparse
from plexapi.exceptions import NotFound, Unauthorized from plexapi.exceptions import NotFound, Unauthorized
@ -34,6 +35,7 @@ from .const import (
PLEX_NEW_MP_SIGNAL, PLEX_NEW_MP_SIGNAL,
PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, PLEX_UPDATE_MEDIA_PLAYER_SIGNAL,
PLEX_UPDATE_SENSOR_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL,
PLEXTV_THROTTLE,
X_PLEX_DEVICE_NAME, X_PLEX_DEVICE_NAME,
X_PLEX_PLATFORM, X_PLEX_PLATFORM,
X_PLEX_PRODUCT, X_PLEX_PRODUCT,
@ -70,6 +72,9 @@ class PlexServer:
self.server_choice = None self.server_choice = None
self._accounts = [] self._accounts = []
self._owner_username = None self._owner_username = None
self._plextv_clients = None
self._plextv_client_timestamp = 0
self._plextv_device_cache = {}
self._version = None self._version = None
self.async_update_platforms = Debouncer( self.async_update_platforms = Debouncer(
hass, hass,
@ -92,15 +97,28 @@ class PlexServer:
self._plex_account = plexapi.myplex.MyPlexAccount(token=self._token) self._plex_account = plexapi.myplex.MyPlexAccount(token=self._token)
return self._plex_account return self._plex_account
def plextv_clients(self):
"""Return available clients linked to Plex account."""
now = time.time()
if now - self._plextv_client_timestamp > PLEXTV_THROTTLE:
self._plextv_client_timestamp = now
resources = self.account.resources()
self._plextv_clients = [
x for x in resources if "player" in x.provides and x.presence
]
_LOGGER.debug(
"Current available clients from plex.tv: %s", self._plextv_clients
)
return self._plextv_clients
def connect(self): def connect(self):
"""Connect to a Plex server directly, obtaining direct URL if necessary.""" """Connect to a Plex server directly, obtaining direct URL if necessary."""
config_entry_update_needed = False config_entry_update_needed = False
def _connect_with_token(): def _connect_with_token():
account = plexapi.myplex.MyPlexAccount(token=self._token)
available_servers = [ available_servers = [
(x.name, x.clientIdentifier) (x.name, x.clientIdentifier)
for x in account.resources() for x in self.account.resources()
if "server" in x.provides if "server" in x.provides
] ]
@ -112,7 +130,9 @@ class PlexServer:
self.server_choice = ( self.server_choice = (
self._server_name if self._server_name else available_servers[0][0] self._server_name if self._server_name else available_servers[0][0]
) )
self._plex_server = account.resource(self.server_choice).connect(timeout=10) self._plex_server = self.account.resource(self.server_choice).connect(
timeout=10
)
def _connect_with_url(): def _connect_with_url():
session = None session = None
@ -124,13 +144,14 @@ class PlexServer:
) )
def _update_plexdirect_hostname(): def _update_plexdirect_hostname():
account = plexapi.myplex.MyPlexAccount(token=self._token)
matching_server = [ matching_server = [
x.name x.name
for x in account.resources() for x in self.account.resources()
if x.clientIdentifier == self._server_id if x.clientIdentifier == self._server_id
][0] ][0]
self._plex_server = account.resource(matching_server).connect(timeout=10) self._plex_server = self.account.resource(matching_server).connect(
timeout=10
)
if self._url: if self._url:
try: try:
@ -193,7 +214,11 @@ class PlexServer:
def _fetch_platform_data(self): def _fetch_platform_data(self):
"""Fetch all data from the Plex server in a single method.""" """Fetch all data from the Plex server in a single method."""
return (self._plex_server.clients(), self._plex_server.sessions()) return (
self._plex_server.clients(),
self._plex_server.sessions(),
self.plextv_clients(),
)
async def _async_update_platforms(self): async def _async_update_platforms(self):
"""Update the platform entities.""" """Update the platform entities."""
@ -217,7 +242,7 @@ class PlexServer:
monitored_users.add(new_user) monitored_users.add(new_user)
try: try:
devices, sessions = await self.hass.async_add_executor_job( devices, sessions, plextv_clients = await self.hass.async_add_executor_job(
self._fetch_platform_data self._fetch_platform_data
) )
except ( except (
@ -245,10 +270,8 @@ class PlexServer:
) )
return return
if ( if device.machineIdentifier not in (
device.machineIdentifier not in self._created_clients self._created_clients | ignored_clients | new_clients
and device.machineIdentifier not in ignored_clients
and device.machineIdentifier not in new_clients
): ):
new_clients.add(device.machineIdentifier) new_clients.add(device.machineIdentifier)
_LOGGER.debug( _LOGGER.debug(
@ -258,6 +281,30 @@ class PlexServer:
for device in devices: for device in devices:
process_device("device", device) process_device("device", device)
def connect_to_resource(resource):
"""Connect to a plex.tv resource and return a Plex client."""
client_id = resource.clientIdentifier
if client_id in self._plextv_device_cache:
return self._plextv_device_cache[client_id]
client = None
try:
client = resource.connect(timeout=3)
_LOGGER.debug("plex.tv resource connection successful: %s", client)
except NotFound:
_LOGGER.error("plex.tv resource connection failed: %s", resource.name)
self._plextv_device_cache[client_id] = client
return client
for plextv_client in plextv_clients:
if plextv_client.clientIdentifier not in available_clients:
device = await self.hass.async_add_executor_job(
connect_to_resource, plextv_client
)
if device:
process_device("resource", device)
for session in sessions: for session in sessions:
if session.TYPE == "photo": if session.TYPE == "photo":
_LOGGER.debug("Photo session detected, skipping: %s", session) _LOGGER.debug("Photo session detected, skipping: %s", session)
@ -296,6 +343,7 @@ class PlexServer:
for client_id in idle_clients: for client_id in idle_clients:
self.async_refresh_entity(client_id, None, None) self.async_refresh_entity(client_id, None, None)
self._known_idle.add(client_id) self._known_idle.add(client_id)
self._plextv_device_cache.pop(client_id, None)
if new_entity_configs: if new_entity_configs:
async_dispatcher_send( async_dispatcher_send(
@ -390,7 +438,7 @@ class PlexServer:
key = kwargs["plex_key"] key = kwargs["plex_key"]
try: try:
return self.fetch_item(key) return self.fetch_item(key)
except plexapi.exceptions.NotFound: except NotFound:
_LOGGER.error("Media for key %s not found", key) _LOGGER.error("Media for key %s not found", key)
return None return None

View File

@ -38,28 +38,37 @@ class MockGDM:
class MockResource: class MockResource:
"""Mock a PlexAccount resource.""" """Mock a PlexAccount resource."""
def __init__(self, index): def __init__(self, index, kind="server"):
"""Initialize the object.""" """Initialize the object."""
if kind == "server":
self.name = MOCK_SERVERS[index][CONF_SERVER] self.name = MOCK_SERVERS[index][CONF_SERVER]
self.clientIdentifier = MOCK_SERVERS[index][ # pylint: disable=invalid-name self.clientIdentifier = MOCK_SERVERS[index][ # pylint: disable=invalid-name
CONF_SERVER_IDENTIFIER CONF_SERVER_IDENTIFIER
] ]
self.provides = ["server"] self.provides = ["server"]
self._mock_plex_server = MockPlexServer(index) self.device = MockPlexServer(index)
else:
self.name = f"plex.tv Resource Player {index+10}"
self.clientIdentifier = f"client-{index+10}"
self.provides = ["player"]
self.device = MockPlexClient(f"http://192.168.0.1{index}:32500", index + 10)
self.presence = index == 0
def connect(self, timeout): def connect(self, timeout):
"""Mock the resource connect method.""" """Mock the resource connect method."""
return self._mock_plex_server return self.device
class MockPlexAccount: class MockPlexAccount:
"""Mock a PlexAccount instance.""" """Mock a PlexAccount instance."""
def __init__(self, servers=1): def __init__(self, servers=1, players=3):
"""Initialize the object.""" """Initialize the object."""
self._resources = [] self._resources = []
for index in range(servers): for index in range(servers):
self._resources.append(MockResource(index)) self._resources.append(MockResource(index))
for index in range(players):
self._resources.append(MockResource(index, kind="player"))
def resource(self, name): def resource(self, name):
"""Mock the PlexAccount resource lookup method.""" """Mock the PlexAccount resource lookup method."""

View File

@ -479,6 +479,7 @@ async def test_option_flow_new_users_available(hass, caplog):
server_id = mock_plex_server.machineIdentifier server_id = mock_plex_server.machineIdentifier
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -254,7 +254,10 @@ async def test_setup_with_photo_session(hass):
server_id = mock_plex_server.machineIdentifier server_id = mock_plex_server.machineIdentifier
async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
async_dispatcher_send(
hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)
)
await hass.async_block_till_done() await hass.async_block_till_done()
media_player = hass.states.get("media_player.plex_product_title") media_player = hass.states.get("media_player.plex_product_title")

View File

@ -0,0 +1,91 @@
"""Tests for Plex media_players."""
from plexapi.exceptions import NotFound
from homeassistant.components.plex.const import DOMAIN, SERVERS
from .const import DEFAULT_DATA, DEFAULT_OPTIONS
from .mock_classes import MockPlexAccount, MockPlexServer
from tests.async_mock import patch
from tests.common import MockConfigEntry
async def test_plex_tv_clients(hass):
"""Test getting Plex clients from plex.tv."""
entry = MockConfigEntry(
domain=DOMAIN,
data=DEFAULT_DATA,
options=DEFAULT_OPTIONS,
unique_id=DEFAULT_DATA["server_id"],
)
mock_plex_server = MockPlexServer(config_entry=entry)
mock_plex_account = MockPlexAccount()
with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
"homeassistant.components.plex.PlexWebsocket.listen"
):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
server_id = mock_plex_server.machineIdentifier
plex_server = hass.data[DOMAIN][SERVERS][server_id]
resource = next(
x
for x in mock_plex_account.resources()
if x.name.startswith("plex.tv Resource Player")
)
with patch(
"plexapi.myplex.MyPlexAccount", return_value=mock_plex_account
), patch.object(resource, "connect", side_effect=NotFound):
await plex_server._async_update_platforms()
await hass.async_block_till_done()
media_players_before = len(hass.states.async_entity_ids("media_player"))
# Ensure one more client is discovered
await hass.config_entries.async_unload(entry.entry_id)
with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
"homeassistant.components.plex.PlexWebsocket.listen"
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
plex_server = hass.data[DOMAIN][SERVERS][server_id]
with patch("plexapi.myplex.MyPlexAccount", return_value=mock_plex_account):
await plex_server._async_update_platforms()
await hass.async_block_till_done()
media_players_after = len(hass.states.async_entity_ids("media_player"))
assert media_players_after == media_players_before + 1
# Ensure only plex.tv resource client is found
await hass.config_entries.async_unload(entry.entry_id)
mock_plex_server.clear_clients()
mock_plex_server.clear_sessions()
with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
"homeassistant.components.plex.PlexWebsocket.listen"
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
plex_server = hass.data[DOMAIN][SERVERS][server_id]
with patch("plexapi.myplex.MyPlexAccount", return_value=mock_plex_account):
await plex_server._async_update_platforms()
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids("media_player")) == 1
# Ensure cache gets called
with patch("plexapi.myplex.MyPlexAccount", return_value=mock_plex_account):
await plex_server._async_update_platforms()
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids("media_player")) == 1

View File

@ -26,6 +26,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DEFAULT_DATA, DEFAULT_OPTIONS from .const import DEFAULT_DATA, DEFAULT_OPTIONS
from .mock_classes import ( from .mock_classes import (
MockPlexAccount,
MockPlexArtist, MockPlexArtist,
MockPlexLibrary, MockPlexLibrary,
MockPlexLibrarySection, MockPlexLibrarySection,
@ -62,6 +63,7 @@ async def test_new_users_available(hass):
server_id = mock_plex_server.machineIdentifier server_id = mock_plex_server.machineIdentifier
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done() await hass.async_block_till_done()
@ -101,6 +103,7 @@ async def test_new_ignored_users_available(hass, caplog):
server_id = mock_plex_server.machineIdentifier server_id = mock_plex_server.machineIdentifier
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done() await hass.async_block_till_done()
@ -253,6 +256,7 @@ async def test_ignore_plex_web_client(hass):
server_id = mock_plex_server.machineIdentifier server_id = mock_plex_server.machineIdentifier
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(players=0)):
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done() await hass.async_block_till_done()
@ -287,6 +291,7 @@ async def test_media_lookups(hass):
loaded_server = hass.data[DOMAIN][SERVERS][server_id] loaded_server = hass.data[DOMAIN][SERVERS][server_id]
# Plex Key searches # Plex Key searches
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done() await hass.async_block_till_done()
media_player_id = hass.states.async_entity_ids("media_player")[0] media_player_id = hass.states.async_entity_ids("media_player")[0]