Debounce calls to Plex server (#33560)

* Debounce calls to Plex server

* Simplify debounce by recommendation

* Update tests to handle debounce

* Test debouncer, fix & optimize tests

* Use property instead
pull/33814/head
jjlawren 2020-04-04 00:34:42 -05:00 committed by Paulus Schoutsen
parent 4ead87270e
commit 0763503151
6 changed files with 120 additions and 35 deletions

View File

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

View File

@ -1,4 +1,5 @@
"""Shared class to maintain Plex server instances.""" """Shared class to maintain Plex server instances."""
from functools import partial, wraps
import logging import logging
import ssl import ssl
from urllib.parse import urlparse from urllib.parse import urlparse
@ -12,6 +13,7 @@ import requests.exceptions
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL
from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import async_call_later
from .const import ( from .const import (
CONF_CLIENT_IDENTIFIER, CONF_CLIENT_IDENTIFIER,
@ -19,6 +21,7 @@ from .const import (
CONF_MONITORED_USERS, CONF_MONITORED_USERS,
CONF_SERVER, CONF_SERVER,
CONF_USE_EPISODE_ART, CONF_USE_EPISODE_ART,
DEBOUNCE_TIMEOUT,
DEFAULT_VERIFY_SSL, DEFAULT_VERIFY_SSL,
PLEX_NEW_MP_SIGNAL, PLEX_NEW_MP_SIGNAL,
PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, PLEX_UPDATE_MEDIA_PLAYER_SIGNAL,
@ -39,12 +42,37 @@ plexapi.X_PLEX_PRODUCT = X_PLEX_PRODUCT
plexapi.X_PLEX_VERSION = X_PLEX_VERSION plexapi.X_PLEX_VERSION = X_PLEX_VERSION
def debounce(func):
"""Decorate function to debounce callbacks from Plex websocket."""
unsub = None
async def call_later_listener(self, _):
"""Handle call_later callback."""
nonlocal unsub
unsub = None
await self.hass.async_add_executor_job(func, self)
@wraps(func)
def wrapper(self):
"""Schedule async callback."""
nonlocal unsub
if unsub:
_LOGGER.debug("Throttling update of %s", self.friendly_name)
unsub() # pylint: disable=not-callable
unsub = async_call_later(
self.hass, DEBOUNCE_TIMEOUT, partial(call_later_listener, self),
)
return wrapper
class PlexServer: class PlexServer:
"""Manages a single Plex server connection.""" """Manages a single Plex server connection."""
def __init__(self, hass, server_config, known_server_id=None, options=None): def __init__(self, hass, server_config, known_server_id=None, options=None):
"""Initialize a Plex server instance.""" """Initialize a Plex server instance."""
self._hass = hass self.hass = hass
self._plex_server = None self._plex_server = None
self._known_clients = set() self._known_clients = set()
self._known_idle = set() self._known_idle = set()
@ -150,12 +178,13 @@ class PlexServer:
unique_id = f"{self.machine_identifier}:{machine_identifier}" unique_id = f"{self.machine_identifier}:{machine_identifier}"
_LOGGER.debug("Refreshing %s", unique_id) _LOGGER.debug("Refreshing %s", unique_id)
dispatcher_send( dispatcher_send(
self._hass, self.hass,
PLEX_UPDATE_MEDIA_PLAYER_SIGNAL.format(unique_id), PLEX_UPDATE_MEDIA_PLAYER_SIGNAL.format(unique_id),
device, device,
session, session,
) )
@debounce
def update_platforms(self): def update_platforms(self):
"""Update the platform entities.""" """Update the platform entities."""
_LOGGER.debug("Updating devices") _LOGGER.debug("Updating devices")
@ -239,13 +268,13 @@ class PlexServer:
if new_entity_configs: if new_entity_configs:
dispatcher_send( dispatcher_send(
self._hass, self.hass,
PLEX_NEW_MP_SIGNAL.format(self.machine_identifier), PLEX_NEW_MP_SIGNAL.format(self.machine_identifier),
new_entity_configs, new_entity_configs,
) )
dispatcher_send( dispatcher_send(
self._hass, self.hass,
PLEX_UPDATE_SENSOR_SIGNAL.format(self.machine_identifier), PLEX_UPDATE_SENSOR_SIGNAL.format(self.machine_identifier),
sessions, sessions,
) )

View File

@ -0,0 +1,20 @@
"""Common fixtures and functions for Plex tests."""
from datetime import timedelta
from homeassistant.components.plex.const import (
DEBOUNCE_TIMEOUT,
PLEX_UPDATE_PLATFORMS_SIGNAL,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
import homeassistant.util.dt as dt_util
from tests.common import async_fire_time_changed
async def trigger_plex_update(hass, server_id):
"""Update Plex by sending signal and jumping ahead by debounce timeout."""
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
next_update = dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()

View File

@ -15,14 +15,13 @@ from homeassistant.components.plex.const import (
CONF_USE_EPISODE_ART, CONF_USE_EPISODE_ART,
DOMAIN, DOMAIN,
PLEX_SERVER_CONFIG, PLEX_SERVER_CONFIG,
PLEX_UPDATE_PLATFORMS_SIGNAL,
SERVERS, SERVERS,
) )
from homeassistant.config_entries import ENTRY_STATE_LOADED from homeassistant.config_entries import ENTRY_STATE_LOADED
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, CONF_URL from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, CONF_URL
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .common import trigger_plex_update
from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN
from .mock_classes import MockPlexAccount, MockPlexServer from .mock_classes import MockPlexAccount, MockPlexServer
@ -416,8 +415,7 @@ async def test_option_flow_new_users_available(hass, caplog):
server_id = mock_plex_server.machineIdentifier server_id = mock_plex_server.machineIdentifier
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) await trigger_plex_update(hass, server_id)
await hass.async_block_till_done()
monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users

View File

@ -23,10 +23,10 @@ from homeassistant.const import (
CONF_URL, CONF_URL,
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
) )
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from .common import trigger_plex_update
from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN
from .mock_classes import MockPlexAccount, MockPlexServer from .mock_classes import MockPlexAccount, MockPlexServer
@ -74,7 +74,7 @@ async def test_setup_with_config(hass):
) )
async def test_setup_with_config_entry(hass): async def test_setup_with_config_entry(hass, caplog):
"""Test setup component with config.""" """Test setup component with config."""
mock_plex_server = MockPlexServer() mock_plex_server = MockPlexServer()
@ -109,30 +109,31 @@ async def test_setup_with_config_entry(hass):
hass.data[const.DOMAIN][const.PLATFORMS_COMPLETED][server_id] == const.PLATFORMS hass.data[const.DOMAIN][const.PLATFORMS_COMPLETED][server_id] == const.PLATFORMS
) )
async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) await trigger_plex_update(hass, server_id)
await hass.async_block_till_done()
sensor = hass.states.get("sensor.plex_plex_server_1") sensor = hass.states.get("sensor.plex_plex_server_1")
assert sensor.state == str(len(mock_plex_server.accounts)) assert sensor.state == str(len(mock_plex_server.accounts))
async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) await trigger_plex_update(hass, server_id)
await hass.async_block_till_done()
with patch.object( with patch.object(
mock_plex_server, "clients", side_effect=plexapi.exceptions.BadRequest mock_plex_server, "clients", side_effect=plexapi.exceptions.BadRequest
): ) as patched_clients_bad_request:
async_dispatcher_send( await trigger_plex_update(hass, server_id)
hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)
) assert patched_clients_bad_request.called
await hass.async_block_till_done() assert "Error requesting Plex client data from server" in caplog.text
with patch.object( with patch.object(
mock_plex_server, "clients", side_effect=requests.exceptions.RequestException mock_plex_server, "clients", side_effect=requests.exceptions.RequestException
): ) as patched_clients_requests_exception:
async_dispatcher_send( await trigger_plex_update(hass, server_id)
hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)
) assert patched_clients_requests_exception.called
await hass.async_block_till_done() assert (
f"Could not connect to Plex server: {mock_plex_server.friendlyName}"
in caplog.text
)
async def test_set_config_entry_unique_id(hass): async def test_set_config_entry_unique_id(hass):
@ -294,8 +295,7 @@ 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)) await trigger_plex_update(hass, server_id)
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")
assert media_player.state == "idle" assert media_player.state == "idle"

