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_VERIFY_SSL = True
PLEXTV_THROTTLE = 60
DEBOUNCE_TIMEOUT = 1
DISPATCHERS = "dispatchers"
PLATFORMS = frozenset(["media_player", "sensor"])

View File

@ -1,6 +1,7 @@
"""Shared class to maintain Plex server instances."""
import logging
import ssl
import time
from urllib.parse import urlparse
from plexapi.exceptions import NotFound, Unauthorized
@ -34,6 +35,7 @@ from .const import (
PLEX_NEW_MP_SIGNAL,
PLEX_UPDATE_MEDIA_PLAYER_SIGNAL,
PLEX_UPDATE_SENSOR_SIGNAL,
PLEXTV_THROTTLE,
X_PLEX_DEVICE_NAME,
X_PLEX_PLATFORM,
X_PLEX_PRODUCT,
@ -70,6 +72,9 @@ class PlexServer:
self.server_choice = None
self._accounts = []
self._owner_username = None
self._plextv_clients = None
self._plextv_client_timestamp = 0
self._plextv_device_cache = {}
self._version = None
self.async_update_platforms = Debouncer(
hass,
@ -92,15 +97,28 @@ class PlexServer:
self._plex_account = plexapi.myplex.MyPlexAccount(token=self._token)
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):
"""Connect to a Plex server directly, obtaining direct URL if necessary."""
config_entry_update_needed = False
def _connect_with_token():
account = plexapi.myplex.MyPlexAccount(token=self._token)
available_servers = [
(x.name, x.clientIdentifier)
for x in account.resources()
for x in self.account.resources()
if "server" in x.provides
]
@ -112,7 +130,9 @@ class PlexServer:
self.server_choice = (
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():
session = None
@ -124,13 +144,14 @@ class PlexServer:
)
def _update_plexdirect_hostname():
account = plexapi.myplex.MyPlexAccount(token=self._token)
matching_server = [
x.name
for x in account.resources()
for x in self.account.resources()
if x.clientIdentifier == self._server_id
][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:
try:
@ -193,7 +214,11 @@ class PlexServer:
def _fetch_platform_data(self):
"""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):
"""Update the platform entities."""
@ -217,7 +242,7 @@ class PlexServer:
monitored_users.add(new_user)
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
)
except (
@ -245,10 +270,8 @@ class PlexServer:
)
return
if (
device.machineIdentifier not in self._created_clients
and device.machineIdentifier not in ignored_clients
and device.machineIdentifier not in new_clients
if device.machineIdentifier not in (
self._created_clients | ignored_clients | new_clients
):
new_clients.add(device.machineIdentifier)
_LOGGER.debug(
@ -258,6 +281,30 @@ class PlexServer:
for device in devices:
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:
if session.TYPE == "photo":
_LOGGER.debug("Photo session detected, skipping: %s", session)
@ -296,6 +343,7 @@ class PlexServer:
for client_id in idle_clients:
self.async_refresh_entity(client_id, None, None)
self._known_idle.add(client_id)
self._plextv_device_cache.pop(client_id, None)
if new_entity_configs:
async_dispatcher_send(
@ -390,7 +438,7 @@ class PlexServer:
key = kwargs["plex_key"]
try:
return self.fetch_item(key)
except plexapi.exceptions.NotFound:
except NotFound:
_LOGGER.error("Media for key %s not found", key)
return None

View File

@ -38,28 +38,37 @@ class MockGDM:
class MockResource:
"""Mock a PlexAccount resource."""
def __init__(self, index):
def __init__(self, index, kind="server"):
"""Initialize the object."""
self.name = MOCK_SERVERS[index][CONF_SERVER]
self.clientIdentifier = MOCK_SERVERS[index][ # pylint: disable=invalid-name
CONF_SERVER_IDENTIFIER
]
self.provides = ["server"]
self._mock_plex_server = MockPlexServer(index)
if kind == "server":
self.name = MOCK_SERVERS[index][CONF_SERVER]
self.clientIdentifier = MOCK_SERVERS[index][ # pylint: disable=invalid-name
CONF_SERVER_IDENTIFIER
]
self.provides = ["server"]
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):
"""Mock the resource connect method."""
return self._mock_plex_server
return self.device
class MockPlexAccount:
"""Mock a PlexAccount instance."""
def __init__(self, servers=1):
def __init__(self, servers=1, players=3):
"""Initialize the object."""
self._resources = []
for index in range(servers):
self._resources.append(MockResource(index))
for index in range(players):
self._resources.append(MockResource(index, kind="player"))
def resource(self, name):
"""Mock the PlexAccount resource lookup method."""

View File

@ -479,8 +479,9 @@ async def test_option_flow_new_users_available(hass, caplog):
server_id = mock_plex_server.machineIdentifier
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users

View File

@ -254,8 +254,11 @@ async def test_setup_with_photo_session(hass):
server_id = mock_plex_server.machineIdentifier
async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
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()
media_player = hass.states.get("media_player.plex_product_title")
assert media_player.state == "idle"

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 .mock_classes import (
MockPlexAccount,
MockPlexArtist,
MockPlexLibrary,
MockPlexLibrarySection,
@ -62,8 +63,9 @@ async def test_new_users_available(hass):
server_id = mock_plex_server.machineIdentifier
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
@ -101,8 +103,9 @@ async def test_new_ignored_users_available(hass, caplog):
server_id = mock_plex_server.machineIdentifier
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
@ -253,8 +256,9 @@ async def test_ignore_plex_web_client(hass):
server_id = mock_plex_server.machineIdentifier
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(players=0)):
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
sensor = hass.states.get("sensor.plex_plex_server_1")
assert sensor.state == str(len(mock_plex_server.accounts))
@ -287,8 +291,9 @@ async def test_media_lookups(hass):
loaded_server = hass.data[DOMAIN][SERVERS][server_id]
# Plex Key searches
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
media_player_id = hass.states.async_entity_ids("media_player")[0]
with patch("homeassistant.components.plex.PlexServer.create_playqueue"):
assert await hass.services.async_call(