Add Lidarr integration (#66438)
This commit is contained in:
parent
3776fc3b9f
commit
2a2cc79fc3
16 changed files with 816 additions and 0 deletions
|
@ -667,6 +667,9 @@ omit =
|
||||||
homeassistant/components/led_ble/util.py
|
homeassistant/components/led_ble/util.py
|
||||||
homeassistant/components/lg_netcast/media_player.py
|
homeassistant/components/lg_netcast/media_player.py
|
||||||
homeassistant/components/lg_soundbar/media_player.py
|
homeassistant/components/lg_soundbar/media_player.py
|
||||||
|
homeassistant/components/lidarr/__init__.py
|
||||||
|
homeassistant/components/lidarr/coordinator.py
|
||||||
|
homeassistant/components/lidarr/sensor.py
|
||||||
homeassistant/components/life360/__init__.py
|
homeassistant/components/life360/__init__.py
|
||||||
homeassistant/components/life360/const.py
|
homeassistant/components/life360/const.py
|
||||||
homeassistant/components/life360/coordinator.py
|
homeassistant/components/life360/coordinator.py
|
||||||
|
|
|
@ -609,6 +609,8 @@ build.json @home-assistant/supervisor
|
||||||
/homeassistant/components/led_ble/ @bdraco
|
/homeassistant/components/led_ble/ @bdraco
|
||||||
/tests/components/led_ble/ @bdraco
|
/tests/components/led_ble/ @bdraco
|
||||||
/homeassistant/components/lg_netcast/ @Drafteed
|
/homeassistant/components/lg_netcast/ @Drafteed
|
||||||
|
/homeassistant/components/lidarr/ @tkdrob
|
||||||
|
/tests/components/lidarr/ @tkdrob
|
||||||
/homeassistant/components/life360/ @pnbruckner
|
/homeassistant/components/life360/ @pnbruckner
|
||||||
/tests/components/life360/ @pnbruckner
|
/tests/components/life360/ @pnbruckner
|
||||||
/homeassistant/components/lifx/ @bdraco @Djelibeybi
|
/homeassistant/components/lifx/ @bdraco @Djelibeybi
|
||||||
|
|
85
homeassistant/components/lidarr/__init__.py
Normal file
85
homeassistant/components/lidarr/__init__.py
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
"""The Lidarr component."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aiopyarr.lidarr_client import LidarrClient
|
||||||
|
from aiopyarr.models.host_configuration import PyArrHostConfiguration
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
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 (
|
||||||
|
DiskSpaceDataUpdateCoordinator,
|
||||||
|
LidarrDataUpdateCoordinator,
|
||||||
|
QueueDataUpdateCoordinator,
|
||||||
|
StatusDataUpdateCoordinator,
|
||||||
|
WantedDataUpdateCoordinator,
|
||||||
|
)
|
||||||
|
|
||||||
|
PLATFORMS = [Platform.SENSOR]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Lidarr from a config entry."""
|
||||||
|
host_configuration = PyArrHostConfiguration(
|
||||||
|
api_token=entry.data[CONF_API_KEY],
|
||||||
|
verify_ssl=entry.data[CONF_VERIFY_SSL],
|
||||||
|
url=entry.data[CONF_URL],
|
||||||
|
)
|
||||||
|
lidarr = LidarrClient(
|
||||||
|
host_configuration=host_configuration,
|
||||||
|
session=async_get_clientsession(hass, host_configuration.verify_ssl),
|
||||||
|
request_timeout=60,
|
||||||
|
)
|
||||||
|
coordinators: dict[str, LidarrDataUpdateCoordinator] = {
|
||||||
|
"disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, lidarr),
|
||||||
|
"queue": QueueDataUpdateCoordinator(hass, host_configuration, lidarr),
|
||||||
|
"status": StatusDataUpdateCoordinator(hass, host_configuration, lidarr),
|
||||||
|
"wanted": WantedDataUpdateCoordinator(hass, host_configuration, lidarr),
|
||||||
|
}
|
||||||
|
# Temporary, until we add diagnostic entities
|
||||||
|
_version = None
|
||||||
|
for coordinator in coordinators.values():
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
if isinstance(coordinator, StatusDataUpdateCoordinator):
|
||||||
|
_version = coordinator.data
|
||||||
|
coordinator.system_version = _version
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
class LidarrEntity(CoordinatorEntity[LidarrDataUpdateCoordinator]):
|
||||||
|
"""Defines a base Lidarr entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, coordinator: LidarrDataUpdateCoordinator, description: EntityDescription
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the Lidarr entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.entity_description = description
|
||||||
|
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
configuration_url=coordinator.host_configuration.base_url,
|
||||||
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
|
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||||
|
manufacturer=DEFAULT_NAME,
|
||||||
|
name=DEFAULT_NAME,
|
||||||
|
sw_version=coordinator.system_version,
|
||||||
|
)
|
111
homeassistant/components/lidarr/config_flow.py
Normal file
111
homeassistant/components/lidarr/config_flow.py
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
"""Config flow for Lidarr."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import ClientConnectorError
|
||||||
|
from aiopyarr import SystemStatus, exceptions
|
||||||
|
from aiopyarr.lidarr_client import LidarrClient
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||||
|
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from .const import DEFAULT_NAME, DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
class LidarrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Lidarr."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the flow."""
|
||||||
|
self.entry: ConfigEntry | None = None
|
||||||
|
|
||||||
|
async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult:
|
||||||
|
"""Handle configuration by re-auth."""
|
||||||
|
self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
||||||
|
|
||||||
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
|
async def async_step_reauth_confirm(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Confirm reauth dialog."""
|
||||||
|
if user_input is not None:
|
||||||
|
return await self.async_step_user()
|
||||||
|
|
||||||
|
self._set_confirm_only()
|
||||||
|
return self.async_show_form(step_id="reauth_confirm")
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle a flow initiated by the user."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is None:
|
||||||
|
user_input = dict(self.entry.data) if self.entry else None
|
||||||
|
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = await validate_input(self.hass, user_input)
|
||||||
|
if isinstance(result, tuple):
|
||||||
|
user_input[CONF_API_KEY] = result[1]
|
||||||
|
elif isinstance(result, str):
|
||||||
|
errors = {"base": result}
|
||||||
|
except exceptions.ArrAuthenticationException:
|
||||||
|
errors = {"base": "invalid_auth"}
|
||||||
|
except (ClientConnectorError, exceptions.ArrConnectionException):
|
||||||
|
errors = {"base": "cannot_connect"}
|
||||||
|
except exceptions.ArrException:
|
||||||
|
errors = {"base": "unknown"}
|
||||||
|
if not errors:
|
||||||
|
if self.entry:
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
self.entry, data=user_input
|
||||||
|
)
|
||||||
|
await self.hass.config_entries.async_reload(self.entry.entry_id)
|
||||||
|
|
||||||
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
|
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
|
||||||
|
|
||||||
|
user_input = user_input or {}
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str,
|
||||||
|
vol.Optional(CONF_API_KEY): str,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
|
default=user_input.get(CONF_VERIFY_SSL, False),
|
||||||
|
): bool,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_input(
|
||||||
|
hass: HomeAssistant, data: dict[str, Any]
|
||||||
|
) -> tuple[str, str, str] | str | SystemStatus:
|
||||||
|
"""Validate the user input allows us to connect.
|
||||||
|
|
||||||
|
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||||
|
"""
|
||||||
|
lidarr = LidarrClient(
|
||||||
|
api_token=data.get(CONF_API_KEY, ""),
|
||||||
|
url=data[CONF_URL],
|
||||||
|
session=async_get_clientsession(hass),
|
||||||
|
verify_ssl=data[CONF_VERIFY_SSL],
|
||||||
|
)
|
||||||
|
if CONF_API_KEY not in data:
|
||||||
|
return await lidarr.async_try_zeroconf()
|
||||||
|
return await lidarr.async_get_system_status()
|
38
homeassistant/components/lidarr/const.py
Normal file
38
homeassistant/components/lidarr/const.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
"""Constants for Lidarr."""
|
||||||
|
import logging
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from homeassistant.const import (
|
||||||
|
DATA_BYTES,
|
||||||
|
DATA_EXABYTES,
|
||||||
|
DATA_GIGABYTES,
|
||||||
|
DATA_KILOBYTES,
|
||||||
|
DATA_MEGABYTES,
|
||||||
|
DATA_PETABYTES,
|
||||||
|
DATA_TERABYTES,
|
||||||
|
DATA_YOTTABYTES,
|
||||||
|
DATA_ZETTABYTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
BYTE_SIZES = [
|
||||||
|
DATA_BYTES,
|
||||||
|
DATA_KILOBYTES,
|
||||||
|
DATA_MEGABYTES,
|
||||||
|
DATA_GIGABYTES,
|
||||||
|
DATA_TERABYTES,
|
||||||
|
DATA_PETABYTES,
|
||||||
|
DATA_EXABYTES,
|
||||||
|
DATA_ZETTABYTES,
|
||||||
|
DATA_YOTTABYTES,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
DEFAULT_DAYS = "1"
|
||||||
|
DEFAULT_HOST = "localhost"
|
||||||
|
DEFAULT_NAME = "Lidarr"
|
||||||
|
DEFAULT_UNIT = DATA_GIGABYTES
|
||||||
|
DEFAULT_MAX_RECORDS = 20
|
||||||
|
|
||||||
|
DOMAIN: Final = "lidarr"
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__package__)
|
94
homeassistant/components/lidarr/coordinator.py
Normal file
94
homeassistant/components/lidarr/coordinator.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
"""Data update coordinator for the Lidarr integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import abstractmethod
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Generic, TypeVar, cast
|
||||||
|
|
||||||
|
from aiopyarr import LidarrAlbum, LidarrQueue, LidarrRootFolder, exceptions
|
||||||
|
from aiopyarr.lidarr_client import LidarrClient
|
||||||
|
from aiopyarr.models.host_configuration import PyArrHostConfiguration
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER
|
||||||
|
|
||||||
|
T = TypeVar("T", list[LidarrRootFolder], LidarrQueue, str, LidarrAlbum)
|
||||||
|
|
||||||
|
|
||||||
|
class LidarrDataUpdateCoordinator(DataUpdateCoordinator, Generic[T]):
|
||||||
|
"""Data update coordinator for the Lidarr integration."""
|
||||||
|
|
||||||
|
config_entry: ConfigEntry
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
host_configuration: PyArrHostConfiguration,
|
||||||
|
api_client: LidarrClient,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the coordinator."""
|
||||||
|
super().__init__(
|
||||||
|
hass=hass,
|
||||||
|
logger=LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=timedelta(seconds=30),
|
||||||
|
)
|
||||||
|
self.api_client = api_client
|
||||||
|
self.host_configuration = host_configuration
|
||||||
|
self.system_version: str | None = None
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> T:
|
||||||
|
"""Get the latest data from Lidarr."""
|
||||||
|
try:
|
||||||
|
return await self._fetch_data()
|
||||||
|
|
||||||
|
except exceptions.ArrConnectionException as ex:
|
||||||
|
raise UpdateFailed(ex) from ex
|
||||||
|
except exceptions.ArrAuthenticationException as ex:
|
||||||
|
raise ConfigEntryAuthFailed(
|
||||||
|
"API Key is no longer valid. Please reauthenticate"
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def _fetch_data(self) -> T:
|
||||||
|
"""Fetch the actual data."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class DiskSpaceDataUpdateCoordinator(LidarrDataUpdateCoordinator):
|
||||||
|
"""Disk space update coordinator for Lidarr."""
|
||||||
|
|
||||||
|
async def _fetch_data(self) -> list[LidarrRootFolder]:
|
||||||
|
"""Fetch the data."""
|
||||||
|
return cast(list, await self.api_client.async_get_root_folders())
|
||||||
|
|
||||||
|
|
||||||
|
class QueueDataUpdateCoordinator(LidarrDataUpdateCoordinator):
|
||||||
|
"""Queue update coordinator."""
|
||||||
|
|
||||||
|
async def _fetch_data(self) -> LidarrQueue:
|
||||||
|
"""Fetch the album count in queue."""
|
||||||
|
return await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusDataUpdateCoordinator(LidarrDataUpdateCoordinator):
|
||||||
|
"""Status update coordinator for Lidarr."""
|
||||||
|
|
||||||
|
async def _fetch_data(self) -> str:
|
||||||
|
"""Fetch the data."""
|
||||||
|
return (await self.api_client.async_get_system_status()).version
|
||||||
|
|
||||||
|
|
||||||
|
class WantedDataUpdateCoordinator(LidarrDataUpdateCoordinator):
|
||||||
|
"""Wanted update coordinator."""
|
||||||
|
|
||||||
|
async def _fetch_data(self) -> LidarrAlbum:
|
||||||
|
"""Fetch the wanted data."""
|
||||||
|
return cast(
|
||||||
|
LidarrAlbum,
|
||||||
|
await self.api_client.async_get_wanted(page_size=DEFAULT_MAX_RECORDS),
|
||||||
|
)
|
10
homeassistant/components/lidarr/manifest.json
Normal file
10
homeassistant/components/lidarr/manifest.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"domain": "lidarr",
|
||||||
|
"name": "Lidarr",
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/lidarr",
|
||||||
|
"requirements": ["aiopyarr==22.7.0"],
|
||||||
|
"codeowners": ["@tkdrob"],
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"loggers": ["aiopyarr"]
|
||||||
|
}
|
162
homeassistant/components/lidarr/sensor.py
Normal file
162
homeassistant/components/lidarr/sensor.py
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
"""Support for Lidarr."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generic
|
||||||
|
|
||||||
|
from aiopyarr import LidarrQueueItem, LidarrRootFolder
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import DATA_GIGABYTES
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import StateType
|
||||||
|
|
||||||
|
from . import LidarrEntity
|
||||||
|
from .const import BYTE_SIZES, DOMAIN
|
||||||
|
from .coordinator import LidarrDataUpdateCoordinator, T
|
||||||
|
|
||||||
|
|
||||||
|
def get_space(data: list[LidarrRootFolder], name: str) -> str:
|
||||||
|
"""Get space."""
|
||||||
|
space = []
|
||||||
|
for mount in data:
|
||||||
|
if name in mount.path:
|
||||||
|
mount.freeSpace = mount.freeSpace if mount.accessible else 0
|
||||||
|
space.append(mount.freeSpace / 1024 ** BYTE_SIZES.index(DATA_GIGABYTES))
|
||||||
|
return f"{space[0]:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_modified_description(
|
||||||
|
description: LidarrSensorEntityDescription, mount: LidarrRootFolder
|
||||||
|
) -> tuple[LidarrSensorEntityDescription, str]:
|
||||||
|
"""Return modified description and folder name."""
|
||||||
|
desc = deepcopy(description)
|
||||||
|
name = mount.path.rsplit("/")[-1].rsplit("\\")[-1]
|
||||||
|
desc.key = f"{description.key}_{name}"
|
||||||
|
desc.name = f"{description.name} {name}".capitalize()
|
||||||
|
return desc, name
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LidarrSensorEntityDescriptionMixIn(Generic[T]):
|
||||||
|
"""Mixin for required keys."""
|
||||||
|
|
||||||
|
value_fn: Callable[[T, str], str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LidarrSensorEntityDescription(
|
||||||
|
SensorEntityDescription, LidarrSensorEntityDescriptionMixIn, Generic[T]
|
||||||
|
):
|
||||||
|
"""Class to describe a Lidarr sensor."""
|
||||||
|
|
||||||
|
attributes_fn: Callable[
|
||||||
|
[T], dict[str, StateType | datetime] | None
|
||||||
|
] = lambda _: None
|
||||||
|
description_fn: Callable[
|
||||||
|
[LidarrSensorEntityDescription, LidarrRootFolder],
|
||||||
|
tuple[LidarrSensorEntityDescription, str] | None,
|
||||||
|
] = lambda _, __: None
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_TYPES: dict[str, LidarrSensorEntityDescription] = {
|
||||||
|
"disk_space": LidarrSensorEntityDescription(
|
||||||
|
key="disk_space",
|
||||||
|
name="Disk space",
|
||||||
|
native_unit_of_measurement=DATA_GIGABYTES,
|
||||||
|
icon="mdi:harddisk",
|
||||||
|
value_fn=get_space,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
description_fn=get_modified_description,
|
||||||
|
),
|
||||||
|
"queue": LidarrSensorEntityDescription(
|
||||||
|
key="queue",
|
||||||
|
name="Queue",
|
||||||
|
native_unit_of_measurement="Albums",
|
||||||
|
icon="mdi:download",
|
||||||
|
value_fn=lambda data, _: data.totalRecords,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
attributes_fn=lambda data: {i.title: queue_str(i) for i in data.records},
|
||||||
|
),
|
||||||
|
"wanted": LidarrSensorEntityDescription(
|
||||||
|
key="wanted",
|
||||||
|
name="Wanted",
|
||||||
|
native_unit_of_measurement="Albums",
|
||||||
|
icon="mdi:music",
|
||||||
|
value_fn=lambda data, _: data.totalRecords,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
attributes_fn=lambda data: {
|
||||||
|
album.title: album.artist.artistName for album in data.records
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Lidarr sensors based on a config entry."""
|
||||||
|
coordinators: dict[str, LidarrDataUpdateCoordinator] = hass.data[DOMAIN][
|
||||||
|
entry.entry_id
|
||||||
|
]
|
||||||
|
entities = []
|
||||||
|
for coordinator_type, description in SENSOR_TYPES.items():
|
||||||
|
coordinator = coordinators[coordinator_type]
|
||||||
|
if coordinator_type != "disk_space":
|
||||||
|
entities.append(LidarrSensor(coordinator, description))
|
||||||
|
else:
|
||||||
|
entities.extend(
|
||||||
|
LidarrSensor(coordinator, *get_modified_description(description, mount))
|
||||||
|
for mount in coordinator.data
|
||||||
|
if description.description_fn
|
||||||
|
)
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class LidarrSensor(LidarrEntity, SensorEntity):
|
||||||
|
"""Implementation of the Lidarr sensor."""
|
||||||
|
|
||||||
|
entity_description: LidarrSensorEntityDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: LidarrDataUpdateCoordinator,
|
||||||
|
description: LidarrSensorEntityDescription,
|
||||||
|
folder_name: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Create Lidarr entity."""
|
||||||
|
super().__init__(coordinator, description)
|
||||||
|
self.folder_name = folder_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict[str, StateType | datetime] | None:
|
||||||
|
"""Return the state attributes of the sensor."""
|
||||||
|
return self.entity_description.attributes_fn(self.coordinator.data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> StateType:
|
||||||
|
"""Return the state of the sensor."""
|
||||||
|
return self.entity_description.value_fn(self.coordinator.data, self.folder_name)
|
||||||
|
|
||||||
|
|
||||||
|
def queue_str(item: LidarrQueueItem) -> str:
|
||||||
|
"""Return string description of queue item."""
|
||||||
|
if (
|
||||||
|
item.sizeleft > 0
|
||||||
|
and item.timeleft == "00:00:00"
|
||||||
|
or not hasattr(item, "trackedDownloadState")
|
||||||
|
):
|
||||||
|
return "stopped"
|
||||||
|
return item.trackedDownloadState
|
42
homeassistant/components/lidarr/strings.json
Normal file
42
homeassistant/components/lidarr/strings.json
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "API key can be retrieved automatically if login credentials were not set in application.\nYour API key can be found in Settings > General in the Lidarr Web UI.",
|
||||||
|
"data": {
|
||||||
|
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||||
|
"url": "[%key:common::config_flow::data::url%]",
|
||||||
|
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"title": "[%key:common::config_flow::title::reauth%]",
|
||||||
|
"description": "The Lidarr integration needs to be manually re-authenticated with the Lidarr API",
|
||||||
|
"data": {
|
||||||
|
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"zeroconf_failed": "API key not found. Please enter it manually",
|
||||||
|
"wrong_app": "Incorrect application reached. Please try again",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||||
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"upcoming_days": "Number of upcoming days to display on calendar",
|
||||||
|
"max_records": "Number of maximum records to display on wanted and queue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
homeassistant/components/lidarr/translations/en.json
Normal file
42
homeassistant/components/lidarr/translations/en.json
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Service is already configured",
|
||||||
|
"reauth_successful": "Re-authentication was successful"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect",
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
|
"zeroconf_failed": "API key not found. Please enter it manually",
|
||||||
|
"wrong_app": "Incorrect application reached. Please try again",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"reauth_confirm": {
|
||||||
|
"description": "The Lidarr integration needs to be manually re-authenticated with the Lidarr API",
|
||||||
|
"title": "Reauthenticate Integration",
|
||||||
|
"data": {
|
||||||
|
"api_key": "API Key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"description": "API key can be retrieved automatically if login credentials were not set in application.\nYour API key can be found in Settings > General in the Lidarr Web UI.",
|
||||||
|
"data": {
|
||||||
|
"api_key": "API Key",
|
||||||
|
"url": "URL",
|
||||||
|
"verify_ssl": "Verify SSL certificate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"upcoming_days": "Number of upcoming days to display on calendar",
|
||||||
|
"max_records": "Number of maximum records to display on wanted and queue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -204,6 +204,7 @@ FLOWS = {
|
||||||
"laundrify",
|
"laundrify",
|
||||||
"led_ble",
|
"led_ble",
|
||||||
"lg_soundbar",
|
"lg_soundbar",
|
||||||
|
"lidarr",
|
||||||
"life360",
|
"life360",
|
||||||
"lifx",
|
"lifx",
|
||||||
"litejet",
|
"litejet",
|
||||||
|
|
|
@ -231,6 +231,7 @@ aiopvapi==2.0.1
|
||||||
# homeassistant.components.pvpc_hourly_pricing
|
# homeassistant.components.pvpc_hourly_pricing
|
||||||
aiopvpc==3.0.0
|
aiopvpc==3.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.lidarr
|
||||||
# homeassistant.components.sonarr
|
# homeassistant.components.sonarr
|
||||||
aiopyarr==22.7.0
|
aiopyarr==22.7.0
|
||||||
|
|
||||||
|
|
|
@ -206,6 +206,7 @@ aiopvapi==2.0.1
|
||||||
# homeassistant.components.pvpc_hourly_pricing
|
# homeassistant.components.pvpc_hourly_pricing
|
||||||
aiopvpc==3.0.0
|
aiopvpc==3.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.lidarr
|
||||||
# homeassistant.components.sonarr
|
# homeassistant.components.sonarr
|
||||||
aiopyarr==22.7.0
|
aiopyarr==22.7.0
|
||||||
|
|
||||||
|
|
53
tests/components/lidarr/__init__.py
Normal file
53
tests/components/lidarr/__init__.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
"""Tests for the Lidarr component."""
|
||||||
|
from aiopyarr.lidarr_client import LidarrClient
|
||||||
|
|
||||||
|
from homeassistant.components.lidarr.const import DOMAIN
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_API_KEY,
|
||||||
|
CONF_URL,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
|
CONTENT_TYPE_JSON,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, load_fixture
|
||||||
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
|
|
||||||
|
BASE_PATH = ""
|
||||||
|
API_KEY = "1234567890abcdef1234567890abcdef"
|
||||||
|
URL = "http://127.0.0.1:8686"
|
||||||
|
client = LidarrClient(session=async_get_clientsession, api_token=API_KEY, url=URL)
|
||||||
|
API_URL = f"{URL}/api/{client._host.api_ver}"
|
||||||
|
|
||||||
|
MOCK_REAUTH_INPUT = {CONF_API_KEY: "new_key"}
|
||||||
|
|
||||||
|
MOCK_USER_INPUT = {
|
||||||
|
CONF_URL: URL,
|
||||||
|
CONF_VERIFY_SSL: False,
|
||||||
|
}
|
||||||
|
|
||||||
|
CONF_DATA = MOCK_USER_INPUT | {CONF_API_KEY: API_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
def mock_connection(
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
url: str = API_URL,
|
||||||
|
) -> None:
|
||||||
|
"""Mock lidarr connection."""
|
||||||
|
aioclient_mock.get(
|
||||||
|
f"{url}/system/status",
|
||||||
|
text=load_fixture("lidarr/system-status.json"),
|
||||||
|
headers={"Content-Type": CONTENT_TYPE_JSON},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||||
|
"""Create Efergy entry in Home Assistant."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=CONF_DATA,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
return entry
|
29
tests/components/lidarr/fixtures/system-status.json
Normal file
29
tests/components/lidarr/fixtures/system-status.json
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"version": "10.0.0.34882",
|
||||||
|
"buildTime": "2020-09-01T23:23:23.9621974Z",
|
||||||
|
"isDebug": true,
|
||||||
|
"isProduction": false,
|
||||||
|
"isAdmin": false,
|
||||||
|
"isUserInteractive": true,
|
||||||
|
"startupPath": "C:\\ProgramData\\Radarr",
|
||||||
|
"appData": "C:\\ProgramData\\Radarr",
|
||||||
|
"osName": "Windows",
|
||||||
|
"osVersion": "10.0.18363.0",
|
||||||
|
"isNetCore": true,
|
||||||
|
"isMono": false,
|
||||||
|
"isMonoRuntime": false,
|
||||||
|
"isLinux": false,
|
||||||
|
"isOsx": false,
|
||||||
|
"isWindows": true,
|
||||||
|
"isDocker": false,
|
||||||
|
"mode": "console",
|
||||||
|
"branch": "nightly",
|
||||||
|
"authentication": "none",
|
||||||
|
"sqliteVersion": "3.32.1",
|
||||||
|
"migrationVersion": 180,
|
||||||
|
"urlBase": "",
|
||||||
|
"runtimeVersion": "3.1.10",
|
||||||
|
"runtimeName": "netCore",
|
||||||
|
"startTime": "2020-09-01T23:50:20.2415965Z",
|
||||||
|
"packageUpdateMechanism": "builtIn"
|
||||||
|
}
|
142
tests/components/lidarr/test_config_flow.py
Normal file
142
tests/components/lidarr/test_config_flow.py
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
"""Test Lidarr config flow."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from aiopyarr import ArrAuthenticationException, ArrConnectionException, ArrException
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
|
from homeassistant.components.lidarr.const import DEFAULT_NAME, DOMAIN
|
||||||
|
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
|
||||||
|
from homeassistant.const import CONF_API_KEY, CONF_SOURCE
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from . import API_KEY, CONF_DATA, MOCK_USER_INPUT, create_entry, mock_connection
|
||||||
|
|
||||||
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_client():
|
||||||
|
return patch(
|
||||||
|
"homeassistant.components.lidarr.config_flow.LidarrClient.async_get_system_status"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_user_form(
|
||||||
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
|
"""Test that the user set up form is served."""
|
||||||
|
mock_connection(aioclient_mock)
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={CONF_SOURCE: SOURCE_USER},
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf",
|
||||||
|
return_value=("/api/v3", API_KEY, ""),
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input=MOCK_USER_INPUT,
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["title"] == DEFAULT_NAME
|
||||||
|
assert result["data"] == CONF_DATA
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None:
|
||||||
|
"""Test invalid authentication."""
|
||||||
|
with _patch_client() as client:
|
||||||
|
client.side_effect = ArrAuthenticationException
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={CONF_SOURCE: SOURCE_USER},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input=CONF_DATA,
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"]["base"] == "invalid_auth"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None:
|
||||||
|
"""Test connection error."""
|
||||||
|
with _patch_client() as client:
|
||||||
|
client.side_effect = ArrConnectionException
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={CONF_SOURCE: SOURCE_USER},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input=CONF_DATA,
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"]["base"] == "cannot_connect"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_user_unknown_error(hass: HomeAssistant) -> None:
|
||||||
|
"""Test unknown error."""
|
||||||
|
with _patch_client() as client:
|
||||||
|
client.side_effect = ArrException
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={CONF_SOURCE: SOURCE_USER},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input=CONF_DATA,
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"]["base"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_user_failed_zeroconf(hass: HomeAssistant) -> None:
|
||||||
|
"""Test zero configuration failed."""
|
||||||
|
with _patch_client() as client:
|
||||||
|
client.return_value = "zeroconf_failed"
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={CONF_SOURCE: SOURCE_USER},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input=CONF_DATA,
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"]["base"] == "zeroconf_failed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_reauth(
|
||||||
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
|
"""Test reauth."""
|
||||||
|
entry = create_entry(hass)
|
||||||
|
mock_connection(aioclient_mock)
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={
|
||||||
|
CONF_SOURCE: SOURCE_REAUTH,
|
||||||
|
"entry_id": entry.entry_id,
|
||||||
|
"unique_id": entry.unique_id,
|
||||||
|
},
|
||||||
|
data=CONF_DATA,
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "reauth_confirm"
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={},
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_API_KEY: "abc123"},
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "reauth_successful"
|
||||||
|
assert entry.data[CONF_API_KEY] == "abc123"
|
Loading…
Add table
Reference in a new issue