Add Plex library count sensors (#48339)

This commit is contained in:
jjlawren 2021-03-31 06:57:16 -05:00 committed by GitHub
parent 93498ec831
commit d62297a28b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 262 additions and 12 deletions

View File

@ -26,7 +26,10 @@ from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from .const import ( from .const import (
CONF_SERVER, CONF_SERVER,
@ -38,6 +41,7 @@ from .const import (
PLATFORMS, PLATFORMS,
PLATFORMS_COMPLETED, PLATFORMS_COMPLETED,
PLEX_SERVER_CONFIG, PLEX_SERVER_CONFIG,
PLEX_UPDATE_LIBRARY_SIGNAL,
PLEX_UPDATE_PLATFORMS_SIGNAL, PLEX_UPDATE_PLATFORMS_SIGNAL,
SERVERS, SERVERS,
WEBSOCKETS, WEBSOCKETS,
@ -179,12 +183,20 @@ async def async_setup_entry(hass, entry):
elif msgtype == "playing": elif msgtype == "playing":
hass.async_create_task(plex_server.async_update_session(data)) hass.async_create_task(plex_server.async_update_session(data))
elif msgtype == "status":
if data["StatusNotification"][0]["title"] == "Library scan complete":
async_dispatcher_send(
hass,
PLEX_UPDATE_LIBRARY_SIGNAL.format(server_id),
)
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
subscriptions = ["playing", "status"]
verify_ssl = server_config.get(CONF_VERIFY_SSL) verify_ssl = server_config.get(CONF_VERIFY_SSL)
websocket = PlexWebsocket( websocket = PlexWebsocket(
plex_server.plex_server, plex_server.plex_server,
plex_websocket_callback, plex_websocket_callback,
subscriptions=subscriptions,
session=session, session=session,
verify_ssl=verify_ssl, verify_ssl=verify_ssl,
) )

View File

@ -26,6 +26,7 @@ PLEX_SERVER_CONFIG = "server_config"
PLEX_NEW_MP_SIGNAL = "plex_new_mp_signal.{}" PLEX_NEW_MP_SIGNAL = "plex_new_mp_signal.{}"
PLEX_UPDATE_MEDIA_PLAYER_SESSION_SIGNAL = "plex_update_session_signal.{}" PLEX_UPDATE_MEDIA_PLAYER_SESSION_SIGNAL = "plex_update_session_signal.{}"
PLEX_UPDATE_MEDIA_PLAYER_SIGNAL = "plex_update_mp_signal.{}" PLEX_UPDATE_MEDIA_PLAYER_SIGNAL = "plex_update_mp_signal.{}"
PLEX_UPDATE_LIBRARY_SIGNAL = "plex_update_libraries_signal.{}"
PLEX_UPDATE_PLATFORMS_SIGNAL = "plex_update_platforms_signal.{}" PLEX_UPDATE_PLATFORMS_SIGNAL = "plex_update_platforms_signal.{}"
PLEX_UPDATE_SENSOR_SIGNAL = "plex_update_sensor_signal.{}" PLEX_UPDATE_SENSOR_SIGNAL = "plex_update_sensor_signal.{}"

View File

@ -1,19 +1,40 @@
"""Support for Plex media server monitoring.""" """Support for Plex media server monitoring."""
import logging import logging
from plexapi.exceptions import NotFound
from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorEntity
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import ( from .const import (
CONF_SERVER_IDENTIFIER, CONF_SERVER_IDENTIFIER,
DISPATCHERS,
DOMAIN as PLEX_DOMAIN, DOMAIN as PLEX_DOMAIN,
NAME_FORMAT, NAME_FORMAT,
PLEX_UPDATE_LIBRARY_SIGNAL,
PLEX_UPDATE_SENSOR_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL,
SERVERS, SERVERS,
) )
LIBRARY_ATTRIBUTE_TYPES = {
"artist": ["artist", "album"],
"photo": ["photoalbum"],
"show": ["show", "season"],
}
LIBRARY_PRIMARY_LIBTYPE = {
"show": "episode",
"artist": "track",
}
LIBRARY_ICON_LOOKUP = {
"artist": "mdi:music",
"movie": "mdi:movie",
"photo": "mdi:image",
"show": "mdi:television",
}
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -21,8 +42,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Plex sensor from a config entry.""" """Set up Plex sensor from a config entry."""
server_id = config_entry.data[CONF_SERVER_IDENTIFIER] server_id = config_entry.data[CONF_SERVER_IDENTIFIER]
plexserver = hass.data[PLEX_DOMAIN][SERVERS][server_id] plexserver = hass.data[PLEX_DOMAIN][SERVERS][server_id]
sensor = PlexSensor(hass, plexserver) sensors = [PlexSensor(hass, plexserver)]
async_add_entities([sensor])
def create_library_sensors():
"""Create Plex library sensors with sync calls."""
for library in plexserver.library.sections():
sensors.append(PlexLibrarySectionSensor(hass, plexserver, library))
await hass.async_add_executor_job(create_library_sensors)
async_add_entities(sensors)
class PlexSensor(SensorEntity): class PlexSensor(SensorEntity):
@ -45,12 +73,13 @@ class PlexSensor(SensorEntity):
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Run when about to be added to hass.""" """Run when about to be added to hass."""
server_id = self._server.machine_identifier server_id = self._server.machine_identifier
unsub = async_dispatcher_connect( self.async_on_remove(
self.hass, async_dispatcher_connect(
PLEX_UPDATE_SENSOR_SIGNAL.format(server_id), self.hass,
self.async_refresh_sensor, PLEX_UPDATE_SENSOR_SIGNAL.format(server_id),
self.async_refresh_sensor,
)
) )
self.hass.data[PLEX_DOMAIN][DISPATCHERS][server_id].append(unsub)
async def _async_refresh_sensor(self): async def _async_refresh_sensor(self):
"""Set instance object and trigger an entity state update.""" """Set instance object and trigger an entity state update."""
@ -103,6 +132,110 @@ class PlexSensor(SensorEntity):
"identifiers": {(PLEX_DOMAIN, self._server.machine_identifier)}, "identifiers": {(PLEX_DOMAIN, self._server.machine_identifier)},
"manufacturer": "Plex", "manufacturer": "Plex",
"model": "Plex Media Server", "model": "Plex Media Server",
"name": "Activity Sensor", "name": self._server.friendly_name,
"sw_version": self._server.version,
}
class PlexLibrarySectionSensor(SensorEntity):
"""Representation of a Plex library section sensor."""
def __init__(self, hass, plex_server, plex_library_section):
"""Initialize the sensor."""
self._server = plex_server
self.server_name = plex_server.friendly_name
self.server_id = plex_server.machine_identifier
self.library_section = plex_library_section
self.library_type = plex_library_section.type
self._name = f"{self.server_name} Library - {plex_library_section.title}"
self._unique_id = f"library-{self.server_id}-{plex_library_section.uuid}"
self._state = None
self._attributes = {}
async def async_added_to_hass(self):
"""Run when about to be added to hass."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
PLEX_UPDATE_LIBRARY_SIGNAL.format(self.server_id),
self.async_refresh_sensor,
)
)
await self.async_refresh_sensor()
async def async_refresh_sensor(self):
"""Update state and attributes for the library sensor."""
_LOGGER.debug("Refreshing library sensor for '%s'", self.name)
try:
await self.hass.async_add_executor_job(self._update_state_and_attrs)
except NotFound:
self._state = STATE_UNAVAILABLE
self.async_write_ha_state()
def _update_state_and_attrs(self):
"""Update library sensor state with sync calls."""
primary_libtype = LIBRARY_PRIMARY_LIBTYPE.get(
self.library_type, self.library_type
)
self._state = self.library_section.totalViewSize(
libtype=primary_libtype, includeCollections=False
)
for libtype in LIBRARY_ATTRIBUTE_TYPES.get(self.library_type, []):
self._attributes[f"{libtype}s"] = self.library_section.totalViewSize(
libtype=libtype, includeCollections=False
)
@property
def entity_registry_enabled_default(self):
"""Return if sensor should be enabled by default."""
return False
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def unique_id(self):
"""Return the id of this plex client."""
return self._unique_id
@property
def should_poll(self):
"""Return True if entity has to be polled for state."""
return False
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
return "Items"
@property
def icon(self):
"""Return the icon of the sensor."""
return LIBRARY_ICON_LOOKUP.get(self.library_type, "mdi:plex")
@property
def extra_state_attributes(self):
"""Return the state attributes."""
return self._attributes
@property
def device_info(self):
"""Return a device description for device registry."""
if self.unique_id is None:
return None
return {
"identifiers": {(PLEX_DOMAIN, self.server_id)},
"manufacturer": "Plex",
"model": "Plex Media Server",
"name": self.server_name,
"sw_version": self._server.version, "sw_version": self._server.version,
} }

View File

@ -120,6 +120,24 @@ def library_fixture():
return load_fixture("plex/library.xml") return load_fixture("plex/library.xml")
@pytest.fixture(name="library_tvshows_size", scope="session")
def library_tvshows_size_fixture():
"""Load tvshow library size payload and return it."""
return load_fixture("plex/library_tvshows_size.xml")
@pytest.fixture(name="library_tvshows_size_episodes", scope="session")
def library_tvshows_size_episodes_fixture():
"""Load tvshow library size in episodes payload and return it."""
return load_fixture("plex/library_tvshows_size_episodes.xml")
@pytest.fixture(name="library_tvshows_size_seasons", scope="session")
def library_tvshows_size_seasons_fixture():
"""Load tvshow library size in seasons payload and return it."""
return load_fixture("plex/library_tvshows_size_seasons.xml")
@pytest.fixture(name="library_sections", scope="session") @pytest.fixture(name="library_sections", scope="session")
def library_sections_fixture(): def library_sections_fixture():
"""Load library sections payload and return it.""" """Load library sections payload and return it."""

View File

@ -26,10 +26,10 @@ def websocket_connected(mock_websocket):
callback(SIGNAL_CONNECTION_STATE, STATE_CONNECTED, None) callback(SIGNAL_CONNECTION_STATE, STATE_CONNECTED, None)
def trigger_plex_update(mock_websocket, payload=UPDATE_PAYLOAD): def trigger_plex_update(mock_websocket, msgtype="playing", payload=UPDATE_PAYLOAD):
"""Call the websocket callback method with a Plex update.""" """Call the websocket callback method with a Plex update."""
callback = mock_websocket.call_args[0][1] callback = mock_websocket.call_args[0][1]
callback("playing", payload, None) callback(msgtype, payload, None)
async def wait_for_debouncer(hass): async def wait_for_debouncer(hass):

View File

@ -650,6 +650,7 @@ async def test_manual_config(hass, mock_plex_calls):
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MANUAL_SERVER result["flow_id"], user_input=MANUAL_SERVER
) )
await hass.async_block_till_done()
assert result["type"] == "create_entry" assert result["type"] == "create_entry"

View File

@ -0,0 +1,76 @@
"""Tests for Plex sensors."""
from datetime import timedelta
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt
from .helpers import trigger_plex_update, wait_for_debouncer
from tests.common import async_fire_time_changed
LIBRARY_UPDATE_PAYLOAD = {"StatusNotification": [{"title": "Library scan complete"}]}
async def test_library_sensor_values(
hass,
setup_plex_server,
mock_websocket,
requests_mock,
library_tvshows_size,
library_tvshows_size_episodes,
library_tvshows_size_seasons,
):
"""Test the library sensors."""
requests_mock.get(
"/library/sections/2/all?includeCollections=0&type=2",
text=library_tvshows_size,
)
requests_mock.get(
"/library/sections/2/all?includeCollections=0&type=3",
text=library_tvshows_size_seasons,
)
requests_mock.get(
"/library/sections/2/all?includeCollections=0&type=4",
text=library_tvshows_size_episodes,
)
await setup_plex_server()
await wait_for_debouncer(hass)
activity_sensor = hass.states.get("sensor.plex_plex_server_1")
assert activity_sensor.state == "1"
# Ensure sensor is created as disabled
assert hass.states.get("sensor.plex_server_1_library_tv_shows") is None
# Enable sensor and validate values
entity_registry = er.async_get(hass)
entity_registry.async_update_entity(
entity_id="sensor.plex_server_1_library_tv_shows", disabled_by=None
)
await hass.async_block_till_done()
async_fire_time_changed(
hass,
dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows")
assert library_tv_sensor.state == "10"
assert library_tv_sensor.attributes["seasons"] == 1
assert library_tv_sensor.attributes["shows"] == 1
# Handle library deletion
requests_mock.get(
"/library/sections/2/all?includeCollections=0&type=2", status_code=404
)
trigger_plex_update(
mock_websocket, msgtype="status", payload=LIBRARY_UPDATE_PAYLOAD
)
await hass.async_block_till_done()
library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows")
assert library_tv_sensor.state == STATE_UNAVAILABLE

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<MediaContainer size="0" totalSize="1" allowSync="1" art="/:/resources/show-fanart.jpg" identifier="com.plexapp.plugins.library" librarySectionID="2" librarySectionTitle="TV Shows" librarySectionUUID="905308ec-5019-43d4-a449-75d2b9e42f93" mediaTagPrefix="/system/bundle/media/flags/" mediaTagVersion="1614092584" nocache="1" offset="0" sortAsc="1" thumb="/:/resources/show.png" title1="TV Shows" title2="All Shows" viewGroup="show" viewMode="131122">
</MediaContainer>

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<MediaContainer size="0" totalSize="10" allowSync="1" art="/:/resources/show-fanart.jpg" identifier="com.plexapp.plugins.library" librarySectionID="2" librarySectionTitle="TV Shows" librarySectionUUID="905308ec-5019-43d4-a449-75d2b9e42f93" mediaTagPrefix="/system/bundle/media/flags/" mediaTagVersion="1614092584" nocache="1" offset="0" sortAsc="1" thumb="/:/resources/show.png" title1="TV Shows" title2="All Shows" viewGroup="show" viewMode="131122">
</MediaContainer>

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<MediaContainer size="0" totalSize="1" allowSync="1" art="/:/resources/show-fanart.jpg" identifier="com.plexapp.plugins.library" librarySectionID="2" librarySectionTitle="TV Shows" librarySectionUUID="905308ec-5019-43d4-a449-75d2b9e42f93" mediaTagPrefix="/system/bundle/media/flags/" mediaTagVersion="1614092584" nocache="1" offset="0" sortAsc="1" thumb="/:/resources/show.png" title1="TV Shows" title2="All Shows" viewGroup="show" viewMode="131122">
</MediaContainer>