View File

@ -1,5 +1,6 @@
"""Tests for Plex server.""" """Tests for Plex server."""
import copy import copy
from datetime import timedelta
from asynctest import patch from asynctest import patch
@ -7,16 +8,19 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.plex.const import ( from homeassistant.components.plex.const import (
CONF_IGNORE_NEW_SHARED_USERS, CONF_IGNORE_NEW_SHARED_USERS,
CONF_MONITORED_USERS, CONF_MONITORED_USERS,
DEBOUNCE_TIMEOUT,
DOMAIN, DOMAIN,
PLEX_UPDATE_PLATFORMS_SIGNAL, PLEX_UPDATE_PLATFORMS_SIGNAL,
SERVERS, SERVERS,
) )
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
import homeassistant.util.dt as dt_util
from .common import trigger_plex_update
from .const import DEFAULT_DATA, DEFAULT_OPTIONS from .const import DEFAULT_DATA, DEFAULT_OPTIONS
from .mock_classes import MockPlexServer from .mock_classes import MockPlexServer
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, async_fire_time_changed
async def test_new_users_available(hass): async def test_new_users_available(hass):
@ -44,8 +48,7 @@ async def test_new_users_available(hass):
server_id = mock_plex_server.machineIdentifier server_id = mock_plex_server.machineIdentifier
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) await trigger_plex_update(hass, server_id)
await hass.async_block_till_done()
monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
@ -83,8 +86,7 @@ async def test_new_ignored_users_available(hass, caplog):
server_id = mock_plex_server.machineIdentifier server_id = mock_plex_server.machineIdentifier
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) await trigger_plex_update(hass, server_id)
await hass.async_block_till_done()
monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
@ -118,8 +120,7 @@ async def test_mark_sessions_idle(hass):
server_id = mock_plex_server.machineIdentifier server_id = mock_plex_server.machineIdentifier
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) await trigger_plex_update(hass, server_id)
await hass.async_block_till_done()
sensor = hass.states.get("sensor.plex_plex_server_1") sensor = hass.states.get("sensor.plex_plex_server_1")
assert sensor.state == str(len(mock_plex_server.accounts)) assert sensor.state == str(len(mock_plex_server.accounts))
@ -127,8 +128,44 @@ async def test_mark_sessions_idle(hass):
mock_plex_server.clear_clients() mock_plex_server.clear_clients()
mock_plex_server.clear_sessions() mock_plex_server.clear_sessions()
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) await trigger_plex_update(hass, server_id)
await hass.async_block_till_done()
sensor = hass.states.get("sensor.plex_plex_server_1") sensor = hass.states.get("sensor.plex_plex_server_1")
assert sensor.state == "0" assert sensor.state == "0"
async def test_debouncer(hass, caplog):
"""Test debouncer decorator logic."""
entry = MockConfigEntry(
domain=DOMAIN,
data=DEFAULT_DATA,
options=DEFAULT_OPTIONS,
unique_id=DEFAULT_DATA["server_id"],
)
mock_plex_server = MockPlexServer(config_entry=entry)
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
# First two updates are skipped
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
await hass.async_block_till_done()
next_update = dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert (
caplog.text.count(f"Throttling update of {mock_plex_server.friendlyName}") == 2
)