Add config flow to tautulli integration (#57450)
parent
8a2b20faf0
commit
09a7116efc
|
@ -1202,7 +1202,7 @@ omit =
|
|||
homeassistant/components/tankerkoenig/const.py
|
||||
homeassistant/components/tankerkoenig/sensor.py
|
||||
homeassistant/components/tapsaff/binary_sensor.py
|
||||
homeassistant/components/tautulli/const.py
|
||||
homeassistant/components/tautulli/__init__.py
|
||||
homeassistant/components/tautulli/coordinator.py
|
||||
homeassistant/components/tautulli/sensor.py
|
||||
homeassistant/components/ted5000/sensor.py
|
||||
|
|
|
@ -1013,7 +1013,8 @@ build.json @home-assistant/supervisor
|
|||
/homeassistant/components/tapsaff/ @bazwilliams
|
||||
/homeassistant/components/tasmota/ @emontnemery
|
||||
/tests/components/tasmota/ @emontnemery
|
||||
/homeassistant/components/tautulli/ @ludeeus
|
||||
/homeassistant/components/tautulli/ @ludeeus @tkdrob
|
||||
/tests/components/tautulli/ @ludeeus @tkdrob
|
||||
/homeassistant/components/tellduslive/ @fredrike
|
||||
/tests/components/tellduslive/ @fredrike
|
||||
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
|
||||
|
|
|
@ -1 +1,63 @@
|
|||
"""The tautulli component."""
|
||||
"""The Tautulli integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pytautulli import PyTautulli, PyTautulliHostConfiguration
|
||||
|
||||
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 TautulliDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Tautulli from a config entry."""
|
||||
host_configuration = PyTautulliHostConfiguration(
|
||||
api_token=entry.data[CONF_API_KEY],
|
||||
url=entry.data[CONF_URL],
|
||||
verify_ssl=entry.data[CONF_VERIFY_SSL],
|
||||
)
|
||||
api_client = PyTautulli(
|
||||
host_configuration=host_configuration,
|
||||
session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]),
|
||||
)
|
||||
coordinator = TautulliDataUpdateCoordinator(hass, host_configuration, api_client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data[DOMAIN] = coordinator
|
||||
hass.config_entries.async_setup_platforms(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.pop(DOMAIN)
|
||||
return unload_ok
|
||||
|
||||
|
||||
class TautulliEntity(CoordinatorEntity[TautulliDataUpdateCoordinator]):
|
||||
"""Defines a base Tautulli entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: TautulliDataUpdateCoordinator,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Tautulli 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,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
"""Config flow for Tautulli."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pytautulli import (
|
||||
PyTautulli,
|
||||
PyTautulliException,
|
||||
PyTautulliHostConfiguration,
|
||||
exceptions,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import _LOGGER
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
CONF_PATH,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_URL,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_PATH,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
class TautulliConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Tautulli."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
if (error := await self.validate_input(user_input)) is None:
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_NAME,
|
||||
data=user_input,
|
||||
)
|
||||
errors["base"] = error
|
||||
|
||||
user_input = user_input or {}
|
||||
data_schema = {
|
||||
vol.Required(CONF_API_KEY, default=user_input.get(CONF_API_KEY, "")): str,
|
||||
vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str,
|
||||
vol.Optional(
|
||||
CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True)
|
||||
): bool,
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(data_schema),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult:
|
||||
"""Handle a reauthorization flow request."""
|
||||
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."""
|
||||
errors = {}
|
||||
if user_input is not None and (
|
||||
entry := self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
||||
):
|
||||
_input = {**entry.data, CONF_API_KEY: user_input[CONF_API_KEY]}
|
||||
if (error := await self.validate_input(_input)) is None:
|
||||
self.hass.config_entries.async_update_entry(entry, data=_input)
|
||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
errors["base"] = error
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, config: dict[str, Any]) -> FlowResult:
|
||||
"""Import a config entry from configuration.yaml."""
|
||||
_LOGGER.warning(
|
||||
"Configuration of the Tautulli platform in YAML is deprecated and will be "
|
||||
"removed in Home Assistant 2022.6; Your existing configuration for host %s"
|
||||
"has been imported into the UI automatically and can be safely removed "
|
||||
"from your configuration.yaml file",
|
||||
config[CONF_HOST],
|
||||
)
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
host_configuration = PyTautulliHostConfiguration(
|
||||
config[CONF_API_KEY],
|
||||
ipaddress=config[CONF_HOST],
|
||||
port=config.get(CONF_PORT, DEFAULT_PORT),
|
||||
ssl=config.get(CONF_SSL, DEFAULT_SSL),
|
||||
verify_ssl=config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
|
||||
base_api_path=config.get(CONF_PATH, DEFAULT_PATH),
|
||||
)
|
||||
return await self.async_step_user(
|
||||
{
|
||||
CONF_API_KEY: host_configuration.api_token,
|
||||
CONF_URL: host_configuration.base_url,
|
||||
CONF_VERIFY_SSL: host_configuration.verify_ssl,
|
||||
}
|
||||
)
|
||||
|
||||
async def validate_input(self, user_input: dict[str, Any]) -> str | None:
|
||||
"""Try connecting to Tautulli."""
|
||||
try:
|
||||
api_client = PyTautulli(
|
||||
api_token=user_input[CONF_API_KEY],
|
||||
url=user_input[CONF_URL],
|
||||
session=async_get_clientsession(
|
||||
self.hass, user_input.get(CONF_VERIFY_SSL, True)
|
||||
),
|
||||
verify_ssl=user_input.get(CONF_VERIFY_SSL, True),
|
||||
)
|
||||
await api_client.async_get_server_info()
|
||||
except exceptions.PyTautulliConnectionException:
|
||||
return "cannot_connect"
|
||||
except exceptions.PyTautulliAuthenticationException:
|
||||
return "invalid_auth"
|
||||
except PyTautulliException:
|
||||
return "unknown"
|
||||
return None
|
|
@ -1,5 +1,11 @@
|
|||
"""Constants for the Tautulli integration."""
|
||||
from logging import Logger, getLogger
|
||||
|
||||
CONF_MONITORED_USERS = "monitored_users"
|
||||
DEFAULT_NAME = "Tautulli"
|
||||
DEFAULT_PATH = ""
|
||||
DEFAULT_PORT = "8181"
|
||||
DEFAULT_SSL = False
|
||||
DEFAULT_VERIFY_SSL = True
|
||||
DOMAIN = "tautulli"
|
||||
LOGGER: Logger = getLogger(__package__)
|
||||
|
|
|
@ -9,10 +9,16 @@ from pytautulli import (
|
|||
PyTautulliApiActivity,
|
||||
PyTautulliApiHomeStats,
|
||||
PyTautulliApiUser,
|
||||
PyTautulliException,
|
||||
)
|
||||
from pytautulli.exceptions import (
|
||||
PyTautulliAuthenticationException,
|
||||
PyTautulliConnectionException,
|
||||
)
|
||||
from pytautulli.models.host_configuration import PyTautulliHostConfiguration
|
||||
|
||||
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 DOMAIN, LOGGER
|
||||
|
@ -21,9 +27,12 @@ from .const import DOMAIN, LOGGER
|
|||
class TautulliDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Data update coordinator for the Tautulli integration."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
host_configuration: PyTautulliHostConfiguration,
|
||||
api_client: PyTautulli,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
|
@ -33,6 +42,7 @@ class TautulliDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=10),
|
||||
)
|
||||
self.host_configuration = host_configuration
|
||||
self.api_client = api_client
|
||||
self.activity: PyTautulliApiActivity | None = None
|
||||
self.home_stats: list[PyTautulliApiHomeStats] | None = None
|
||||
|
@ -48,5 +58,7 @@ class TautulliDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
self.api_client.async_get_users(),
|
||||
]
|
||||
)
|
||||
except PyTautulliException as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
except PyTautulliConnectionException as ex:
|
||||
raise UpdateFailed(ex) from ex
|
||||
except PyTautulliAuthenticationException as ex:
|
||||
raise ConfigEntryAuthFailed(ex) from ex
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
"name": "Tautulli",
|
||||
"documentation": "https://www.home-assistant.io/integrations/tautulli",
|
||||
"requirements": ["pytautulli==21.11.0"],
|
||||
"codeowners": ["@ludeeus"],
|
||||
"config_flow": true,
|
||||
"codeowners": ["@ludeeus", "@tkdrob"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pytautulli"]
|
||||
}
|
||||
|
|
|
@ -3,10 +3,14 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any
|
||||
|
||||
from pytautulli import PyTautulli
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
|
||||
from homeassistant.components.sensor import (
|
||||
PLATFORM_SCHEMA,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
|
@ -18,22 +22,23 @@ from homeassistant.const import (
|
|||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import TautulliEntity
|
||||
from .const import (
|
||||
CONF_MONITORED_USERS,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_PATH,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import TautulliDataUpdateCoordinator
|
||||
|
||||
CONF_MONITORED_USERS = "monitored_users"
|
||||
|
||||
DEFAULT_NAME = "Tautulli"
|
||||
DEFAULT_PORT = "8181"
|
||||
DEFAULT_PATH = ""
|
||||
DEFAULT_SSL = False
|
||||
DEFAULT_VERIFY_SSL = True
|
||||
|
||||
# Deprecated in Home Assistant 2022.4
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): cv.string,
|
||||
|
@ -48,6 +53,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|||
}
|
||||
)
|
||||
|
||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
icon="mdi:plex",
|
||||
key="watching_count",
|
||||
name="Tautulli",
|
||||
native_unit_of_measurement="Watching",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
|
@ -56,65 +70,30 @@ async def async_setup_platform(
|
|||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Create the Tautulli sensor."""
|
||||
|
||||
name = config[CONF_NAME]
|
||||
host = config[CONF_HOST]
|
||||
port = config[CONF_PORT]
|
||||
path = config[CONF_PATH]
|
||||
api_key = config[CONF_API_KEY]
|
||||
monitored_conditions = config.get(CONF_MONITORED_CONDITIONS, [])
|
||||
users = config.get(CONF_MONITORED_USERS, [])
|
||||
use_ssl = config[CONF_SSL]
|
||||
verify_ssl = config[CONF_VERIFY_SSL]
|
||||
|
||||
session = async_get_clientsession(hass=hass, verify_ssl=verify_ssl)
|
||||
|
||||
api_client = PyTautulli(
|
||||
api_token=api_key,
|
||||
hostname=host,
|
||||
session=session,
|
||||
verify_ssl=verify_ssl,
|
||||
port=port,
|
||||
ssl=use_ssl,
|
||||
base_api_path=path,
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||
)
|
||||
)
|
||||
|
||||
coordinator = TautulliDataUpdateCoordinator(hass=hass, api_client=api_client)
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up Tautulli sensor."""
|
||||
coordinator: TautulliDataUpdateCoordinator = hass.data[DOMAIN]
|
||||
async_add_entities(
|
||||
new_entities=[
|
||||
TautulliSensor(
|
||||
coordinator=coordinator,
|
||||
name=name,
|
||||
monitored_conditions=monitored_conditions,
|
||||
usernames=users,
|
||||
)
|
||||
],
|
||||
update_before_add=True,
|
||||
TautulliSensor(
|
||||
coordinator,
|
||||
description,
|
||||
)
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
|
||||
class TautulliSensor(CoordinatorEntity[TautulliDataUpdateCoordinator], SensorEntity):
|
||||
class TautulliSensor(TautulliEntity, SensorEntity):
|
||||
"""Representation of a Tautulli sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: TautulliDataUpdateCoordinator,
|
||||
name: str,
|
||||
monitored_conditions: list[str],
|
||||
usernames: list[str],
|
||||
) -> None:
|
||||
"""Initialize the Tautulli sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.monitored_conditions = monitored_conditions
|
||||
self.usernames = usernames
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
|
@ -122,16 +101,6 @@ class TautulliSensor(CoordinatorEntity[TautulliDataUpdateCoordinator], SensorEnt
|
|||
return 0
|
||||
return self.coordinator.activity.stream_count or 0
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return the icon of the sensor."""
|
||||
return "mdi:plex"
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str:
|
||||
"""Return the unit this state is expressed in."""
|
||||
return "Watching"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return attributes for the sensor."""
|
||||
|
@ -161,20 +130,14 @@ class TautulliSensor(CoordinatorEntity[TautulliDataUpdateCoordinator], SensorEnt
|
|||
_attributes["Top User"] = stat.rows[0].user if stat.rows else None
|
||||
|
||||
for user in self.coordinator.users:
|
||||
if (
|
||||
self.usernames
|
||||
and user.username not in self.usernames
|
||||
or user.username == "Local"
|
||||
):
|
||||
if user.username == "Local":
|
||||
continue
|
||||
_attributes.setdefault(user.username, {})["Activity"] = None
|
||||
|
||||
for session in self.coordinator.activity.sessions:
|
||||
if not _attributes.get(session.username):
|
||||
if not _attributes.get(session.username) or "null" in session.state:
|
||||
continue
|
||||
|
||||
_attributes[session.username]["Activity"] = session.state
|
||||
for key in self.monitored_conditions:
|
||||
_attributes[session.username][key] = getattr(session, key)
|
||||
|
||||
return _attributes
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "To find your API key, open the Tautulli webpage and navigate to Settings and then to Web interface. The API key will be at the bottom of that page.\n\nExample of the URL: ```http://192.168.0.10:8181``` with 8181 being the default port.",
|
||||
"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": "Re-authenticate Tautulli",
|
||||
"description": "To find your API key, open the Tautulli webpage and navigate to Settings and then to Web interface. The API key will be at the bottom of that page.",
|
||||
"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%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "To find your API key, open the Tautulli webpage and navigate to Settings and then to Web interface. The API key will be at the bottom of that page.\n\nExample of the URL: ```http://192.168.0.10:8181``` with 8181 being the default port.",
|
||||
"data": {
|
||||
"api_key": "Api Key",
|
||||
"url": "URL",
|
||||
"verify_ssl": "Verify SSL certificate"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-authenticate Tautulli",
|
||||
"description": "To find your API key, open the Tautulli webpage and navigate to Settings and then to Web interface. The API key will be at the bottom of that page.",
|
||||
"data": {
|
||||
"api_key": "Api Key"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Already configured. Only a single configuration possible.",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -342,6 +342,7 @@ FLOWS = {
|
|||
"tailscale",
|
||||
"tankerkoenig",
|
||||
"tasmota",
|
||||
"tautulli",
|
||||
"tellduslive",
|
||||
"tesla_wall_connector",
|
||||
"tibber",
|
||||
|
|
|
@ -1232,6 +1232,9 @@ pysyncthru==0.7.10
|
|||
# homeassistant.components.tankerkoenig
|
||||
pytankerkoenig==0.0.6
|
||||
|
||||
# homeassistant.components.tautulli
|
||||
pytautulli==21.11.0
|
||||
|
||||
# homeassistant.components.ecobee
|
||||
python-ecobee-api==0.2.14
|
||||
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
"""Tests for the Tautulli integration."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from homeassistant.components.tautulli.const import CONF_MONITORED_USERS, DOMAIN
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
CONF_MONITORED_CONDITIONS,
|
||||
CONF_PATH,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_URL,
|
||||
CONF_VERIFY_SSL,
|
||||
CONTENT_TYPE_JSON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
API_KEY = "abcd"
|
||||
URL = "http://1.2.3.4:8181/test"
|
||||
NAME = "Tautulli"
|
||||
SSL = False
|
||||
VERIFY_SSL = True
|
||||
|
||||
CONF_DATA = {
|
||||
CONF_API_KEY: API_KEY,
|
||||
CONF_URL: URL,
|
||||
CONF_VERIFY_SSL: VERIFY_SSL,
|
||||
}
|
||||
CONF_IMPORT_DATA = {
|
||||
CONF_API_KEY: API_KEY,
|
||||
CONF_HOST: "1.2.3.4",
|
||||
CONF_MONITORED_CONDITIONS: ["Stream count"],
|
||||
CONF_MONITORED_USERS: ["test"],
|
||||
CONF_PORT: "8181",
|
||||
CONF_PATH: "/test",
|
||||
CONF_SSL: SSL,
|
||||
CONF_VERIFY_SSL: VERIFY_SSL,
|
||||
}
|
||||
|
||||
DEFAULT_USERS = [{11111111: {"enabled": False}, 22222222: {"enabled": False}}]
|
||||
SELECTED_USERNAMES = ["user1"]
|
||||
|
||||
|
||||
def patch_config_flow_tautulli(mocked_tautulli) -> AsyncMock:
|
||||
"""Mock Tautulli config flow."""
|
||||
return patch(
|
||||
"homeassistant.components.tautulli.config_flow.PyTautulli.async_get_server_info",
|
||||
return_value=mocked_tautulli,
|
||||
)
|
||||
|
||||
|
||||
def mock_connection(
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
url: str = URL,
|
||||
invalid_auth: bool = False,
|
||||
) -> None:
|
||||
"""Mock Tautulli connection."""
|
||||
url = f"http://{url}/api/v2?apikey={API_KEY}"
|
||||
|
||||
if invalid_auth:
|
||||
aioclient_mock.get(
|
||||
f"{url}&cmd=get_activity",
|
||||
text=load_fixture("tautulli/get_activity.json"),
|
||||
headers={"Content-Type": CONTENT_TYPE_JSON},
|
||||
)
|
||||
return
|
||||
|
||||
aioclient_mock.get(
|
||||
f"{url}&cmd=get_activity",
|
||||
text=load_fixture("tautulli/get_activity.json"),
|
||||
headers={"Content-Type": CONTENT_TYPE_JSON},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{url}&cmd=get_home_stats",
|
||||
text=load_fixture("tautulli/get_home_stats.json"),
|
||||
headers={"Content-Type": CONTENT_TYPE_JSON},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{url}&cmd=get_users",
|
||||
text=load_fixture("tautulli/get_users.json"),
|
||||
headers={"Content-Type": CONTENT_TYPE_JSON},
|
||||
)
|
||||
|
||||
|
||||
async def setup_integration(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
url: str = URL,
|
||||
api_key: str = API_KEY,
|
||||
unique_id: str = None,
|
||||
skip_entry_setup: bool = False,
|
||||
invalid_auth: bool = False,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Tautulli integration in Home Assistant."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=unique_id,
|
||||
data={
|
||||
CONF_URL: url,
|
||||
CONF_VERIFY_SSL: VERIFY_SSL,
|
||||
CONF_API_KEY: api_key,
|
||||
},
|
||||
options={
|
||||
CONF_MONITORED_USERS: DEFAULT_USERS,
|
||||
},
|
||||
)
|
||||
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
mock_connection(
|
||||
aioclient_mock,
|
||||
url=url,
|
||||
invalid_auth=invalid_auth,
|
||||
)
|
||||
|
||||
if not skip_entry_setup:
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return entry
|
|
@ -0,0 +1,256 @@
|
|||
{
|
||||
"response": {
|
||||
"result": "success",
|
||||
"message": "None",
|
||||
"data": {
|
||||
"stream_count": "1",
|
||||
"sessions": [
|
||||
{
|
||||
"session_key": "39",
|
||||
"media_type": "episode",
|
||||
"view_offset": "688805",
|
||||
"progress_percent": "53",
|
||||
"quality_profile": "Original",
|
||||
"synced_version_profile": "",
|
||||
"optimized_version_profile": "",
|
||||
"user": "user1",
|
||||
"channel_stream": 0,
|
||||
"section_id": "2",
|
||||
"library_name": "TV Shows",
|
||||
"rating_key": "28514",
|
||||
"parent_rating_key": "28493",
|
||||
"grandparent_rating_key": "27615",
|
||||
"title": "What If…? Ultron Won",
|
||||
"parent_title": "Season 1",
|
||||
"grandparent_title": "What If…?",
|
||||
"original_title": "",
|
||||
"sort_title": "",
|
||||
"media_index": "21",
|
||||
"parent_media_index": "7",
|
||||
"studio": "Marvel",
|
||||
"content_rating": "TV-14",
|
||||
"summary": "Natasha Romanoff and Clint Barton seek to destroy killer-robot Ultron following a cataclysmic event.\r\n",
|
||||
"tagline": "",
|
||||
"rating": "7.0",
|
||||
"rating_image": "",
|
||||
"audience_rating": "",
|
||||
"audience_rating_image": "",
|
||||
"user_rating": "",
|
||||
"duration": "1288538",
|
||||
"year": "2021",
|
||||
"thumb": "/library/metadata/28514/thumb/1581502592",
|
||||
"parent_thumb": "/library/metadata/28493/thumb/1581502619",
|
||||
"grandparent_thumb": "/library/metadata/27615/thumb/1608869467",
|
||||
"art": "/library/metadata/27615/art/1608869467",
|
||||
"banner": "/library/metadata/27615/banner/1608869467",
|
||||
"originally_available_at": "2004-01-29",
|
||||
"added_at": "1516913437",
|
||||
"updated_at": "1581502592",
|
||||
"last_viewed_at": "",
|
||||
"guid": "com.plexapp.agents.thetvdb://367147/1/8?lang=en",
|
||||
"parent_guid": "com.plexapp.agents.thetvdb://367147/1?lang=en",
|
||||
"grandparent_guid": "com.plexapp.agents.thetvdb://367147?lang=en",
|
||||
"directors": [],
|
||||
"writers": [],
|
||||
"actors": ["John Doe"],
|
||||
"genres": [
|
||||
"Action",
|
||||
"Animation",
|
||||
"Anime",
|
||||
"Children",
|
||||
"Comedy",
|
||||
"Fantasy"
|
||||
],
|
||||
"labels": [],
|
||||
"collections": [],
|
||||
"guids": [],
|
||||
"full_title": "What If…? Ultron Won",
|
||||
"children_count": 0,
|
||||
"live": 0,
|
||||
"id": "",
|
||||
"container": "mkv",
|
||||
"bitrate": "326",
|
||||
"height": "480",
|
||||
"width": "640",
|
||||
"aspect_ratio": "1.33",
|
||||
"video_codec": "h264",
|
||||
"video_resolution": "480",
|
||||
"video_full_resolution": "480p",
|
||||
"video_framerate": "24p",
|
||||
"video_profile": "high",
|
||||
"audio_codec": "aac",
|
||||
"audio_channels": "2",
|
||||
"audio_channel_layout": "stereo",
|
||||
"audio_profile": "lc",
|
||||
"optimized_version": 0,
|
||||
"channel_call_sign": "",
|
||||
"channel_identifier": "",
|
||||
"channel_thumb": "",
|
||||
"file": "/Videos/What If...?/Season 1/What If...? - S01E08 - Ultron Won.mkv",
|
||||
"file_size": "52560333",
|
||||
"indexes": 1,
|
||||
"selected": 1,
|
||||
"type": "",
|
||||
"video_codec_level": "41",
|
||||
"video_bitrate": "326",
|
||||
"video_bit_depth": "8",
|
||||
"video_chroma_subsampling": "4:2:0",
|
||||
"video_color_primaries": "smpte170m",
|
||||
"video_color_range": "tv",
|
||||
"video_color_space": "smpte170m",
|
||||
"video_color_trc": "bt709",
|
||||
"video_frame_rate": "23.976",
|
||||
"video_ref_frames": "16",
|
||||
"video_height": "480",
|
||||
"video_width": "640",
|
||||
"video_language": "English",
|
||||
"video_language_code": "eng",
|
||||
"video_scan_type": "progressive",
|
||||
"audio_bitrate": "",
|
||||
"audio_bitrate_mode": "",
|
||||
"audio_sample_rate": "48000",
|
||||
"audio_language": "English",
|
||||
"audio_language_code": "eng",
|
||||
"subtitle_codec": "",
|
||||
"subtitle_container": "",
|
||||
"subtitle_format": "",
|
||||
"subtitle_forced": 0,
|
||||
"subtitle_location": "",
|
||||
"subtitle_language": "",
|
||||
"subtitle_language_code": "",
|
||||
"row_id": 2,
|
||||
"user_id": 11111111,
|
||||
"username": "user1",
|
||||
"friendly_name": "user1",
|
||||
"user_thumb": "https://plex.tv/users/1234567890abcdef/avatar?c=1111111111",
|
||||
"email": "user1@test.com",
|
||||
"is_active": 1,
|
||||
"is_admin": 1,
|
||||
"is_home_user": 1,
|
||||
"is_allow_sync": 1,
|
||||
"is_restricted": 0,
|
||||
"do_notify": 1,
|
||||
"keep_history": 1,
|
||||
"deleted_user": 0,
|
||||
"allow_guest": 0,
|
||||
"shared_libraries": [
|
||||
"2",
|
||||
"1",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"2",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"last_seen": "None",
|
||||
"ip_address": "10.1.0.200",
|
||||
"ip_address_public": "1.2.3.4",
|
||||
"device": "AFTMM",
|
||||
"platform": "Android",
|
||||
"platform_name": "android",
|
||||
"platform_version": "7.1.2",
|
||||
"product": "Plex for Android (TV)",
|
||||
"product_version": "8.23.2.28087",
|
||||
"profile": "Android",
|
||||
"player": "AFTMM",
|
||||
"machine_id": "1234567890abcdef-com-plexapp-android",
|
||||
"state": "playing",
|
||||
"local": 1,
|
||||
"relayed": 0,
|
||||
"secure": 1,
|
||||
"session_id": "1234567890abcdef-com-plexapp-android",
|
||||
"bandwidth": "587",
|
||||
"location": "lan",
|
||||
"transcode_key": "",
|
||||
"transcode_throttled": 0,
|
||||
"transcode_progress": 0,
|
||||
"transcode_speed": "",
|
||||
"transcode_audio_channels": "",
|
||||
"transcode_audio_codec": "",
|
||||
"transcode_video_codec": "",
|
||||
"transcode_width": "",
|
||||
"transcode_height": "",
|
||||
"transcode_container": "",
|
||||
"transcode_protocol": "",
|
||||
"transcode_hw_requested": 0,
|
||||
"transcode_hw_decode": "",
|
||||
"transcode_hw_decode_title": "",
|
||||
"transcode_hw_encode": "",
|
||||
"transcode_hw_encode_title": "",
|
||||
"transcode_hw_full_pipeline": 0,
|
||||
"audio_decision": "direct play",
|
||||
"video_decision": "direct play",
|
||||
"subtitle_decision": "",
|
||||
"throttled": "0",
|
||||
"transcode_hw_decoding": 0,
|
||||
"transcode_hw_encoding": 0,
|
||||
"stream_container": "mkv",
|
||||
"stream_bitrate": "326",
|
||||
"stream_aspect_ratio": "1.33",
|
||||
"stream_audio_codec": "aac",
|
||||
"stream_audio_channels": "2",
|
||||
"stream_audio_channel_layout": "stereo",
|
||||
"stream_video_codec": "h264",
|
||||
"stream_video_framerate": "24p",
|
||||
"stream_video_resolution": "480",
|
||||
"stream_video_height": "480",
|
||||
"stream_video_width": "640",
|
||||
"stream_duration": "1288538",
|
||||
"stream_container_decision": "direct play",
|
||||
"optimized_version_title": "",
|
||||
"synced_version": 0,
|
||||
"live_uuid": "",
|
||||
"bif_thumb": "/library/parts/227108/indexes/sd/688805",
|
||||
"subtitles": 0,
|
||||
"transcode_decision": "direct play",
|
||||
"container_decision": "direct play",
|
||||
"stream_video_full_resolution": "480p",
|
||||
"video_dynamic_range": "SDR",
|
||||
"stream_video_dynamic_range": "SDR",
|
||||
"stream_video_bitrate": "326",
|
||||
"stream_video_bit_depth": "8",
|
||||
"stream_video_chroma_subsampling": "4:2:0",
|
||||
"stream_video_color_primaries": "smpte170m",
|
||||
"stream_video_color_range": "tv",
|
||||
"stream_video_color_space": "smpte170m",
|
||||
"stream_video_color_trc": "bt709",
|
||||
"stream_video_codec_level": "41",
|
||||
"stream_video_ref_frames": "16",
|
||||
"stream_video_language": "English",
|
||||
"stream_video_language_code": "eng",
|
||||
"stream_video_scan_type": "progressive",
|
||||
"stream_video_decision": "direct play",
|
||||
"stream_audio_bitrate": "",
|
||||
"stream_audio_bitrate_mode": "",
|
||||
"stream_audio_sample_rate": "48000",
|
||||
"stream_audio_channel_layout_": "stereo",
|
||||
"stream_audio_language": "English",
|
||||
"stream_audio_language_code": "eng",
|
||||
"stream_audio_decision": "direct play",
|
||||
"stream_subtitle_codec": "",
|
||||
"stream_subtitle_container": "",
|
||||
"stream_subtitle_format": "",
|
||||
"stream_subtitle_forced": 0,
|
||||
"stream_subtitle_location": "",
|
||||
"stream_subtitle_language": "",
|
||||
"stream_subtitle_language_code": "",
|
||||
"stream_subtitle_decision": "",
|
||||
"stream_subtitle_transient": 0
|
||||
}
|
||||
],
|
||||
"stream_count_direct_play": 1,
|
||||
"stream_count_direct_stream": 0,
|
||||
"stream_count_transcode": 0,
|
||||
"total_bandwidth": 587,
|
||||
"lan_bandwidth": 587,
|
||||
"wan_bandwidth": 0
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,434 @@
|
|||
{
|
||||
"response": {
|
||||
"result": "success",
|
||||
"message": "None",
|
||||
"data": [
|
||||
{
|
||||
"stat_id": "top_movies",
|
||||
"stat_type": "total_plays",
|
||||
"stat_title": "Most Watched Movies",
|
||||
"rows": [
|
||||
{
|
||||
"title": "Rush Hour",
|
||||
"year": 1998,
|
||||
"total_plays": 1,
|
||||
"total_duration": 6007,
|
||||
"users_watched": "",
|
||||
"rating_key": 2975,
|
||||
"grandparent_rating_key": "",
|
||||
"last_play": 1633333051,
|
||||
"grandparent_thumb": "",
|
||||
"thumb": "/library/metadata/2975/thumb/1608877847",
|
||||
"art": "/library/metadata/2975/art/1608877847",
|
||||
"section_id": 1,
|
||||
"media_type": "movie",
|
||||
"content_rating": "PG-13",
|
||||
"labels": [],
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"platform": "",
|
||||
"live": 0,
|
||||
"guid": "plex://movie/5d77682b7e9a3c0020c6b438",
|
||||
"row_id": 4838
|
||||
},
|
||||
{
|
||||
"title": "Tomb Raider",
|
||||
"year": 2018,
|
||||
"total_plays": 1,
|
||||
"total_duration": 7102,
|
||||
"users_watched": "",
|
||||
"rating_key": 4725,
|
||||
"grandparent_rating_key": "",
|
||||
"last_play": 1633299184,
|
||||
"grandparent_thumb": "",
|
||||
"thumb": "/library/metadata/4725/thumb/1608896342",
|
||||
"art": "/library/metadata/4725/art/1608896342",
|
||||
"section_id": 1,
|
||||
"media_type": "movie",
|
||||
"content_rating": "PG-13",
|
||||
"labels": [],
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"platform": "",
|
||||
"live": 0,
|
||||
"guid": "plex://movie/5d776b9e96b655001fe16fd9",
|
||||
"row_id": 4837
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stat_id": "popular_movies",
|
||||
"stat_title": "Most Popular Movies",
|
||||
"rows": [
|
||||
{
|
||||
"title": "Rush Hour",
|
||||
"year": 1998,
|
||||
"users_watched": 1,
|
||||
"rating_key": 2975,
|
||||
"grandparent_rating_key": "",
|
||||
"last_play": 1633333051,
|
||||
"total_plays": 1,
|
||||
"grandparent_thumb": "",
|
||||
"thumb": "/library/metadata/2975/thumb/1608877847",
|
||||
"art": "/library/metadata/2975/art/1608877847",
|
||||
"section_id": 1,
|
||||
"media_type": "movie",
|
||||
"content_rating": "PG-13",
|
||||
"labels": [],
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"platform": "",
|
||||
"live": 0,
|
||||
"guid": "plex://movie/5d77682b7e9a3c0020c6b438",
|
||||
"row_id": 4838
|
||||
},
|
||||
{
|
||||
"title": "Rush Hour 2",
|
||||
"year": 2001,
|
||||
"users_watched": 1,
|
||||
"rating_key": 2976,
|
||||
"grandparent_rating_key": "",
|
||||
"last_play": 1633339363,
|
||||
"total_plays": 1,
|
||||
"grandparent_thumb": "",
|
||||
"thumb": "/library/metadata/2976/thumb/1608877847",
|
||||
"art": "/library/metadata/2976/art/1608877847",
|
||||
"section_id": 1,
|
||||
"media_type": "movie",
|
||||
"content_rating": "PG-13",
|
||||
"labels": [],
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"platform": "",
|
||||
"live": 0,
|
||||
"guid": "plex://movie/5d77682eeb5d26001f1df4fd",
|
||||
"row_id": 4839
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stat_id": "top_tv",
|
||||
"stat_type": "total_plays",
|
||||
"stat_title": "Most Watched TV Shows",
|
||||
"rows": [
|
||||
{
|
||||
"title": "What If…?",
|
||||
"year": "",
|
||||
"total_plays": 17,
|
||||
"total_duration": 32104,
|
||||
"users_watched": "",
|
||||
"rating_key": 229036,
|
||||
"grandparent_rating_key": 229036,
|
||||
"last_play": 1633555543,
|
||||
"grandparent_thumb": "/library/metadata/229036/thumb/1632908571",
|
||||
"thumb": "/library/metadata/229036/thumb/1632908571",
|
||||
"art": "/library/metadata/229036/art/1632908571",
|
||||
"section_id": 2,
|
||||
"media_type": "episode",
|
||||
"content_rating": "TV-14",
|
||||
"labels": [],
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"platform": "",
|
||||
"live": 0,
|
||||
"guid": "plex://episode/60eb19efd30af2002dc67d14",
|
||||
"row_id": 4852
|
||||
},
|
||||
{
|
||||
"title": "Star Trek: Lower Decks",
|
||||
"year": "",
|
||||
"total_plays": 3,
|
||||
"total_duration": 5189,
|
||||
"users_watched": "",
|
||||
"rating_key": 156643,
|
||||
"grandparent_rating_key": 156643,
|
||||
"last_play": 1633593441,
|
||||
"grandparent_thumb": "/library/metadata/156643/thumb/1632998327",
|
||||
"thumb": "/library/metadata/156643/thumb/1632998327",
|
||||
"art": "/library/metadata/156643/art/1632998327",
|
||||
"section_id": 2,
|
||||
"media_type": "episode",
|
||||
"content_rating": "TV-14",
|
||||
"labels": [],
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"platform": "",
|
||||
"live": 0,
|
||||
"guid": "plex://episode/6148a04e5f74bd5dfc6aa3cf",
|
||||
"row_id": 4856
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stat_id": "popular_tv",
|
||||
"stat_title": "Most Popular TV Shows",
|
||||
"rows": [
|
||||
{
|
||||
"title": "What If…?",
|
||||
"year": "",
|
||||
"users_watched": 1,
|
||||
"rating_key": 229036,
|
||||
"grandparent_rating_key": 229036,
|
||||
"last_play": 1633555543,
|
||||
"total_plays": 17,
|
||||
"grandparent_thumb": "/library/metadata/229036/thumb/1632908571",
|
||||
"thumb": "/library/metadata/229036/thumb/1632908571",
|
||||
"art": "/library/metadata/229036/art/1632908571",
|
||||
"section_id": 2,
|
||||
"media_type": "episode",
|
||||
"content_rating": "TV-14",
|
||||
"labels": [],
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"platform": "",
|
||||
"live": 0,
|
||||
"guid": "plex://episode/60eb19efd30af2002dc67d14",
|
||||
"row_id": 4852
|
||||
},
|
||||
{
|
||||
"title": "Star Trek: Lower Decks",
|
||||
"year": "",
|
||||
"users_watched": 1,
|
||||
"rating_key": 156643,
|
||||
"grandparent_rating_key": 156643,
|
||||
"last_play": 1633593441,
|
||||
"total_plays": 3,
|
||||
"grandparent_thumb": "/library/metadata/156643/thumb/1632998327",
|
||||
"thumb": "/library/metadata/156643/thumb/1632998327",
|
||||
"art": "/library/metadata/156643/art/1632998327",
|
||||
"section_id": 2,
|
||||
"media_type": "episode",
|
||||
"content_rating": "TV-14",
|
||||
"labels": [],
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"platform": "",
|
||||
"live": 0,
|
||||
"guid": "plex://episode/6148a04e5f74bd5dfc6aa3cf",
|
||||
"row_id": 4856
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stat_id": "top_music",
|
||||
"stat_type": "total_plays",
|
||||
"stat_title": "Most Played Artists",
|
||||
"rows": []
|
||||
},
|
||||
{
|
||||
"stat_id": "popular_music",
|
||||
"stat_title": "Most Popular Artists",
|
||||
"rows": []
|
||||
},
|
||||
{
|
||||
"stat_id": "last_watched",
|
||||
"stat_title": "Recently Watched",
|
||||
"rows": [
|
||||
{
|
||||
"row_id": 4876,
|
||||
"user": "user1",
|
||||
"friendly_name": "user1",
|
||||
"user_id": 12269439,
|
||||
"user_thumb": "https://plex.tv/users/1234567890abcdef/avatar?c=1111111111",
|
||||
"title": "What If…? Ultron Won",
|
||||
"grandparent_title": "What If…?",
|
||||
"grandchild_title": "What If…? Ultron Won",
|
||||
"year": 2021,
|
||||
"media_index": 8,
|
||||
"parent_media_index": 1,
|
||||
"rating_key": 28505,
|
||||
"grandparent_rating_key": 27615,
|
||||
"thumb": "/library/metadata/367147/thumb/1/8/1608869467",
|
||||
"grandparent_thumb": "/library/metadata/367147/thumb/1608869467",
|
||||
"art": "/library/metadata/367147/art/1608869467",
|
||||
"section_id": 2,
|
||||
"media_type": "episode",
|
||||
"content_rating": "TV-14",
|
||||
"labels": [],
|
||||
"last_watch": 1633851517,
|
||||
"live": 0,
|
||||
"guid": "com.plexapp.agents.thetvdb://367147?lang=en",
|
||||
"player": "AFTMM"
|
||||
},
|
||||
{
|
||||
"row_id": 4876,
|
||||
"user": "user1",
|
||||
"friendly_name": "user1",
|
||||
"user_id": 12269439,
|
||||
"user_thumb": "https://plex.tv/users/1234567890abcdef/avatar?c=1111111111",
|
||||
"title": "What If…? The Watcher Broke His Oath",
|
||||
"grandparent_title": "What If…?",
|
||||
"grandchild_title": "What If…? The Watcher Broke His Oath",
|
||||
"year": 2021,
|
||||
"media_index": 9,
|
||||
"parent_media_index": 1,
|
||||
"rating_key": 28505,
|
||||
"grandparent_rating_key": 27615,
|
||||
"thumb": "/library/metadata/367147/thumb/1608869467",
|
||||
"grandparent_thumb": "/library/metadata/367147/thumb/1608869467",
|
||||
"art": "/library/metadata/367147/art/1608869467",
|
||||
"section_id": 2,
|
||||
"media_type": "episode",
|
||||
"content_rating": "TV-14",
|
||||
"labels": [],
|
||||
"last_watch": 1633851517,
|
||||
"live": 0,
|
||||
"guid": "com.plexapp.agents.thetvdb://367147?lang=en",
|
||||
"player": "AFTMM"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stat_id": "top_libraries",
|
||||
"stat_type": "total_plays",
|
||||
"stat_title": "Most Active Libraries",
|
||||
"rows": [
|
||||
{
|
||||
"total_plays": 488,
|
||||
"total_duration": 914600,
|
||||
"section_type": "movie",
|
||||
"section_name": "Movies",
|
||||
"section_id": 2,
|
||||
"last_play": 1633879410,
|
||||
"thumb": "/:/resources/show.png",
|
||||
"grandparent_thumb": "",
|
||||
"art": "/:/resources/movie-fanart.jpg",
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"users_watched": "",
|
||||
"rating_key": "",
|
||||
"grandparent_rating_key": "",
|
||||
"title": "",
|
||||
"platform": "",
|
||||
"row_id": ""
|
||||
},
|
||||
{
|
||||
"total_plays": 51,
|
||||
"total_duration": 271800,
|
||||
"section_type": "movie",
|
||||
"section_name": "Movies",
|
||||
"section_id": 1,
|
||||
"last_play": 1633811027,
|
||||
"thumb": "/:/resources/movie.png",
|
||||
"grandparent_thumb": "",
|
||||
"art": "/:/resources/movie-fanart.jpg",
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"users_watched": "",
|
||||
"rating_key": "",
|
||||
"grandparent_rating_key": "",
|
||||
"title": "",
|
||||
"platform": "",
|
||||
"row_id": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stat_id": "top_users",
|
||||
"stat_type": "total_plays",
|
||||
"stat_title": "Most Active Users",
|
||||
"rows": [
|
||||
{
|
||||
"user": "user1",
|
||||
"user_id": 11111111,
|
||||
"friendly_name": "user1",
|
||||
"total_plays": 132,
|
||||
"total_duration": 308729,
|
||||
"last_play": 1633879410,
|
||||
"user_thumb": "https://plex.tv/users/1234567890abcdef/avatar?c=1111111111",
|
||||
"grandparent_thumb": "",
|
||||
"art": "",
|
||||
"users_watched": "",
|
||||
"rating_key": "",
|
||||
"grandparent_rating_key": "",
|
||||
"title": "",
|
||||
"platform": "",
|
||||
"row_id": ""
|
||||
},
|
||||
{
|
||||
"user": "user2",
|
||||
"user_id": 22222222,
|
||||
"friendly_name": "test@email.com",
|
||||
"total_plays": 7,
|
||||
"total_duration": 10521,
|
||||
"last_play": 1632799281,
|
||||
"user_thumb": "https://plex.tv/users/fedcba0987654321/avatar?c=2222222222",
|
||||
"grandparent_thumb": "",
|
||||
"art": "",
|
||||
"users_watched": "",
|
||||
"rating_key": "",
|
||||
"grandparent_rating_key": "",
|
||||
"title": "",
|
||||
"platform": "",
|
||||
"row_id": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stat_id": "top_platforms",
|
||||
"stat_type": "total_plays",
|
||||
"stat_title": "Most Active Platforms",
|
||||
"rows": [
|
||||
{
|
||||
"total_plays": 122,
|
||||
"total_duration": 285083,
|
||||
"last_play": 1633879410,
|
||||
"platform": "Android",
|
||||
"platform_name": "android",
|
||||
"title": "",
|
||||
"thumb": "",
|
||||
"grandparent_thumb": "",
|
||||
"art": "",
|
||||
"users_watched": "",
|
||||
"rating_key": "",
|
||||
"grandparent_rating_key": "",
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"row_id": ""
|
||||
},
|
||||
{
|
||||
"total_plays": 10,
|
||||
"total_duration": 23646,
|
||||
"last_play": 1632480545,
|
||||
"platform": "Windows",
|
||||
"platform_name": "windows",
|
||||
"title": "",
|
||||
"thumb": "",
|
||||
"grandparent_thumb": "",
|
||||
"art": "",
|
||||
"users_watched": "",
|
||||
"rating_key": "",
|
||||
"grandparent_rating_key": "",
|
||||
"user": "",
|
||||
"friendly_name": "",
|
||||
"row_id": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stat_id": "most_concurrent",
|
||||
"stat_title": "Most Concurrent Streams",
|
||||
"rows": [
|
||||
{
|
||||
"title": "Concurrent Streams",
|
||||
"count": 2,
|
||||
"started": "1632702800",
|
||||
"stopped": "1632705315"
|
||||
},
|
||||
{
|
||||
"title": "Concurrent Transcodes",
|
||||
"count": 1,
|
||||
"started": "1633294952",
|
||||
"stopped": "1633298957"
|
||||
},
|
||||
{
|
||||
"title": "Concurrent Direct Plays",
|
||||
"count": 2,
|
||||
"started": "1632702800",
|
||||
"stopped": "1632705315"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"response": {
|
||||
"result": "success",
|
||||
"message": null,
|
||||
"data": [
|
||||
{
|
||||
"row_id": 1,
|
||||
"user_id": 11111111,
|
||||
"username": "user1",
|
||||
"friendly_name": "user1",
|
||||
"thumb": "https://plex.tv/users/1234567890abcdef/avatar?c=1111111111",
|
||||
"email": "test@email.com",
|
||||
"is_active": 1,
|
||||
"is_admin": 1,
|
||||
"is_home_user": 1,
|
||||
"is_allow_sync": 1,
|
||||
"is_restricted": 0,
|
||||
"do_notify": 1,
|
||||
"keep_history": 1,
|
||||
"allow_guest": 0,
|
||||
"server_token": "1234-67890abcdef-234",
|
||||
"shared_libraries": "2;1;3;4;5;6;7;2;1;2;3;1;2;3",
|
||||
"filter_all": "",
|
||||
"filter_movies": "",
|
||||
"filter_tv": "",
|
||||
"filter_music": "",
|
||||
"filter_photos": ""
|
||||
},
|
||||
{
|
||||
"row_id": 2,
|
||||
"user_id": 22222222,
|
||||
"username": "user2",
|
||||
"friendly_name": "user2",
|
||||
"thumb": "https://plex.tv/users/fedcba0987654321/avatar?c=2222222222",
|
||||
"email": "",
|
||||
"is_active": 1,
|
||||
"is_admin": 0,
|
||||
"is_home_user": 1,
|
||||
"is_allow_sync": 1,
|
||||
"is_restricted": 1,
|
||||
"do_notify": 1,
|
||||
"keep_history": 1,
|
||||
"allow_guest": 0,
|
||||
"server_token": "1234567890abcdef1234",
|
||||
"shared_libraries": "1;3;2",
|
||||
"filter_all": "",
|
||||
"filter_movies": "",
|
||||
"filter_tv": "",
|
||||
"filter_music": "",
|
||||
"filter_photos": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"response": {
|
||||
"result": "error",
|
||||
"message": "Invalid apikey",
|
||||
"data": {}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,227 @@
|
|||
"""Test Tautulli config flow."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pytautulli import exceptions
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.tautulli.const import DEFAULT_NAME, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER
|
||||
from homeassistant.const import CONF_API_KEY, CONF_SOURCE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import (
|
||||
CONF_DATA,
|
||||
CONF_IMPORT_DATA,
|
||||
NAME,
|
||||
patch_config_flow_tautulli,
|
||||
setup_integration,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
|
||||
async def test_flow_user_single_instance_allowed(hass: HomeAssistant) -> None:
|
||||
"""Test user step single instance allowed."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch_config_flow_tautulli(AsyncMock()):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data=CONF_IMPORT_DATA,
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
async def test_flow_user(hass: HomeAssistant) -> None:
|
||||
"""Test user initiated flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch_config_flow_tautulli(AsyncMock()):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=CONF_DATA,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2["title"] == NAME
|
||||
assert result2["data"] == CONF_DATA
|
||||
|
||||
|
||||
async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None:
|
||||
"""Test user initialized flow with unreachable server."""
|
||||
with patch_config_flow_tautulli(AsyncMock()) as tautullimock:
|
||||
tautullimock.side_effect = exceptions.PyTautulliConnectionException
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"]["base"] == "cannot_connect"
|
||||
|
||||
with patch_config_flow_tautulli(AsyncMock()):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=CONF_DATA,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2["title"] == NAME
|
||||
assert result2["data"] == CONF_DATA
|
||||
|
||||
|
||||
async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None:
|
||||
"""Test user initialized flow with invalid authentication."""
|
||||
with patch_config_flow_tautulli(AsyncMock()) as tautullimock:
|
||||
tautullimock.side_effect = exceptions.PyTautulliAuthenticationException
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
with patch_config_flow_tautulli(AsyncMock()):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=CONF_DATA,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2["title"] == NAME
|
||||
assert result2["data"] == CONF_DATA
|
||||
|
||||
|
||||
async def test_flow_user_unknown_error(hass: HomeAssistant) -> None:
|
||||
"""Test user initialized flow with unreachable server."""
|
||||
with patch_config_flow_tautulli(AsyncMock()) as tautullimock:
|
||||
tautullimock.side_effect = exceptions.PyTautulliException
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"]["base"] == "unknown"
|
||||
|
||||
with patch_config_flow_tautulli(AsyncMock()):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=CONF_DATA,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2["title"] == NAME
|
||||
assert result2["data"] == CONF_DATA
|
||||
|
||||
|
||||
async def test_flow_import(hass: HomeAssistant, caplog: LogCaptureFixture) -> None:
|
||||
"""Test import step."""
|
||||
with patch_config_flow_tautulli(AsyncMock()):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=CONF_IMPORT_DATA,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result["title"] == DEFAULT_NAME
|
||||
assert result["data"] == CONF_DATA
|
||||
assert "Tautulli platform in YAML" in caplog.text
|
||||
|
||||
|
||||
async def test_flow_import_single_instance_allowed(hass: HomeAssistant) -> None:
|
||||
"""Test import step single instance allowed."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch_config_flow_tautulli(AsyncMock()):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=CONF_IMPORT_DATA,
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
async def test_flow_reauth(
|
||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||
) -> None:
|
||||
"""Test reauth flow."""
|
||||
with patch("homeassistant.components.tautulli.PLATFORMS", []):
|
||||
entry = await setup_integration(hass, 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"
|
||||
assert result["errors"] == {}
|
||||
|
||||
new_conf = {CONF_API_KEY: "efgh"}
|
||||
CONF_DATA[CONF_API_KEY] = "efgh"
|
||||
with patch_config_flow_tautulli(AsyncMock()), patch(
|
||||
"homeassistant.components.tautulli.async_setup_entry"
|
||||
) as mock_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=new_conf,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result2["reason"] == "reauth_successful"
|
||||
assert entry.data == CONF_DATA
|
||||
assert len(mock_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_flow_reauth_error(
|
||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||
) -> None:
|
||||
"""Test reauth flow with invalid authentication."""
|
||||
with patch("homeassistant.components.tautulli.PLATFORMS", []):
|
||||
entry = await setup_integration(hass, aioclient_mock)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": SOURCE_REAUTH,
|
||||
"entry_id": entry.entry_id,
|
||||
"unique_id": entry.unique_id,
|
||||
},
|
||||
)
|
||||
with patch_config_flow_tautulli(AsyncMock()) as tautullimock:
|
||||
tautullimock.side_effect = exceptions.PyTautulliAuthenticationException
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_API_KEY: "efgh"},
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
with patch_config_flow_tautulli(AsyncMock()):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_API_KEY: "efgh"},
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
Loading…
Reference in New Issue