Add update entity for Plex Media Server (#101682)

This commit is contained in:
jjlawren 2023-10-11 06:06:10 -05:00 committed by GitHub
parent 1a7601ebbe
commit f116e83b62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 223 additions and 1 deletions

View File

@ -20,7 +20,9 @@ DEBOUNCE_TIMEOUT = 1
DISPATCHERS: Final = "dispatchers"
GDM_DEBOUNCER: Final = "gdm_debouncer"
GDM_SCANNER: Final = "gdm_scanner"
PLATFORMS = frozenset([Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR])
PLATFORMS = frozenset(
[Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.UPDATE]
)
PLATFORMS_COMPLETED: Final = "platforms_completed"
PLAYER_SOURCE = "player_source"
SERVERS: Final = "servers"

View File

@ -0,0 +1,76 @@
"""Representation of Plex updates."""
import logging
from typing import Any
from plexapi.exceptions import PlexApiException
import plexapi.server
import requests.exceptions
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import CONF_SERVER_IDENTIFIER
from .helpers import get_plex_server
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Plex media_player from a config entry."""
server_id = config_entry.data[CONF_SERVER_IDENTIFIER]
server = get_plex_server(hass, server_id)
plex_server = server.plex_server
can_update = await hass.async_add_executor_job(plex_server.canInstallUpdate)
async_add_entities([PlexUpdate(plex_server, can_update)], update_before_add=True)
class PlexUpdate(UpdateEntity):
"""Representation of a Plex server update entity."""
_attr_supported_features = UpdateEntityFeature.RELEASE_NOTES
_release_notes: str | None = None
def __init__(
self, plex_server: plexapi.server.PlexServer, can_update: bool
) -> None:
"""Initialize the Update entity."""
self.plex_server = plex_server
self._attr_name = f"Plex Media Server ({plex_server.friendlyName})"
self._attr_unique_id = plex_server.machineIdentifier
if can_update:
self._attr_supported_features |= UpdateEntityFeature.INSTALL
def update(self) -> None:
"""Update sync attributes."""
self._attr_installed_version = self.plex_server.version
try:
if (release := self.plex_server.checkForUpdate()) is None:
return
except (requests.exceptions.RequestException, PlexApiException):
_LOGGER.debug("Polling update sensor failed, will try again")
return
self._attr_latest_version = release.version
if release.fixed:
self._release_notes = "\n".join(
f"* {line}" for line in release.fixed.split("\n")
)
else:
self._release_notes = None
def release_notes(self) -> str | None:
"""Return release notes for the available upgrade."""
return self._release_notes
def install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
"""Install an update."""
try:
self.plex_server.installUpdate()
except (requests.exceptions.RequestException, PlexApiException) as exc:
raise HomeAssistantError(str(exc)) from exc

View File

@ -396,6 +396,24 @@ def hubs_music_library_fixture():
return load_fixture("plex/hubs_library_section.xml")
@pytest.fixture(name="update_check_nochange", scope="session")
def update_check_fixture_nochange() -> str:
"""Load a no-change update resource payload and return it."""
return load_fixture("plex/release_nochange.xml")
@pytest.fixture(name="update_check_new", scope="session")
def update_check_fixture_new() -> str:
"""Load a changed update resource payload and return it."""
return load_fixture("plex/release_new.xml")
@pytest.fixture(name="update_check_new_not_updatable", scope="session")
def update_check_fixture_new_not_updatable() -> str:
"""Load a changed update resource payload (not updatable) and return it."""
return load_fixture("plex/release_new_not_updatable.xml")
@pytest.fixture(name="entry")
async def mock_config_entry():
"""Return the default mocked config entry."""
@ -452,6 +470,7 @@ def mock_plex_calls(
plex_server_clients,
plex_server_default,
security_token,
update_check_nochange,
):
"""Mock Plex API calls."""
requests_mock.get("https://plex.tv/api/users/", text=plextv_shared_users)
@ -519,6 +538,8 @@ def mock_plex_calls(
requests_mock.get(f"{url}/playlists", text=playlists)
requests_mock.get(f"{url}/playlists/500/items", text=playlist_500)
requests_mock.get(f"{url}/security/token", text=security_token)
requests_mock.put(f"{url}/updater/check")
requests_mock.get(f"{url}/updater/status", text=update_check_nochange)
@pytest.fixture

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<MediaContainer size="1" canInstall="1" checkedAt="1696794052" downloadURL="https://plex.tv/downloads/latest/5?channel=0&amp;build=linux-x86_64&amp;distro=debian&amp;X-Plex-Token=xxxxxxxxxxxxxxxxxxxx" status="0">
<Release key="https://plex.tv/updater/releases/9999" version="1.50.0" added="" fixed="Summary of&#xA;release notes" downloadURL="https://plex.tv/downloads/latest/5?channel=0&amp;build=linux-x86_64&amp;distro=debian&amp;X-Plex-Token=xxxxxxxxxxxxxxxxxxxx" state="notify" />
</MediaContainer>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<MediaContainer size="1" canInstall="0" checkedAt="1696794052" downloadURL="https://plex.tv/downloads/latest/5?channel=0&amp;build=linux-x86_64&amp;distro=debian&amp;X-Plex-Token=xxxxxxxxxxxxxxxxxxxx" status="0">
<Release key="https://plex.tv/updater/releases/9999" version="1.50.0" added="" fixed="Summary of&#xA;release notes" downloadURL="https://plex.tv/downloads/latest/5?channel=0&amp;build=linux-x86_64&amp;distro=debian&amp;X-Plex-Token=xxxxxxxxxxxxxxxxxxxx" state="notify" />
</MediaContainer>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<MediaContainer size="1" canInstall="0" checkedAt="1696794052" downloadURL="https://plex.tv/downloads/latest/5?channel=0&amp;build=linux-x86_64&amp;distro=debian&amp;X-Plex-Token=xxxxxxxxxxxxxxxxxxxx" status="0">
<Release key="https://plex.tv/updater/releases/1" version="1.20.4.3517-ab5e1197c" added="" fixed="" downloadURL="" state="notify" />
</MediaContainer>

View File

@ -0,0 +1,111 @@
"""Tests for update entities."""
import pytest
import requests_mock
from homeassistant.components.update import (
DOMAIN as UPDATE_DOMAIN,
SCAN_INTERVAL as UPDATER_SCAN_INTERVAL,
SERVICE_INSTALL,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, HomeAssistantError
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import WebSocketGenerator
UPDATE_ENTITY = "update.plex_media_server_plex_server_1"
async def test_plex_update(
hass: HomeAssistant,
entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
mock_plex_server,
requests_mock: requests_mock.Mocker,
empty_payload: str,
update_check_new: str,
update_check_new_not_updatable: str,
) -> None:
"""Test Plex update entity."""
ws_client = await hass_ws_client(hass)
assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF
await ws_client.send_json(
{
"id": 1,
"type": "update/release_notes",
"entity_id": UPDATE_ENTITY,
}
)
result = await ws_client.receive_json()
assert result["result"] is None
apply_mock = requests_mock.put("/updater/apply")
# Failed updates
requests_mock.get("/updater/status", status_code=500)
async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL)
await hass.async_block_till_done()
requests_mock.get("/updater/status", text=empty_payload)
async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL)
await hass.async_block_till_done()
# New release (not updatable)
requests_mock.get("/updater/status", text=update_check_new_not_updatable)
async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL)
await hass.async_block_till_done()
assert hass.states.get(UPDATE_ENTITY).state == STATE_ON
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: UPDATE_ENTITY,
},
blocking=True,
)
assert not apply_mock.called
# New release (updatable)
requests_mock.get("/updater/status", text=update_check_new)
await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(UPDATE_ENTITY).state == STATE_ON
ws_client = await hass_ws_client(hass)
await ws_client.send_json(
{
"id": 1,
"type": "update/release_notes",
"entity_id": UPDATE_ENTITY,
}
)
result = await ws_client.receive_json()
assert result["result"] == "* Summary of\n* release notes"
# Successful upgrade request
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: UPDATE_ENTITY,
},
blocking=True,
)
assert apply_mock.called_once
# Failed upgrade request
requests_mock.put("/updater/apply", status_code=500)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: UPDATE_ENTITY,
},
blocking=True,
)