Discover Plex clients using GDM (#39053)
parent
72759d7501
commit
c63c253b7f
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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}
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue