Discover Plex clients using GDM (#39053)

pull/41834/head
jjlawren 2020-10-14 08:46:52 -05:00 committed by GitHub
parent 72759d7501
commit c63c253b7f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 207 additions and 39 deletions

View File

@ -1,10 +1,12 @@
"""Support to embed Plex."""
import asyncio
import functools
from functools import partial
import json
import logging
import plexapi.exceptions
from plexapi.gdm import GDM
from plexwebsocket import (
SIGNAL_CONNECTION_STATE,
SIGNAL_DATA,
@ -33,6 +35,7 @@ from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
@ -43,6 +46,8 @@ from .const import (
CONF_SERVER_IDENTIFIER,
DISPATCHERS,
DOMAIN as PLEX_DOMAIN,
GDM_DEBOUNCER,
GDM_SCANNER,
PLATFORMS,
PLATFORMS_COMPLETED,
PLEX_SERVER_CONFIG,
@ -67,6 +72,16 @@ async def async_setup(hass, config):
await async_setup_services(hass)
gdm = hass.data[PLEX_DOMAIN][GDM_SCANNER] = GDM()
hass.data[PLEX_DOMAIN][GDM_DEBOUNCER] = Debouncer(
hass,
_LOGGER,
cooldown=10,
immediate=True,
function=partial(gdm.scan, scan_for_clients=True),
).async_call
return True
@ -143,10 +158,14 @@ async def async_setup_entry(hass, entry):
entry.add_update_listener(async_options_updated)
async def async_update_plex():
await hass.data[PLEX_DOMAIN][GDM_DEBOUNCER]()
await plex_server.async_update_platforms()
unsub = async_dispatcher_connect(
hass,
PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id),
plex_server.async_update_platforms,
async_update_plex,
)
hass.data[PLEX_DOMAIN][DISPATCHERS].setdefault(server_id, [])
hass.data[PLEX_DOMAIN][DISPATCHERS][server_id].append(unsub)

View File

@ -13,6 +13,8 @@ PLEXTV_THROTTLE = 60
DEBOUNCE_TIMEOUT = 1
DISPATCHERS = "dispatchers"
GDM_DEBOUNCER = "gdm_debouncer"
GDM_SCANNER = "gdm_scanner"
PLATFORMS = frozenset(["media_player", "sensor"])
PLATFORMS_COMPLETED = "platforms_completed"
PLAYER_SOURCE = "player_source"

View File

@ -4,6 +4,7 @@ import ssl
import time
from urllib.parse import urlparse
from plexapi.client import PlexClient
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
import plexapi.myplex
import plexapi.playqueue
@ -32,6 +33,8 @@ from .const import (
CONF_USE_EPISODE_ART,
DEBOUNCE_TIMEOUT,
DEFAULT_VERIFY_SSL,
DOMAIN,
GDM_SCANNER,
PLAYER_SOURCE,
PLEX_NEW_MP_SIGNAL,
PLEX_UPDATE_MEDIA_PLAYER_SIGNAL,
@ -84,7 +87,7 @@ class PlexServer:
self._owner_username = None
self._plextv_clients = None
self._plextv_client_timestamp = 0
self._plextv_device_cache = {}
self._client_device_cache = {}
self._use_plex_tv = self._token is not None
self._version = None
self.async_update_platforms = Debouncer(
@ -289,6 +292,9 @@ class PlexServer:
return
def process_device(source, device):
if device is None:
return
self._known_idle.discard(device.machineIdentifier)
available_clients.setdefault(device.machineIdentifier, {"device": device})
available_clients[device.machineIdentifier].setdefault(
@ -321,13 +327,33 @@ class PlexServer:
for device in devices:
process_device("PMS", device)
def connect_to_client(source, baseurl, machine_identifier, name="Unknown"):
"""Connect to a Plex client and return a PlexClient instance."""
try:
client = PlexClient(
server=self._plex_server,
baseurl=baseurl,
token=self._plex_server.createToken(),
)
except requests.exceptions.ConnectionError:
_LOGGER.error(
"Direct client connection failed, will try again: %s (%s)",
name,
baseurl,
)
except Unauthorized:
_LOGGER.error(
"Direct client connection unauthorized, ignoring: %s (%s)",
name,
baseurl,
)
self._client_device_cache[machine_identifier] = None
else:
self._client_device_cache[client.machineIdentifier] = client
process_device(source, client)
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)
@ -335,17 +361,33 @@ class PlexServer:
_LOGGER.error("plex.tv resource connection failed: %s", resource.name)
else:
client.proxyThroughServer(value=False, server=self._plex_server)
self._client_device_cache[client.machineIdentifier] = client
process_device("plex.tv", client)
self._plextv_device_cache[client_id] = client
return client
def connect_new_clients():
"""Create connections to newly discovered clients."""
for gdm_entry in self.hass.data[DOMAIN][GDM_SCANNER].entries:
machine_identifier = gdm_entry["data"]["Resource-Identifier"]
if machine_identifier in self._client_device_cache:
client = self._client_device_cache[machine_identifier]
if client is not None:
process_device("GDM", client)
elif machine_identifier not in available_clients:
baseurl = (
f"http://{gdm_entry['from'][0]}:{gdm_entry['data']['Port']}"
)
name = gdm_entry["data"]["Name"]
connect_to_client("GDM", baseurl, machine_identifier, name)
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("plex.tv", device)
for plextv_client in plextv_clients:
if plextv_client.clientIdentifier in self._client_device_cache:
client = self._client_device_cache[plextv_client.clientIdentifier]
if client is not None:
process_device("plex.tv", client)
elif plextv_client.clientIdentifier not in available_clients:
connect_to_resource(plextv_client)
await self.hass.async_add_executor_job(connect_new_clients)
for session in sessions:
if session.TYPE == "photo":
@ -385,7 +427,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)
self._client_device_cache.pop(client_id, None)
if new_entity_configs:
async_dispatcher_send(

View File

@ -4,7 +4,7 @@ import pytest
from homeassistant.components.plex.const import DOMAIN
from .const import DEFAULT_DATA, DEFAULT_OPTIONS
from .mock_classes import MockPlexAccount, MockPlexServer
from .mock_classes import MockGDM, MockPlexAccount, MockPlexServer
from tests.async_mock import patch
from tests.common import MockConfigEntry
@ -43,8 +43,12 @@ def setup_plex_server(hass, entry, mock_plex_account, mock_websocket):
async def _wrapper(**kwargs):
"""Wrap the fixture to allow passing arguments to the MockPlexServer instance."""
config_entry = kwargs.get("config_entry", entry)
disable_gdm = kwargs.pop("disable_gdm", True)
plex_server = MockPlexServer(**kwargs)
with patch("plexapi.server.PlexServer", return_value=plex_server):
with patch("plexapi.server.PlexServer", return_value=plex_server), patch(
"homeassistant.components.plex.GDM",
return_value=MockGDM(disabled=disable_gdm),
):
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@ -13,7 +13,7 @@ from homeassistant.const import CONF_URL
from .const import DEFAULT_DATA, MOCK_SERVERS, MOCK_USERS
GDM_PAYLOAD = [
GDM_SERVER_PAYLOAD = [
{
"data": {
"Content-Type": "plex/media-server",
@ -27,17 +27,73 @@ GDM_PAYLOAD = [
}
]
GDM_CLIENT_PAYLOAD = [
{
"data": {
"Content-Type": "plex/media-player",
"Device-Class": "stb",
"Name": "plexamp",
"Port": "36000",
"Product": "Plexamp",
"Protocol": "plex",
"Protocol-Capabilities": "timeline,playback,playqueues,playqueues-creation",
"Protocol-Version": "1",
"Resource-Identifier": "client-2",
"Version": "1.1.0",
},
"from": ("1.2.3.10", 32412),
},
{
"data": {
"Content-Type": "plex/media-player",
"Device-Class": "pc",
"Name": "Chrome",
"Port": "32400",
"Product": "Plex Web",
"Protocol": "plex",
"Protocol-Capabilities": "timeline,playback,navigation,mirror,playqueues",
"Protocol-Version": "3",
"Resource-Identifier": "client-1",
"Version": "4.40.1",
},
"from": ("1.2.3.4", 32412),
},
{
"data": {
"Content-Type": "plex/media-player",
"Device-Class": "mobile",
"Name": "SHIELD Android TV",
"Port": "32500",
"Product": "Plex for Android (TV)",
"Protocol": "plex",
"Protocol-Capabilities": "timeline,playback,navigation,mirror,playqueues,provider-playback",
"Protocol-Version": "1",
"Resource-Identifier": "client-999",
"Updated-At": "1597686153",
"Version": "8.5.0.19697",
},
"from": ("1.2.3.11", 32412),
},
]
class MockGDM:
"""Mock a GDM instance."""
def __init__(self):
def __init__(self, disabled=False):
"""Initialize the object."""
self.entries = GDM_PAYLOAD
self.entries = []
self.disabled = disabled
def scan(self):
def scan(self, scan_for_clients=False):
"""Mock the scan call."""
pass
if self.disabled:
return
if scan_for_clients:
self.entries = GDM_CLIENT_PAYLOAD
else:
self.entries = GDM_SERVER_PAYLOAD
class MockResource:
@ -56,7 +112,9 @@ class MockResource:
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.device = MockPlexClient(
baseurl=f"http://192.168.0.1{index}:32500", index=index + 10
)
self.presence = index == 0
self.publicAddressMatches = True
@ -122,6 +180,7 @@ class MockPlexServer:
self._systemAccounts = list(map(MockPlexSystemAccount, range(num_users)))
self._clients = []
self._session = None
self._sessions = []
self.set_clients(num_users)
self.set_sessions(num_users, session_type)
@ -130,7 +189,9 @@ class MockPlexServer:
def set_clients(self, num_clients):
"""Set up mock PlexClients for this PlexServer."""
self._clients = [MockPlexClient(self._baseurl, x) for x in range(num_clients)]
self._clients = [
MockPlexClient(baseurl=self._baseurl, index=x) for x in range(num_clients)
]
def set_sessions(self, num_sessions, session_type):
"""Set up mock PlexSessions for this PlexServer."""
@ -151,6 +212,10 @@ class MockPlexServer:
"""Mock the clients method."""
return self._clients
def createToken(self):
"""Mock the createToken method."""
return "temporary_token"
def sessions(self):
"""Mock the sessions method."""
return self._sessions
@ -204,10 +269,10 @@ class MockPlexServer:
class MockPlexClient:
"""Mock a PlexClient instance."""
def __init__(self, url, index=0):
def __init__(self, server=None, baseurl=None, token=None, index=0):
"""Initialize the object."""
self.machineIdentifier = f"client-{index+1}"
self._baseurl = url
self._baseurl = baseurl
self._index = index
def url(self, key):

View File

@ -37,7 +37,13 @@ from homeassistant.const import (
from .const import DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN
from .helpers import trigger_plex_update
from .mock_classes import MockGDM, MockPlexAccount, MockPlexServer, MockResource
from .mock_classes import (
MockGDM,
MockPlexAccount,
MockPlexClient,
MockPlexServer,
MockResource,
)
from tests.async_mock import patch
from tests.common import MockConfigEntry
@ -434,10 +440,11 @@ async def test_option_flow_new_users_available(
OPTIONS_OWNER_ONLY[MP_DOMAIN][CONF_MONITORED_USERS] = {"Owner": {"enabled": True}}
entry.options = OPTIONS_OWNER_ONLY
mock_plex_server = await setup_plex_server(config_entry=entry)
mock_plex_server = await setup_plex_server(config_entry=entry, disable_gdm=False)
trigger_plex_update(mock_websocket)
await hass.async_block_till_done()
with patch("homeassistant.components.plex.server.PlexClient", new=MockPlexClient):
trigger_plex_update(mock_websocket)
await hass.async_block_till_done()
server_id = mock_plex_server.machineIdentifier
monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
@ -640,7 +647,11 @@ async def test_manual_config(hass):
with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
"homeassistant.components.plex.PlexWebsocket", autospec=True
), patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
), patch(
"homeassistant.components.plex.GDM", return_value=MockGDM(disabled=True)
), patch(
"plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MANUAL_SERVER
)
@ -674,7 +685,11 @@ async def test_manual_config_with_token(hass):
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()), patch(
"plexapi.server.PlexServer", return_value=mock_plex_server
), patch("homeassistant.components.plex.PlexWebsocket", autospec=True):
), patch(
"homeassistant.components.plex.GDM", return_value=MockGDM(disabled=True)
), patch(
"homeassistant.components.plex.PlexWebsocket", autospec=True
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_TOKEN: MOCK_TOKEN}
)

View File

@ -18,7 +18,7 @@ import homeassistant.util.dt as dt_util
from .const import DEFAULT_DATA, DEFAULT_OPTIONS
from .helpers import trigger_plex_update
from .mock_classes import MockPlexAccount, MockPlexServer
from .mock_classes import MockGDM, MockPlexAccount, MockPlexServer
from tests.async_mock import patch
from tests.common import MockConfigEntry, async_fire_time_changed
@ -183,6 +183,8 @@ async def test_bad_token_with_tokenless_server(hass, entry):
"""Test setup with a bad token and a server with token auth disabled."""
with patch("plexapi.server.PlexServer", return_value=MockPlexServer()), patch(
"plexapi.myplex.MyPlexAccount", side_effect=plexapi.exceptions.Unauthorized
), patch(
"homeassistant.components.plex.GDM", return_value=MockGDM(disabled=True)
), patch(
"homeassistant.components.plex.PlexWebsocket", autospec=True
) as mock_websocket:

View File

@ -26,7 +26,7 @@ async def test_plex_tv_clients(hass, entry, mock_plex_account, setup_plex_server
# Ensure one more client is discovered
await hass.config_entries.async_unload(entry.entry_id)
mock_plex_server = await setup_plex_server(config_entry=entry)
mock_plex_server = await setup_plex_server()
plex_server = hass.data[DOMAIN][SERVERS][server_id]
await plex_server._async_update_platforms()
@ -38,7 +38,7 @@ async def test_plex_tv_clients(hass, entry, mock_plex_account, setup_plex_server
# Ensure only plex.tv resource client is found
await hass.config_entries.async_unload(entry.entry_id)
mock_plex_server = await setup_plex_server(config_entry=entry)
mock_plex_server = await setup_plex_server()
mock_plex_server.clear_clients()
mock_plex_server.clear_sessions()

View File

@ -2,7 +2,7 @@
import copy
from plexapi.exceptions import BadRequest, NotFound
from requests.exceptions import RequestException
from requests.exceptions import ConnectionError, RequestException
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.media_player.const import (
@ -28,6 +28,7 @@ from homeassistant.const import ATTR_ENTITY_ID
from .const import DEFAULT_DATA, DEFAULT_OPTIONS
from .helpers import trigger_plex_update
from .mock_classes import (
MockGDM,
MockPlexAccount,
MockPlexAlbum,
MockPlexArtist,
@ -125,6 +126,24 @@ async def test_network_error_during_refresh(
)
async def test_gdm_client_failure(hass, mock_websocket, setup_plex_server):
"""Test connection failure to a GDM discovered client."""
mock_plex_server = await setup_plex_server(disable_gdm=False)
with patch(
"homeassistant.components.plex.server.PlexClient", side_effect=ConnectionError
):
trigger_plex_update(mock_websocket)
await hass.async_block_till_done()
sensor = hass.states.get("sensor.plex_plex_server_1")
assert sensor.state == str(len(mock_plex_server.accounts))
with patch.object(mock_plex_server, "clients", side_effect=RequestException):
trigger_plex_update(mock_websocket)
await hass.async_block_till_done()
async def test_mark_sessions_idle(hass, mock_plex_server, mock_websocket):
"""Test marking media_players as idle when sessions end."""
server_id = mock_plex_server.machineIdentifier
@ -156,7 +175,7 @@ async def test_ignore_plex_web_client(hass, entry, mock_websocket):
with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
"plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(players=0)
):
), patch("homeassistant.components.plex.GDM", return_value=MockGDM(disabled=True)):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()