Add sensor platform to Jellyfin (#79966)
This commit is contained in:
parent
230fe4453f
commit
46794f7a5d
10 changed files with 2051 additions and 19 deletions
|
@ -1,14 +1,14 @@
|
||||||
"""The Jellyfin integration."""
|
"""The Jellyfin integration."""
|
||||||
import logging
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
|
||||||
from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input
|
from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input
|
||||||
from .const import CONF_CLIENT_DEVICE_ID, DATA_CLIENT, DOMAIN
|
from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, LOGGER, PLATFORMS
|
||||||
|
from .coordinator import JellyfinDataUpdateCoordinator, SessionsDataUpdateCoordinator
|
||||||
_LOGGER = logging.getLogger(__name__)
|
from .models import JellyfinData
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
@ -26,14 +26,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await validate_input(hass, dict(entry.data), client)
|
_, connect_result = await validate_input(hass, dict(entry.data), client)
|
||||||
except CannotConnect as ex:
|
except CannotConnect as ex:
|
||||||
raise ConfigEntryNotReady("Cannot connect to Jellyfin server") from ex
|
raise ConfigEntryNotReady("Cannot connect to Jellyfin server") from ex
|
||||||
except InvalidAuth:
|
except InvalidAuth:
|
||||||
_LOGGER.error("Failed to login to Jellyfin server")
|
LOGGER.error("Failed to login to Jellyfin server")
|
||||||
return False
|
return False
|
||||||
else:
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = {DATA_CLIENT: client}
|
server_info: dict[str, Any] = connect_result["Servers"][0]
|
||||||
|
|
||||||
|
coordinators: dict[str, JellyfinDataUpdateCoordinator[Any]] = {
|
||||||
|
"sessions": SessionsDataUpdateCoordinator(hass, client, server_info),
|
||||||
|
}
|
||||||
|
|
||||||
|
for coordinator in coordinators.values():
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = JellyfinData(
|
||||||
|
jellyfin_client=client,
|
||||||
|
coordinators=coordinators,
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
"""Constants for the Jellyfin integration."""
|
"""Constants for the Jellyfin integration."""
|
||||||
|
import logging
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from homeassistant.const import __version__ as hass_version
|
from homeassistant.const import Platform, __version__ as hass_version
|
||||||
|
|
||||||
DOMAIN: Final = "jellyfin"
|
DOMAIN: Final = "jellyfin"
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ COLLECTION_TYPE_MUSIC: Final = "music"
|
||||||
|
|
||||||
CONF_CLIENT_DEVICE_ID: Final = "client_device_id"
|
CONF_CLIENT_DEVICE_ID: Final = "client_device_id"
|
||||||
|
|
||||||
DATA_CLIENT: Final = "client"
|
DEFAULT_NAME: Final = "Jellyfin"
|
||||||
|
|
||||||
ITEM_KEY_COLLECTION_TYPE: Final = "CollectionType"
|
ITEM_KEY_COLLECTION_TYPE: Final = "CollectionType"
|
||||||
ITEM_KEY_ID: Final = "Id"
|
ITEM_KEY_ID: Final = "Id"
|
||||||
|
@ -43,3 +43,6 @@ SUPPORTED_COLLECTION_TYPES: Final = [COLLECTION_TYPE_MUSIC, COLLECTION_TYPE_MOVI
|
||||||
|
|
||||||
USER_APP_NAME: Final = "Home Assistant"
|
USER_APP_NAME: Final = "Home Assistant"
|
||||||
USER_AGENT: Final = f"Home-Assistant/{CLIENT_VERSION}"
|
USER_AGENT: Final = f"Home-Assistant/{CLIENT_VERSION}"
|
||||||
|
|
||||||
|
PLATFORMS = [Platform.SENSOR]
|
||||||
|
LOGGER = logging.getLogger(__package__)
|
||||||
|
|
69
homeassistant/components/jellyfin/coordinator.py
Normal file
69
homeassistant/components/jellyfin/coordinator.py
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
"""Data update coordinator for the Jellyfin integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import abstractmethod
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any, TypeVar, Union
|
||||||
|
|
||||||
|
from jellyfin_apiclient_python import JellyfinClient
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
|
from .const import DOMAIN, LOGGER
|
||||||
|
|
||||||
|
JellyfinDataT = TypeVar(
|
||||||
|
"JellyfinDataT",
|
||||||
|
bound=Union[
|
||||||
|
dict[str, dict[str, Any]],
|
||||||
|
dict[str, Any],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[JellyfinDataT]):
|
||||||
|
"""Data update coordinator for the Jellyfin integration."""
|
||||||
|
|
||||||
|
config_entry: ConfigEntry
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
api_client: JellyfinClient,
|
||||||
|
system_info: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the coordinator."""
|
||||||
|
super().__init__(
|
||||||
|
hass=hass,
|
||||||
|
logger=LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=timedelta(seconds=30),
|
||||||
|
)
|
||||||
|
self.api_client: JellyfinClient = api_client
|
||||||
|
self.server_id: str = system_info["Id"]
|
||||||
|
self.server_name: str = system_info["Name"]
|
||||||
|
self.server_version: str | None = system_info.get("Version")
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> JellyfinDataT:
|
||||||
|
"""Get the latest data from Jellyfin."""
|
||||||
|
return await self._fetch_data()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def _fetch_data(self) -> JellyfinDataT:
|
||||||
|
"""Fetch the actual data."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class SessionsDataUpdateCoordinator(
|
||||||
|
JellyfinDataUpdateCoordinator[dict[str, dict[str, Any]]]
|
||||||
|
):
|
||||||
|
"""Sessions update coordinator for Jellyfin."""
|
||||||
|
|
||||||
|
async def _fetch_data(self) -> dict:
|
||||||
|
"""Fetch the data."""
|
||||||
|
sessions = await self.hass.async_add_executor_job(
|
||||||
|
self.api_client.jellyfin.sessions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {session["Id"]: session for session in sessions}
|
33
homeassistant/components/jellyfin/entity.py
Normal file
33
homeassistant/components/jellyfin/entity.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
"""Base Entity for Jellyfin."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DEFAULT_NAME, DOMAIN
|
||||||
|
from .coordinator import JellyfinDataT, JellyfinDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinEntity(CoordinatorEntity[JellyfinDataUpdateCoordinator[JellyfinDataT]]):
|
||||||
|
"""Defines a base Jellyfin entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: JellyfinDataUpdateCoordinator[JellyfinDataT],
|
||||||
|
description: EntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the Jellyfin entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.coordinator = coordinator
|
||||||
|
self.entity_description = description
|
||||||
|
self._attr_unique_id = f"{coordinator.server_id}-{description.key}"
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
|
identifiers={(DOMAIN, self.coordinator.server_id)},
|
||||||
|
manufacturer=DEFAULT_NAME,
|
||||||
|
name=self.coordinator.server_name,
|
||||||
|
sw_version=self.coordinator.server_version,
|
||||||
|
)
|
|
@ -1,7 +1,6 @@
|
||||||
"""The Media Source implementation for the Jellyfin integration."""
|
"""The Media Source implementation for the Jellyfin integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
@ -20,7 +19,6 @@ from homeassistant.core import HomeAssistant
|
||||||
from .const import (
|
from .const import (
|
||||||
COLLECTION_TYPE_MOVIES,
|
COLLECTION_TYPE_MOVIES,
|
||||||
COLLECTION_TYPE_MUSIC,
|
COLLECTION_TYPE_MUSIC,
|
||||||
DATA_CLIENT,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ITEM_KEY_COLLECTION_TYPE,
|
ITEM_KEY_COLLECTION_TYPE,
|
||||||
ITEM_KEY_ID,
|
ITEM_KEY_ID,
|
||||||
|
@ -41,19 +39,16 @@ from .const import (
|
||||||
MEDIA_TYPE_VIDEO,
|
MEDIA_TYPE_VIDEO,
|
||||||
SUPPORTED_COLLECTION_TYPES,
|
SUPPORTED_COLLECTION_TYPES,
|
||||||
)
|
)
|
||||||
|
from .models import JellyfinData
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
||||||
"""Set up Jellyfin media source."""
|
"""Set up Jellyfin media source."""
|
||||||
# Currently only a single Jellyfin server is supported
|
# Currently only a single Jellyfin server is supported
|
||||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||||
|
jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
data = hass.data[DOMAIN][entry.entry_id]
|
return JellyfinSource(hass, jellyfin_data.jellyfin_client)
|
||||||
client: JellyfinClient = data[DATA_CLIENT]
|
|
||||||
|
|
||||||
return JellyfinSource(hass, client)
|
|
||||||
|
|
||||||
|
|
||||||
class JellyfinSource(MediaSource):
|
class JellyfinSource(MediaSource):
|
||||||
|
|
16
homeassistant/components/jellyfin/models.py
Normal file
16
homeassistant/components/jellyfin/models.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
"""Models for the Jellyfin integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from jellyfin_apiclient_python import JellyfinClient
|
||||||
|
|
||||||
|
from .coordinator import JellyfinDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JellyfinData:
|
||||||
|
"""Data for the Jellyfin integration."""
|
||||||
|
|
||||||
|
jellyfin_client: JellyfinClient
|
||||||
|
coordinators: dict[str, JellyfinDataUpdateCoordinator]
|
74
homeassistant/components/jellyfin/sensor.py
Normal file
74
homeassistant/components/jellyfin/sensor.py
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
"""Support for Jellyfin sensors."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import StateType
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import JellyfinDataT
|
||||||
|
from .entity import JellyfinEntity
|
||||||
|
from .models import JellyfinData
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JellyfinSensorEntityDescriptionMixin:
|
||||||
|
"""Mixin for required keys."""
|
||||||
|
|
||||||
|
value_fn: Callable[[JellyfinDataT], StateType]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JellyfinSensorEntityDescription(
|
||||||
|
SensorEntityDescription, JellyfinSensorEntityDescriptionMixin
|
||||||
|
):
|
||||||
|
"""Describes Jellyfin sensor entity."""
|
||||||
|
|
||||||
|
|
||||||
|
def _count_now_playing(data: JellyfinDataT) -> int:
|
||||||
|
"""Count the number of now playing."""
|
||||||
|
session_ids = [
|
||||||
|
sid for (sid, session) in data.items() if "NowPlayingItem" in session
|
||||||
|
]
|
||||||
|
|
||||||
|
return len(session_ids)
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_TYPES: dict[str, JellyfinSensorEntityDescription] = {
|
||||||
|
"sessions": JellyfinSensorEntityDescription(
|
||||||
|
key="watching",
|
||||||
|
icon="mdi:television-play",
|
||||||
|
native_unit_of_measurement="Watching",
|
||||||
|
value_fn=_count_now_playing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Jellyfin sensor based on a config entry."""
|
||||||
|
jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
JellyfinSensor(jellyfin_data.coordinators[coordinator_type], description)
|
||||||
|
for coordinator_type, description in SENSOR_TYPES.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinSensor(JellyfinEntity, SensorEntity):
|
||||||
|
"""Defines a Jellyfin sensor entity."""
|
||||||
|
|
||||||
|
entity_description: JellyfinSensorEntityDescription
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> StateType:
|
||||||
|
"""Return the state of the sensor."""
|
||||||
|
return self.entity_description.value_fn(self.coordinator.data)
|
|
@ -12,6 +12,7 @@ import pytest
|
||||||
|
|
||||||
from homeassistant.components.jellyfin.const import DOMAIN
|
from homeassistant.components.jellyfin.const import DOMAIN
|
||||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from . import load_json_fixture
|
from . import load_json_fixture
|
||||||
from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME
|
from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME
|
||||||
|
@ -70,6 +71,7 @@ def mock_api() -> MagicMock:
|
||||||
"""Return a mocked API."""
|
"""Return a mocked API."""
|
||||||
jf_api = create_autospec(API)
|
jf_api = create_autospec(API)
|
||||||
jf_api.get_user_settings.return_value = load_json_fixture("get-user-settings.json")
|
jf_api.get_user_settings.return_value = load_json_fixture("get-user-settings.json")
|
||||||
|
jf_api.sessions.return_value = load_json_fixture("sessions.json")
|
||||||
|
|
||||||
return jf_api
|
return jf_api
|
||||||
|
|
||||||
|
@ -106,3 +108,16 @@ def mock_jellyfin(mock_client: MagicMock) -> Generator[None, MagicMock, None]:
|
||||||
jf.get_client.return_value = mock_client
|
jf.get_client.return_value = mock_client
|
||||||
|
|
||||||
yield jf
|
yield jf
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def init_integration(
|
||||||
|
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_jellyfin: MagicMock
|
||||||
|
) -> MockConfigEntry:
|
||||||
|
"""Set up the Jellyfin integration for testing."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
return mock_config_entry
|
||||||
|
|
1762
tests/components/jellyfin/fixtures/sessions.json
Normal file
1762
tests/components/jellyfin/fixtures/sessions.json
Normal file
File diff suppressed because it is too large
Load diff
51
tests/components/jellyfin/test_sensor.py
Normal file
51
tests/components/jellyfin/test_sensor.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
"""Tests for the Jellyfin sensor platform."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from homeassistant.components.jellyfin.const import DOMAIN
|
||||||
|
from homeassistant.components.sensor import ATTR_STATE_CLASS
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_DEVICE_CLASS,
|
||||||
|
ATTR_FRIENDLY_NAME,
|
||||||
|
ATTR_ICON,
|
||||||
|
ATTR_UNIT_OF_MEASUREMENT,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_watching(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
init_integration: MockConfigEntry,
|
||||||
|
mock_jellyfin: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test the Jellyfin watching sensor."""
|
||||||
|
device_registry = dr.async_get(hass)
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.jellyfin_server")
|
||||||
|
assert state
|
||||||
|
assert state.attributes.get(ATTR_DEVICE_CLASS) is None
|
||||||
|
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-SERVER"
|
||||||
|
assert state.attributes.get(ATTR_ICON) == "mdi:television-play"
|
||||||
|
assert state.attributes.get(ATTR_STATE_CLASS) is None
|
||||||
|
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Watching"
|
||||||
|
assert state.state == "1"
|
||||||
|
|
||||||
|
entry = entity_registry.async_get(state.entity_id)
|
||||||
|
assert entry
|
||||||
|
assert entry.device_id
|
||||||
|
assert entry.entity_category is None
|
||||||
|
assert entry.unique_id == "SERVER-UUID-watching"
|
||||||
|
|
||||||
|
device = device_registry.async_get(entry.device_id)
|
||||||
|
assert device
|
||||||
|
assert device.configuration_url is None
|
||||||
|
assert device.connections == set()
|
||||||
|
assert device.entry_type is dr.DeviceEntryType.SERVICE
|
||||||
|
assert device.hw_version is None
|
||||||
|
assert device.identifiers == {(DOMAIN, "SERVER-UUID")}
|
||||||
|
assert device.manufacturer == "Jellyfin"
|
||||||
|
assert device.name == "JELLYFIN-SERVER"
|
||||||
|
assert device.sw_version is None
|
Loading…
Add table
Add a link
Reference in a new issue