Use aiopyarr for sonarr (#65349)

This commit is contained in:
Chris Talkington 2022-02-22 11:33:10 -06:00 committed by GitHub
parent c14912471d
commit f30681dae7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 464 additions and 345 deletions

View file

@ -2,8 +2,11 @@
from __future__ import annotations
from datetime import timedelta
import logging
from sonarr import Sonarr, SonarrAccessRestricted, SonarrError
from aiopyarr import ArrAuthenticationException, ArrException
from aiopyarr.models.host_configuration import PyArrHostConfiguration
from aiopyarr.sonarr_client import SonarrClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@ -11,6 +14,7 @@ from homeassistant.const import (
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_URL,
CONF_VERIFY_SSL,
Platform,
)
@ -22,7 +26,9 @@ from .const import (
CONF_BASE_PATH,
CONF_UPCOMING_DAYS,
CONF_WANTED_MAX_ITEMS,
DATA_HOST_CONFIG,
DATA_SONARR,
DATA_SYSTEM_STATUS,
DEFAULT_UPCOMING_DAYS,
DEFAULT_WANTED_MAX_ITEMS,
DOMAIN,
@ -30,6 +36,7 @@ from .const import (
PLATFORMS = [Platform.SENSOR]
SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -45,30 +52,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
}
hass.config_entries.async_update_entry(entry, options=options)
sonarr = Sonarr(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
api_key=entry.data[CONF_API_KEY],
base_path=entry.data[CONF_BASE_PATH],
session=async_get_clientsession(hass),
tls=entry.data[CONF_SSL],
host_configuration = PyArrHostConfiguration(
api_token=entry.data[CONF_API_KEY],
url=entry.data[CONF_URL],
verify_ssl=entry.data[CONF_VERIFY_SSL],
)
sonarr = SonarrClient(
host_configuration=host_configuration,
session=async_get_clientsession(hass),
)
try:
await sonarr.update()
except SonarrAccessRestricted as err:
system_status = await sonarr.async_get_system_status()
except ArrAuthenticationException as err:
raise ConfigEntryAuthFailed(
"API Key is no longer valid. Please reauthenticate"
) from err
except SonarrError as err:
except ArrException as err:
raise ConfigEntryNotReady from err
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
DATA_HOST_CONFIG: host_configuration,
DATA_SONARR: sonarr,
DATA_SYSTEM_STATUS: system_status,
}
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
@ -76,6 +86,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
if entry.version == 1:
new_proto = "https" if entry.data[CONF_SSL] else "http"
new_host_port = f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}"
new_path = ""
if entry.data[CONF_BASE_PATH].rstrip("/") not in ("", "/", "/api"):
new_path = entry.data[CONF_BASE_PATH].rstrip("/")
data = {
**entry.data,
CONF_URL: f"{new_proto}://{new_host_port}{new_path}",
}
hass.config_entries.async_update_entry(entry, data=data)
entry.version = 2
_LOGGER.info("Migration to version %s successful", entry.version)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -4,28 +4,21 @@ from __future__ import annotations
import logging
from typing import Any
from sonarr import Sonarr, SonarrAccessRestricted, SonarrError
from aiopyarr import ArrAuthenticationException, ArrException
from aiopyarr.models.host_configuration import PyArrHostConfiguration
from aiopyarr.sonarr_client import SonarrClient
import voluptuous as vol
import yarl
from homeassistant.config_entries import ConfigFlow, OptionsFlow
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
CONF_BASE_PATH,
CONF_UPCOMING_DAYS,
CONF_WANTED_MAX_ITEMS,
DEFAULT_BASE_PATH,
DEFAULT_PORT,
DEFAULT_SSL,
DEFAULT_UPCOMING_DAYS,
DEFAULT_VERIFY_SSL,
DEFAULT_WANTED_MAX_ITEMS,
@ -40,25 +33,24 @@ async def validate_input(hass: HomeAssistant, data: dict) -> None:
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
session = async_get_clientsession(hass)
sonarr = Sonarr(
host=data[CONF_HOST],
port=data[CONF_PORT],
api_key=data[CONF_API_KEY],
base_path=data[CONF_BASE_PATH],
tls=data[CONF_SSL],
host_configuration = PyArrHostConfiguration(
api_token=data[CONF_API_KEY],
url=data[CONF_URL],
verify_ssl=data[CONF_VERIFY_SSL],
session=session,
)
await sonarr.update()
sonarr = SonarrClient(
host_configuration=host_configuration,
session=async_get_clientsession(hass),
)
await sonarr.async_get_system_status()
class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sonarr."""
VERSION = 1
VERSION = 2
def __init__(self):
"""Initialize the flow."""
@ -83,7 +75,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={"host": self.entry.data[CONF_HOST]},
description_placeholders={"url": self.entry.data[CONF_URL]},
data_schema=vol.Schema({}),
errors={},
)
@ -105,9 +97,9 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await validate_input(self.hass, user_input)
except SonarrAccessRestricted:
except ArrAuthenticationException:
errors = {"base": "invalid_auth"}
except SonarrError:
except ArrException:
errors = {"base": "cannot_connect"}
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
@ -116,8 +108,10 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
if self.entry:
return await self._async_reauth_update_entry(user_input)
parsed = yarl.URL(user_input[CONF_URL])
return self.async_create_entry(
title=user_input[CONF_HOST], data=user_input
title=parsed.host or "Sonarr", data=user_input
)
data_schema = self._get_user_data_schema()
@ -139,12 +133,9 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
if self.entry:
return {vol.Required(CONF_API_KEY): str}
data_schema = {
vol.Required(CONF_HOST): str,
data_schema: dict[str, Any] = {
vol.Required(CONF_URL): str,
vol.Required(CONF_API_KEY): str,
vol.Optional(CONF_BASE_PATH, default=DEFAULT_BASE_PATH): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
}
if self.show_advanced_options:

View file

@ -7,17 +7,14 @@ CONF_DAYS = "days"
CONF_INCLUDED = "include_paths"
CONF_UNIT = "unit"
CONF_UPCOMING_DAYS = "upcoming_days"
CONF_URLBASE = "urlbase"
CONF_WANTED_MAX_ITEMS = "wanted_max_items"
# Data
DATA_HOST_CONFIG = "host_config"
DATA_SONARR = "sonarr"
DATA_SYSTEM_STATUS = "system_status"
# Defaults
DEFAULT_BASE_PATH = "/api"
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8989
DEFAULT_SSL = False
DEFAULT_UPCOMING_DAYS = 1
DEFAULT_VERIFY_SSL = False
DEFAULT_WANTED_MAX_ITEMS = 50

View file

@ -1,7 +1,9 @@
"""Base Entity for Sonarr."""
from __future__ import annotations
from sonarr import Sonarr
from aiopyarr import SystemStatus
from aiopyarr.models.host_configuration import PyArrHostConfiguration
from aiopyarr.sonarr_client import SonarrClient
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, Entity
@ -15,7 +17,9 @@ class SonarrEntity(Entity):
def __init__(
self,
*,
sonarr: Sonarr,
sonarr: SonarrClient,
host_config: PyArrHostConfiguration,
system_status: SystemStatus,
entry_id: str,
device_id: str,
) -> None:
@ -23,6 +27,8 @@ class SonarrEntity(Entity):
self._entry_id = entry_id
self._device_id = device_id
self.sonarr = sonarr
self.host_config = host_config
self.system_status = system_status
@property
def device_info(self) -> DeviceInfo | None:
@ -30,15 +36,11 @@ class SonarrEntity(Entity):
if self._device_id is None:
return None
configuration_url = "https://" if self.sonarr.tls else "http://"
configuration_url += f"{self.sonarr.host}:{self.sonarr.port}"
configuration_url += self.sonarr.base_path.replace("/api", "")
return DeviceInfo(
identifiers={(DOMAIN, self._device_id)},
name="Activity Sensor",
manufacturer="Sonarr",
sw_version=self.sonarr.app.info.version,
sw_version=self.system_status.version,
entry_type=DeviceEntryType.SERVICE,
configuration_url=configuration_url,
configuration_url=self.host_config.base_url,
)

View file

@ -3,9 +3,9 @@
"name": "Sonarr",
"documentation": "https://www.home-assistant.io/integrations/sonarr",
"codeowners": ["@ctalkington"],
"requirements": ["sonarr==0.3.0"],
"requirements": ["aiopyarr==22.2.1"],
"config_flow": true,
"quality_scale": "silver",
"iot_class": "local_polling",
"loggers": ["sonarr"]
"loggers": ["aiopyarr"]
}

View file

@ -7,7 +7,9 @@ from functools import wraps
import logging
from typing import Any, TypeVar
from sonarr import Sonarr, SonarrConnectionError, SonarrError
from aiopyarr import ArrConnectionException, ArrException, SystemStatus
from aiopyarr.models.host_configuration import PyArrHostConfiguration
from aiopyarr.sonarr_client import SonarrClient
from typing_extensions import Concatenate, ParamSpec
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
@ -18,7 +20,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
import homeassistant.util.dt as dt_util
from .const import CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DATA_SONARR, DOMAIN
from .const import (
CONF_UPCOMING_DAYS,
CONF_WANTED_MAX_ITEMS,
DATA_HOST_CONFIG,
DATA_SONARR,
DATA_SYSTEM_STATUS,
DOMAIN,
)
from .entity import SonarrEntity
_LOGGER = logging.getLogger(__name__)
@ -77,11 +86,22 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Sonarr sensors based on a config entry."""
sonarr: Sonarr = hass.data[DOMAIN][entry.entry_id][DATA_SONARR]
sonarr: SonarrClient = hass.data[DOMAIN][entry.entry_id][DATA_SONARR]
host_config: PyArrHostConfiguration = hass.data[DOMAIN][entry.entry_id][
DATA_HOST_CONFIG
]
system_status: SystemStatus = hass.data[DOMAIN][entry.entry_id][DATA_SYSTEM_STATUS]
options: dict[str, Any] = dict(entry.options)
entities = [
SonarrSensor(sonarr, entry.entry_id, description, options)
SonarrSensor(
sonarr,
host_config,
system_status,
entry.entry_id,
description,
options,
)
for description in SENSOR_TYPES
]
@ -102,12 +122,12 @@ def sonarr_exception_handler(
try:
await func(self, *args, **kwargs)
self.last_update_success = True
except SonarrConnectionError as error:
if self.available:
except ArrConnectionException as error:
if self.last_update_success:
_LOGGER.error("Error communicating with API: %s", error)
self.last_update_success = False
except SonarrError as error:
if self.available:
except ArrException as error:
if self.last_update_success:
_LOGGER.error("Invalid response from API: %s", error)
self.last_update_success = False
@ -124,7 +144,9 @@ class SonarrSensor(SonarrEntity, SensorEntity):
def __init__(
self,
sonarr: Sonarr,
sonarr: SonarrClient,
host_config: PyArrHostConfiguration,
system_status: SystemStatus,
entry_id: str,
description: SensorEntityDescription,
options: dict[str, Any],
@ -140,6 +162,8 @@ class SonarrSensor(SonarrEntity, SensorEntity):
super().__init__(
sonarr=sonarr,
host_config=host_config,
system_status=system_status,
entry_id=entry_id,
device_id=entry_id,
)
@ -155,23 +179,30 @@ class SonarrSensor(SonarrEntity, SensorEntity):
key = self.entity_description.key
if key == "diskspace":
await self.sonarr.update()
self.data[key] = await self.sonarr.async_get_diskspace()
elif key == "commands":
self.data[key] = await self.sonarr.commands()
self.data[key] = await self.sonarr.async_get_commands()
elif key == "queue":
self.data[key] = await self.sonarr.queue()
self.data[key] = await self.sonarr.async_get_queue(
include_series=True, include_episode=True
)
elif key == "series":
self.data[key] = await self.sonarr.series()
self.data[key] = await self.sonarr.async_get_series()
elif key == "upcoming":
local = dt_util.start_of_local_day().replace(microsecond=0)
start = dt_util.as_utc(local)
end = start + timedelta(days=self.upcoming_days)
self.data[key] = await self.sonarr.calendar(
start=start.isoformat(), end=end.isoformat()
self.data[key] = await self.sonarr.async_get_calendar(
start_date=start,
end_date=end,
include_series=True,
)
elif key == "wanted":
self.data[key] = await self.sonarr.wanted(page_size=self.wanted_max_items)
self.data[key] = await self.sonarr.async_get_wanted(
page_size=self.wanted_max_items,
include_series=True,
)
@property
def extra_state_attributes(self) -> dict[str, str] | None:
@ -179,10 +210,10 @@ class SonarrSensor(SonarrEntity, SensorEntity):
attrs = {}
key = self.entity_description.key
if key == "diskspace":
for disk in self.sonarr.app.disks:
free = disk.free / 1024**3
total = disk.total / 1024**3
if key == "diskspace" and self.data.get(key) is not None:
for disk in self.data[key]:
free = disk.freeSpace / 1024**3
total = disk.totalSpace / 1024**3
usage = free / total * 100
attrs[
@ -190,23 +221,33 @@ class SonarrSensor(SonarrEntity, SensorEntity):
] = f"{free:.2f}/{total:.2f}{self.unit_of_measurement} ({usage:.2f}%)"
elif key == "commands" and self.data.get(key) is not None:
for command in self.data[key]:
attrs[command.name] = command.state
attrs[command.name] = command.status
elif key == "queue" and self.data.get(key) is not None:
for item in self.data[key]:
remaining = 1 if item.size == 0 else item.size_remaining / item.size
for item in self.data[key].records:
remaining = 1 if item.size == 0 else item.sizeleft / item.size
remaining_pct = 100 * (1 - remaining)
name = f"{item.episode.series.title} {item.episode.identifier}"
identifier = f"S{item.episode.seasonNumber:02d}E{item.episode. episodeNumber:02d}"
name = f"{item.series.title} {identifier}"
attrs[name] = f"{remaining_pct:.2f}%"
elif key == "series" and self.data.get(key) is not None:
for item in self.data[key]:
attrs[item.series.title] = f"{item.downloaded}/{item.episodes} Episodes"
stats = item.statistics
attrs[
item.title
] = f"{stats.episodeFileCount}/{stats.episodeCount} Episodes"
elif key == "upcoming" and self.data.get(key) is not None:
for episode in self.data[key]:
attrs[episode.series.title] = episode.identifier
identifier = f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}"
attrs[episode.series.title] = identifier
elif key == "wanted" and self.data.get(key) is not None:
for episode in self.data[key].episodes:
name = f"{episode.series.title} {episode.identifier}"
attrs[name] = episode.airdate
for item in self.data[key].records:
identifier = f"S{item.seasonNumber:02d}E{item.episodeNumber:02d}"
name = f"{item.series.title} {identifier}"
attrs[name] = dt_util.as_local(
item.airDateUtc.replace(tzinfo=dt_util.UTC)
).isoformat()
return attrs
@ -215,8 +256,8 @@ class SonarrSensor(SonarrEntity, SensorEntity):
"""Return the state of the sensor."""
key = self.entity_description.key
if key == "diskspace":
total_free = sum(disk.free for disk in self.sonarr.app.disks)
if key == "diskspace" and self.data.get(key) is not None:
total_free = sum(disk.freeSpace for disk in self.data[key])
free = total_free / 1024**3
return f"{free:.2f}"
@ -224,7 +265,7 @@ class SonarrSensor(SonarrEntity, SensorEntity):
return len(self.data[key])
if key == "queue" and self.data.get(key) is not None:
return len(self.data[key])
return self.data[key].totalRecords
if key == "series" and self.data.get(key) is not None:
return len(self.data[key])
@ -233,6 +274,6 @@ class SonarrSensor(SonarrEntity, SensorEntity):
return len(self.data[key])
if key == "wanted" and self.data.get(key) is not None:
return self.data[key].total
return self.data[key].totalRecords
return None

View file

@ -4,17 +4,14 @@
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"base_path": "Path to API",
"port": "[%key:common::config_flow::data::port%]",
"ssl": "[%key:common::config_flow::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Sonarr integration needs to be manually re-authenticated with the Sonarr API hosted at: {host}"
"description": "The Sonarr integration needs to be manually re-authenticated with the Sonarr API hosted at: {url}"
}
},
"error": {

View file

@ -244,6 +244,9 @@ aiopvapi==1.6.19
# homeassistant.components.pvpc_hourly_pricing
aiopvpc==3.0.0
# homeassistant.components.sonarr
aiopyarr==22.2.1
# homeassistant.components.recollect_waste
aiorecollect==1.0.8
@ -2252,9 +2255,6 @@ somecomfort==0.8.0
# homeassistant.components.somfy_mylink
somfy-mylink-synergy==1.0.6
# homeassistant.components.sonarr
sonarr==0.3.0
# homeassistant.components.marytts
speak2mary==1.4.0

View file

@ -179,6 +179,9 @@ aiopvapi==1.6.19
# homeassistant.components.pvpc_hourly_pricing
aiopvpc==3.0.0
# homeassistant.components.sonarr
aiopyarr==22.2.1
# homeassistant.components.recollect_waste
aiorecollect==1.0.8
@ -1388,9 +1391,6 @@ somecomfort==0.8.0
# homeassistant.components.somfy_mylink
somfy-mylink-synergy==1.0.6
# homeassistant.components.sonarr
sonarr==0.3.0
# homeassistant.components.marytts
speak2mary==1.4.0

View file

@ -1,13 +1,9 @@
"""Tests for the Sonarr component."""
from homeassistant.components.sonarr.const import CONF_BASE_PATH
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL
from homeassistant.const import CONF_API_KEY, CONF_URL
MOCK_REAUTH_INPUT = {CONF_API_KEY: "test-api-key-reauth"}
MOCK_USER_INPUT = {
CONF_HOST: "192.168.1.189",
CONF_PORT: 8989,
CONF_BASE_PATH: "/api",
CONF_SSL: False,
CONF_URL: "http://192.168.1.189:8989",
CONF_API_KEY: "MOCK_API_KEY",
}

View file

@ -3,15 +3,16 @@ from collections.abc import Generator
import json
from unittest.mock import MagicMock, patch
import pytest
from sonarr.models import (
Application,
CommandItem,
Episode,
QueueItem,
SeriesItem,
WantedResults,
from aiopyarr import (
Command,
Diskspace,
SonarrCalendar,
SonarrQueue,
SonarrSeries,
SonarrWantedMissing,
SystemStatus,
)
import pytest
from homeassistant.components.sonarr.const import (
CONF_BASE_PATH,
@ -33,34 +34,46 @@ from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
def sonarr_calendar():
def sonarr_calendar() -> list[SonarrCalendar]:
"""Generate a response for the calendar method."""
results = json.loads(load_fixture("sonarr/calendar.json"))
return [Episode.from_dict(result) for result in results]
return [SonarrCalendar(result) for result in results]
def sonarr_commands():
def sonarr_commands() -> list[Command]:
"""Generate a response for the commands method."""
results = json.loads(load_fixture("sonarr/command.json"))
return [CommandItem.from_dict(result) for result in results]
return [Command(result) for result in results]
def sonarr_queue():
def sonarr_diskspace() -> list[Diskspace]:
"""Generate a response for the diskspace method."""
results = json.loads(load_fixture("sonarr/diskspace.json"))
return [Diskspace(result) for result in results]
def sonarr_queue() -> SonarrQueue:
"""Generate a response for the queue method."""
results = json.loads(load_fixture("sonarr/queue.json"))
return [QueueItem.from_dict(result) for result in results]
return SonarrQueue(results)
def sonarr_series():
def sonarr_series() -> list[SonarrSeries]:
"""Generate a response for the series method."""
results = json.loads(load_fixture("sonarr/series.json"))
return [SeriesItem.from_dict(result) for result in results]
return [SonarrSeries(result) for result in results]
def sonarr_wanted():
def sonarr_system_status() -> SystemStatus:
"""Generate a response for the system status method."""
result = json.loads(load_fixture("sonarr/system-status.json"))
return SystemStatus(result)
def sonarr_wanted() -> SonarrWantedMissing:
"""Generate a response for the wanted method."""
results = json.loads(load_fixture("sonarr/wanted-missing.json"))
return WantedResults.from_dict(results)
return SonarrWantedMissing(results)
@pytest.fixture
@ -95,54 +108,38 @@ def mock_setup_entry() -> Generator[None, None, None]:
@pytest.fixture
def mock_sonarr_config_flow(
request: pytest.FixtureRequest,
) -> Generator[None, MagicMock, None]:
def mock_sonarr_config_flow() -> Generator[None, MagicMock, None]:
"""Return a mocked Sonarr client."""
fixture: str = "sonarr/app.json"
if hasattr(request, "param") and request.param:
fixture = request.param
app = Application(json.loads(load_fixture(fixture)))
with patch(
"homeassistant.components.sonarr.config_flow.Sonarr", autospec=True
"homeassistant.components.sonarr.config_flow.SonarrClient", autospec=True
) as sonarr_mock:
client = sonarr_mock.return_value
client.host = "192.168.1.189"
client.port = 8989
client.base_path = "/api"
client.tls = False
client.app = app
client.update.return_value = app
client.calendar.return_value = sonarr_calendar()
client.commands.return_value = sonarr_commands()
client.queue.return_value = sonarr_queue()
client.series.return_value = sonarr_series()
client.wanted.return_value = sonarr_wanted()
client.async_get_calendar.return_value = sonarr_calendar()
client.async_get_commands.return_value = sonarr_commands()
client.async_get_diskspace.return_value = sonarr_diskspace()
client.async_get_queue.return_value = sonarr_queue()
client.async_get_series.return_value = sonarr_series()
client.async_get_system_status.return_value = sonarr_system_status()
client.async_get_wanted.return_value = sonarr_wanted()
yield client
@pytest.fixture
def mock_sonarr(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
def mock_sonarr() -> Generator[None, MagicMock, None]:
"""Return a mocked Sonarr client."""
fixture: str = "sonarr/app.json"
if hasattr(request, "param") and request.param:
fixture = request.param
app = Application(json.loads(load_fixture(fixture)))
with patch("homeassistant.components.sonarr.Sonarr", autospec=True) as sonarr_mock:
with patch(
"homeassistant.components.sonarr.SonarrClient", autospec=True
) as sonarr_mock:
client = sonarr_mock.return_value
client.host = "192.168.1.189"
client.port = 8989
client.base_path = "/api"
client.tls = False
client.app = app
client.update.return_value = app
client.calendar.return_value = sonarr_calendar()
client.commands.return_value = sonarr_commands()
client.queue.return_value = sonarr_queue()
client.series.return_value = sonarr_series()
client.wanted.return_value = sonarr_wanted()
client.async_get_calendar.return_value = sonarr_calendar()
client.async_get_commands.return_value = sonarr_commands()
client.async_get_diskspace.return_value = sonarr_diskspace()
client.async_get_queue.return_value = sonarr_queue()
client.async_get_series.return_value = sonarr_series()
client.async_get_system_status.return_value = sonarr_system_status()
client.async_get_wanted.return_value = sonarr_wanted()
yield client

View file

@ -1,28 +0,0 @@
{
"info": {
"version": "2.0.0.1121",
"buildTime": "2014-02-08T20:49:36.5560392Z",
"isDebug": false,
"isProduction": true,
"isAdmin": true,
"isUserInteractive": false,
"startupPath": "C:\\ProgramData\\NzbDrone\\bin",
"appData": "C:\\ProgramData\\NzbDrone",
"osVersion": "6.2.9200.0",
"isMono": false,
"isLinux": false,
"isWindows": true,
"branch": "develop",
"authentication": false,
"startOfWeek": 0,
"urlBase": ""
},
"diskspace": [
{
"path": "C:\\",
"label": "",
"freeSpace": 282500067328,
"totalSpace": 499738734592
}
]
}

View file

@ -17,7 +17,6 @@
"queued": "2020-04-06T16:54:06.41945Z",
"started": "2020-04-06T16:54:06.421322Z",
"trigger": "manual",
"state": "started",
"manual": true,
"startedOn": "2020-04-06T16:54:06.41945Z",
"stateChangeTime": "2020-04-06T16:54:06.421322Z",
@ -27,7 +26,7 @@
},
{
"name": "RefreshSeries",
"state": "started",
"status": "started",
"startedOn": "2020-04-06T16:57:51.406504Z",
"stateChangeTime": "2020-04-06T16:57:51.417931Z",
"sendUpdatesToClient": true,

View file

@ -1,129 +1,140 @@
[
{
"page":1,
"pageSize":10,
"sortKey":"timeleft",
"sortDirection":"ascending",
"totalRecords":1,
"records":[
{
"series": {
"title": "The Andy Griffith Show",
"sortTitle": "andy griffith show",
"seasonCount": 8,
"status": "ended",
"overview": "Down-home humor and an endearing cast of characters helped make The Andy Griffith Show one of the most beloved comedies in the history of TV. The show centered around widower Andy Taylor, who divided his time between raising his young son Opie, and his job as sheriff of the sleepy North Carolina town, Mayberry. Andy and Opie live with Andy's Aunt Bee, who serves as a surrogate mother to both father and son. Andy's nervous cousin, Barney Fife, is his deputy sheriff whose incompetence is tolerated because Mayberry is virtually crime-free.",
"network": "CBS",
"airTime": "21:30",
"images": [
"series":{
"title":"The Andy Griffith Show",
"sortTitle":"andy griffith show",
"seasonCount":8,
"status":"ended",
"overview":"Down-home humor and an endearing cast of characters helped make The Andy Griffith Show one of the most beloved comedies in the history of TV. The show centered around widower Andy Taylor, who divided his time between raising his young son Opie, and his job as sheriff of the sleepy North Carolina town, Mayberry. Andy and Opie live with Andy's Aunt Bee, who serves as a surrogate mother to both father and son. Andy's nervous cousin, Barney Fife, is his deputy sheriff whose incompetence is tolerated because Mayberry is virtually crime-free.",
"network":"CBS",
"airTime":"21:30",
"images":[
{
"coverType": "fanart",
"url": "https://artworks.thetvdb.com/banners/fanart/original/77754-5.jpg"
"coverType":"fanart",
"url":"https://artworks.thetvdb.com/banners/fanart/original/77754-5.jpg"
},
{
"coverType": "banner",
"url": "https://artworks.thetvdb.com/banners/graphical/77754-g.jpg"
"coverType":"banner",
"url":"https://artworks.thetvdb.com/banners/graphical/77754-g.jpg"
},
{
"coverType": "poster",
"url": "https://artworks.thetvdb.com/banners/posters/77754-4.jpg"
"coverType":"poster",
"url":"https://artworks.thetvdb.com/banners/posters/77754-4.jpg"
}
],
"seasons": [
"seasons":[
{
"seasonNumber": 0,
"monitored": false
"seasonNumber":0,
"monitored":false
},
{
"seasonNumber": 1,
"monitored": false
"seasonNumber":1,
"monitored":false
},
{
"seasonNumber": 2,
"monitored": true
"seasonNumber":2,
"monitored":true
},
{
"seasonNumber": 3,
"monitored": false
"seasonNumber":3,
"monitored":false
},
{
"seasonNumber": 4,
"monitored": false
"seasonNumber":4,
"monitored":false
},
{
"seasonNumber": 5,
"monitored": true
"seasonNumber":5,
"monitored":true
},
{
"seasonNumber": 6,
"monitored": true
"seasonNumber":6,
"monitored":true
},
{
"seasonNumber": 7,
"monitored": true
"seasonNumber":7,
"monitored":true
},
{
"seasonNumber": 8,
"monitored": true
"seasonNumber":8,
"monitored":true
}
],
"year": 1960,
"path": "F:\\The Andy Griffith Show",
"profileId": 5,
"seasonFolder": true,
"monitored": true,
"useSceneNumbering": false,
"runtime": 25,
"tvdbId": 77754,
"tvRageId": 5574,
"tvMazeId": 3853,
"firstAired": "1960-02-15T06:00:00Z",
"lastInfoSync": "2016-02-05T16:40:11.614176Z",
"seriesType": "standard",
"cleanTitle": "theandygriffithshow",
"imdbId": "",
"titleSlug": "the-andy-griffith-show",
"certification": "TV-G",
"genres": [
"year":1960,
"path":"F:\\The Andy Griffith Show",
"profileId":5,
"seasonFolder":true,
"monitored":true,
"useSceneNumbering":false,
"runtime":25,
"tvdbId":77754,
"tvRageId":5574,
"tvMazeId":3853,
"firstAired":"1960-02-15T06:00:00Z",
"lastInfoSync":"2016-02-05T16:40:11.614176Z",
"seriesType":"standard",
"cleanTitle":"theandygriffithshow",
"imdbId":"",
"titleSlug":"the-andy-griffith-show",
"certification":"TV-G",
"genres":[
"Comedy"
],
"tags": [],
"added": "2008-02-04T13:44:24.204583Z",
"ratings": {
"votes": 547,
"value": 8.6
"tags":[
],
"added":"2008-02-04T13:44:24.204583Z",
"ratings":{
"votes":547,
"value":8.6
},
"qualityProfileId": 5,
"id": 17
"qualityProfileId":5,
"id":17
},
"episode": {
"seriesId": 17,
"episodeFileId": 0,
"seasonNumber": 1,
"episodeNumber": 1,
"title": "The New Housekeeper",
"airDate": "1960-10-03",
"airDateUtc": "1960-10-03T01:00:00Z",
"overview": "Sheriff Andy Taylor and his young son Opie are in need of a new housekeeper. Andy's Aunt Bee looks like the perfect candidate and moves in, but her presence causes friction with Opie.",
"hasFile": false,
"monitored": false,
"absoluteEpisodeNumber": 1,
"unverifiedSceneNumbering": false,
"id": 889
"episode":{
"seriesId":17,
"episodeFileId":0,
"seasonNumber":1,
"episodeNumber":1,
"title":"The New Housekeeper",
"airDate":"1960-10-03",
"airDateUtc":"1960-10-03T01:00:00Z",
"overview":"Sheriff Andy Taylor and his young son Opie are in need of a new housekeeper. Andy's Aunt Bee looks like the perfect candidate and moves in, but her presence causes friction with Opie.",
"hasFile":false,
"monitored":false,
"absoluteEpisodeNumber":1,
"unverifiedSceneNumbering":false,
"id":889
},
"quality": {
"quality": {
"id": 7,
"name": "SD"
"quality":{
"quality":{
"id":7,
"name":"SD"
},
"revision": {
"version": 1,
"real": 0
"revision":{
"version":1,
"real":0
}
},
"size": 4472186820,
"title": "The.Andy.Griffith.Show.S01E01.x264-GROUP",
"sizeleft": 0,
"timeleft": "00:00:00",
"estimatedCompletionTime": "2016-02-05T22:46:52.440104Z",
"status": "Downloading",
"trackedDownloadStatus": "Ok",
"statusMessages": [],
"downloadId": "SABnzbd_nzo_Mq2f_b",
"protocol": "usenet",
"id": 1503378561
"size":4472186820,
"title":"The.Andy.Griffith.Show.S01E01.x264-GROUP",
"sizeleft":0,
"timeleft":"00:00:00",
"estimatedCompletionTime":"2016-02-05T22:46:52.440104Z",
"status":"Downloading",
"trackedDownloadStatus":"Ok",
"statusMessages":[
],
"downloadId":"SABnzbd_nzo_Mq2f_b",
"protocol":"usenet",
"id":1503378561
}
]
]
}

View file

@ -3,11 +3,6 @@
"title": "The Andy Griffith Show",
"alternateTitles": [],
"sortTitle": "andy griffith show",
"seasonCount": 8,
"totalEpisodeCount": 253,
"episodeCount": 0,
"episodeFileCount": 0,
"sizeOnDisk": 0,
"status": "ended",
"overview": "Down-home humor and an endearing cast of characters helped make The Andy Griffith Show one of the most beloved comedies in the history of TV. The show centered around widower Andy Taylor, who divided his time between raising his young son Opie, and his job as sheriff of the sleepy North Carolina town, Mayberry. Andy and Opie live with Andy's Aunt Bee, who serves as a surrogate mother to both father and son. Andy's nervous cousin, Barney Fife, is his deputy sheriff whose incompetence is tolerated because Mayberry is virtually crime-free.",
"network": "CBS",
@ -158,6 +153,14 @@
"value": 8.6
},
"qualityProfileId": 2,
"statistics": {
"seasonCount": 8,
"episodeFileCount": 0,
"episodeCount": 0,
"totalEpisodeCount": 253,
"sizeOnDisk": 0,
"percentOfEpisodes": 0.0
},
"id": 105
}
]

View file

@ -0,0 +1,29 @@
{
"appName": "Sonarr",
"version": "3.0.6.1451",
"buildTime": "2022-01-23T16:51:56Z",
"isDebug": false,
"isProduction": true,
"isAdmin": false,
"isUserInteractive": false,
"startupPath": "/app/sonarr/bin",
"appData": "/config",
"osName": "ubuntu",
"osVersion": "20.04",
"isMonoRuntime": true,
"isMono": true,
"isLinux": true,
"isOsx": false,
"isWindows": false,
"mode": "console",
"branch": "develop",
"authentication": "forms",
"sqliteVersion": "3.31.1",
"urlBase": "",
"runtimeVersion": "6.12.0.122",
"runtimeName": "mono",
"startTime": "2022-02-01T22:10:11.956137Z",
"packageVersion": "3.0.6.1451-ls247",
"packageAuthor": "[linuxserver.io](https://linuxserver.io)",
"packageUpdateMechanism": "docker"
}

View file

@ -1,6 +1,6 @@
{
"page": 1,
"pageSize": 10,
"pageSize": 50,
"sortKey": "airDateUtc",
"sortDirection": "descending",
"totalRecords": 2,

View file

@ -1,7 +1,7 @@
"""Test the Sonarr config flow."""
from unittest.mock import MagicMock, patch
from sonarr import SonarrAccessRestricted, SonarrError
from aiopyarr import ArrAuthenticationException, ArrException
from homeassistant.components.sonarr.const import (
CONF_UPCOMING_DAYS,
@ -11,7 +11,7 @@ from homeassistant.components.sonarr.const import (
DOMAIN,
)
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_SOURCE, CONF_VERIFY_SSL
from homeassistant.const import CONF_API_KEY, CONF_SOURCE, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
@ -38,7 +38,7 @@ async def test_cannot_connect(
hass: HomeAssistant, mock_sonarr_config_flow: MagicMock
) -> None:
"""Test we show user form on connection error."""
mock_sonarr_config_flow.update.side_effect = SonarrError
mock_sonarr_config_flow.async_get_system_status.side_effect = ArrException
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
@ -56,7 +56,9 @@ async def test_invalid_auth(
hass: HomeAssistant, mock_sonarr_config_flow: MagicMock
) -> None:
"""Test we show user form on invalid auth."""
mock_sonarr_config_flow.update.side_effect = SonarrAccessRestricted
mock_sonarr_config_flow.async_get_system_status.side_effect = (
ArrAuthenticationException
)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
@ -74,7 +76,7 @@ async def test_unknown_error(
hass: HomeAssistant, mock_sonarr_config_flow: MagicMock
) -> None:
"""Test we show user form on unknown error."""
mock_sonarr_config_flow.update.side_effect = Exception
mock_sonarr_config_flow.async_get_system_status.side_effect = Exception
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
@ -153,7 +155,7 @@ async def test_full_user_flow_implementation(
assert result["title"] == "192.168.1.189"
assert result["data"]
assert result["data"][CONF_HOST] == "192.168.1.189"
assert result["data"][CONF_URL] == "http://192.168.1.189:8989"
async def test_full_user_flow_advanced_options(
@ -183,7 +185,7 @@ async def test_full_user_flow_advanced_options(
assert result["title"] == "192.168.1.189"
assert result["data"]
assert result["data"][CONF_HOST] == "192.168.1.189"
assert result["data"][CONF_URL] == "http://192.168.1.189:8989"
assert result["data"][CONF_VERIFY_SSL]

View file

@ -1,11 +1,19 @@
"""Tests for the Sonsrr integration."""
from unittest.mock import MagicMock, patch
from sonarr import SonarrAccessRestricted, SonarrError
from aiopyarr import ArrAuthenticationException, ArrException
from homeassistant.components.sonarr.const import DOMAIN
from homeassistant.components.sonarr.const import CONF_BASE_PATH, DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_SOURCE
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SOURCE,
CONF_SSL,
CONF_URL,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@ -17,7 +25,7 @@ async def test_config_entry_not_ready(
mock_sonarr: MagicMock,
) -> None:
"""Test the configuration entry not ready."""
mock_sonarr.update.side_effect = SonarrError
mock_sonarr.async_get_system_status.side_effect = ArrException
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@ -32,7 +40,7 @@ async def test_config_entry_reauth(
mock_sonarr: MagicMock,
) -> None:
"""Test the configuration entry needing to be re-authenticated."""
mock_sonarr.update.side_effect = SonarrAccessRestricted
mock_sonarr.async_get_system_status.side_effect = ArrAuthenticationException
with patch.object(hass.config_entries.flow, "async_init") as mock_flow_init:
mock_config_entry.add_to_hass(hass)
@ -77,3 +85,34 @@ async def test_unload_config_entry(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert mock_config_entry.entry_id not in hass.data[DOMAIN]
async def test_migrate_config_entry(hass: HomeAssistant):
"""Test successful migration of entry data."""
legacy_config = {
CONF_API_KEY: "MOCK_API_KEY",
CONF_HOST: "1.2.3.4",
CONF_PORT: 8989,
CONF_SSL: False,
CONF_VERIFY_SSL: False,
CONF_BASE_PATH: "/base/",
}
entry = MockConfigEntry(domain=DOMAIN, data=legacy_config)
assert entry.data == legacy_config
assert entry.version == 1
assert not entry.unique_id
await entry.async_migrate(hass)
assert entry.data == {
CONF_API_KEY: "MOCK_API_KEY",
CONF_HOST: "1.2.3.4",
CONF_PORT: 8989,
CONF_SSL: False,
CONF_VERIFY_SSL: False,
CONF_BASE_PATH: "/base/",
CONF_URL: "http://1.2.3.4:8989/base",
}
assert entry.version == 2
assert not entry.unique_id

View file

@ -2,8 +2,8 @@
from datetime import timedelta
from unittest.mock import MagicMock, patch
from aiopyarr import ArrException
import pytest
from sonarr import SonarrError
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.sonarr.const import DOMAIN
@ -96,8 +96,11 @@ async def test_sensors(
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:television"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes"
assert state.attributes.get("Bob's Burgers S04E11") == "2014-01-26"
assert state.attributes.get("The Andy Griffith Show S01E01") == "1960-10-03"
assert state.attributes.get("Bob's Burgers S04E11") == "2014-01-27T01:30:00+00:00"
assert (
state.attributes.get("The Andy Griffith Show S01E01")
== "1960-10-03T01:00:00+00:00"
)
assert state.state == "2"
@ -141,44 +144,49 @@ async def test_availability(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(UPCOMING_ENTITY_ID)
assert hass.states.get(UPCOMING_ENTITY_ID).state == "1"
# state to unavailable
mock_sonarr.calendar.side_effect = SonarrError
mock_sonarr.async_get_calendar.side_effect = ArrException
future = now + timedelta(minutes=1)
with patch("homeassistant.util.dt.utcnow", return_value=future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get(UPCOMING_ENTITY_ID)
assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE
# state to available
mock_sonarr.calendar.side_effect = None
mock_sonarr.async_get_calendar.side_effect = None
future += timedelta(minutes=1)
with patch("homeassistant.util.dt.utcnow", return_value=future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get(UPCOMING_ENTITY_ID)
assert hass.states.get(UPCOMING_ENTITY_ID).state == "1"
# state to unavailable
mock_sonarr.calendar.side_effect = SonarrError
mock_sonarr.async_get_calendar.side_effect = ArrException
future += timedelta(minutes=1)
with patch("homeassistant.util.dt.utcnow", return_value=future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get(UPCOMING_ENTITY_ID)
assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE
# state to available
mock_sonarr.calendar.side_effect = None
mock_sonarr.async_get_calendar.side_effect = None
future += timedelta(minutes=1)
with patch("homeassistant.util.dt.utcnow", return_value=future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get(UPCOMING_ENTITY_ID)
assert hass.states.get(UPCOMING_ENTITY_ID).state == "1"