mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 17:57:11 +00:00
Implement cloudhooks for Overseerr (#134680)
This commit is contained in:
parent
77221f53b3
commit
f5d35bca72
@ -3,12 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from aiohttp.hdrs import METH_POST
|
from aiohttp.hdrs import METH_POST
|
||||||
from aiohttp.web_request import Request
|
from aiohttp.web_request import Request
|
||||||
from aiohttp.web_response import Response
|
from aiohttp.web_response import Response
|
||||||
from python_overseerr import OverseerrConnectionError
|
from python_overseerr import OverseerrConnectionError
|
||||||
|
|
||||||
|
from homeassistant.components import cloud
|
||||||
from homeassistant.components.webhook import (
|
from homeassistant.components.webhook import (
|
||||||
async_generate_url,
|
async_generate_url,
|
||||||
async_register,
|
async_register,
|
||||||
@ -26,6 +28,7 @@ from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
|
|||||||
from .services import setup_services
|
from .services import setup_services
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR]
|
PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR]
|
||||||
|
CONF_CLOUDHOOK_URL = "cloudhook_url"
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
@ -64,6 +67,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -
|
|||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_remove_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> None:
|
||||||
|
"""Cleanup when entry is removed."""
|
||||||
|
if cloud.async_active_subscription(hass):
|
||||||
|
try:
|
||||||
|
LOGGER.debug(
|
||||||
|
"Removing Overseerr cloudhook (%s)", entry.data[CONF_WEBHOOK_ID]
|
||||||
|
)
|
||||||
|
await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])
|
||||||
|
except cloud.CloudNotAvailable:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class OverseerrWebhookManager:
|
class OverseerrWebhookManager:
|
||||||
"""Overseerr webhook manager."""
|
"""Overseerr webhook manager."""
|
||||||
|
|
||||||
@ -86,6 +101,8 @@ class OverseerrWebhookManager:
|
|||||||
for url in urls:
|
for url in urls:
|
||||||
if url not in res:
|
if url not in res:
|
||||||
res.append(url)
|
res.append(url)
|
||||||
|
if CONF_CLOUDHOOK_URL in self.entry.data:
|
||||||
|
res.append(self.entry.data[CONF_CLOUDHOOK_URL])
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def register_webhook(self) -> None:
|
async def register_webhook(self) -> None:
|
||||||
@ -101,16 +118,18 @@ class OverseerrWebhookManager:
|
|||||||
if not await self.check_need_change():
|
if not await self.check_need_change():
|
||||||
return
|
return
|
||||||
for url in self.webhook_urls:
|
for url in self.webhook_urls:
|
||||||
if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD):
|
if await self.test_and_set_webhook(url):
|
||||||
LOGGER.debug("Setting Overseerr webhook to %s", url)
|
|
||||||
await self.client.set_webhook_notification_config(
|
|
||||||
enabled=True,
|
|
||||||
types=REGISTERED_NOTIFICATIONS,
|
|
||||||
webhook_url=url,
|
|
||||||
json_payload=JSON_PAYLOAD,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
LOGGER.error("Failed to set Overseerr webhook")
|
LOGGER.info("Failed to register Overseerr webhook")
|
||||||
|
if (
|
||||||
|
cloud.async_active_subscription(self.hass)
|
||||||
|
and CONF_CLOUDHOOK_URL not in self.entry.data
|
||||||
|
):
|
||||||
|
LOGGER.info("Trying to register a cloudhook URL")
|
||||||
|
url = await _async_cloudhook_generate_url(self.hass, self.entry)
|
||||||
|
if await self.test_and_set_webhook(url):
|
||||||
|
return
|
||||||
|
LOGGER.error("Failed to register Overseerr cloudhook")
|
||||||
|
|
||||||
async def check_need_change(self) -> bool:
|
async def check_need_change(self) -> bool:
|
||||||
"""Check if webhook needs to be changed."""
|
"""Check if webhook needs to be changed."""
|
||||||
@ -122,6 +141,19 @@ class OverseerrWebhookManager:
|
|||||||
or current_config.types != REGISTERED_NOTIFICATIONS
|
or current_config.types != REGISTERED_NOTIFICATIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_and_set_webhook(self, url: str) -> bool:
|
||||||
|
"""Test and set webhook."""
|
||||||
|
if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD):
|
||||||
|
LOGGER.debug("Setting Overseerr webhook to %s", url)
|
||||||
|
await self.client.set_webhook_notification_config(
|
||||||
|
enabled=True,
|
||||||
|
types=REGISTERED_NOTIFICATIONS,
|
||||||
|
webhook_url=url,
|
||||||
|
json_payload=JSON_PAYLOAD,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
async def handle_webhook(
|
async def handle_webhook(
|
||||||
self, hass: HomeAssistant, webhook_id: str, request: Request
|
self, hass: HomeAssistant, webhook_id: str, request: Request
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@ -136,3 +168,16 @@ class OverseerrWebhookManager:
|
|||||||
async def unregister_webhook(self) -> None:
|
async def unregister_webhook(self) -> None:
|
||||||
"""Unregister webhook."""
|
"""Unregister webhook."""
|
||||||
async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])
|
async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_cloudhook_generate_url(
|
||||||
|
hass: HomeAssistant, entry: OverseerrConfigEntry
|
||||||
|
) -> str:
|
||||||
|
"""Generate the full URL for a webhook_id."""
|
||||||
|
if CONF_CLOUDHOOK_URL not in entry.data:
|
||||||
|
webhook_id = entry.data[CONF_WEBHOOK_ID]
|
||||||
|
webhook_url = await cloud.async_create_cloudhook(hass, webhook_id)
|
||||||
|
data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url}
|
||||||
|
hass.config_entries.async_update_entry(entry, data=data)
|
||||||
|
return webhook_url
|
||||||
|
return cast(str, entry.data[CONF_CLOUDHOOK_URL])
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "overseerr",
|
"domain": "overseerr",
|
||||||
"name": "Overseerr",
|
"name": "Overseerr",
|
||||||
|
"after_dependencies": ["cloud"],
|
||||||
"codeowners": ["@joostlek"],
|
"codeowners": ["@joostlek"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["http", "webhook"],
|
"dependencies": ["http", "webhook"],
|
||||||
|
@ -7,6 +7,7 @@ import pytest
|
|||||||
from python_overseerr import MovieDetails, RequestCount, RequestResponse
|
from python_overseerr import MovieDetails, RequestCount, RequestResponse
|
||||||
from python_overseerr.models import TVDetails, WebhookNotificationConfig
|
from python_overseerr.models import TVDetails, WebhookNotificationConfig
|
||||||
|
|
||||||
|
from homeassistant.components.overseerr import CONF_CLOUDHOOK_URL
|
||||||
from homeassistant.components.overseerr.const import DOMAIN
|
from homeassistant.components.overseerr.const import DOMAIN
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_API_KEY,
|
CONF_API_KEY,
|
||||||
@ -66,6 +67,24 @@ def mock_overseerr_client() -> Generator[AsyncMock]:
|
|||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_overseerr_client_needs_change(
|
||||||
|
mock_overseerr_client: AsyncMock,
|
||||||
|
) -> Generator[AsyncMock]:
|
||||||
|
"""Mock an Overseerr client."""
|
||||||
|
mock_overseerr_client.get_webhook_notification_config.return_value.types = 0
|
||||||
|
return mock_overseerr_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_overseerr_client_cloudhook(
|
||||||
|
mock_overseerr_client: AsyncMock,
|
||||||
|
) -> Generator[AsyncMock]:
|
||||||
|
"""Mock an Overseerr client."""
|
||||||
|
mock_overseerr_client.get_webhook_notification_config.return_value.options.webhook_url = "https://hooks.nabu.casa/ABCD"
|
||||||
|
return mock_overseerr_client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_config_entry() -> MockConfigEntry:
|
def mock_config_entry() -> MockConfigEntry:
|
||||||
"""Mock a config entry."""
|
"""Mock a config entry."""
|
||||||
@ -81,3 +100,21 @@ def mock_config_entry() -> MockConfigEntry:
|
|||||||
},
|
},
|
||||||
entry_id="01JG00V55WEVTJ0CJHM0GAD7PC",
|
entry_id="01JG00V55WEVTJ0CJHM0GAD7PC",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_cloudhook_config_entry() -> MockConfigEntry:
|
||||||
|
"""Mock a config entry."""
|
||||||
|
return MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Overseerr",
|
||||||
|
data={
|
||||||
|
CONF_HOST: "overseerr.test",
|
||||||
|
CONF_PORT: 80,
|
||||||
|
CONF_SSL: False,
|
||||||
|
CONF_API_KEY: "test-key",
|
||||||
|
CONF_WEBHOOK_ID: WEBHOOK_ID,
|
||||||
|
CONF_CLOUDHOOK_URL: "https://hooks.nabu.casa/ABCD",
|
||||||
|
},
|
||||||
|
entry_id="01JG00V55WEVTJ0CJHM0GAD7PC",
|
||||||
|
)
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
"""Tests for the Overseerr integration."""
|
"""Tests for the Overseerr integration."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from python_overseerr.models import WebhookNotificationOptions
|
from python_overseerr.models import WebhookNotificationOptions
|
||||||
from syrupy import SnapshotAssertion
|
from syrupy import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.components.overseerr import JSON_PAYLOAD, REGISTERED_NOTIFICATIONS
|
from homeassistant.components import cloud
|
||||||
|
from homeassistant.components.overseerr import (
|
||||||
|
CONF_CLOUDHOOK_URL,
|
||||||
|
JSON_PAYLOAD,
|
||||||
|
REGISTERED_NOTIFICATIONS,
|
||||||
|
)
|
||||||
from homeassistant.components.overseerr.const import DOMAIN
|
from homeassistant.components.overseerr.const import DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
@ -15,6 +20,7 @@ from homeassistant.helpers import device_registry as dr
|
|||||||
from . import setup_integration
|
from . import setup_integration
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
from tests.components.cloud import mock_cloud
|
||||||
|
|
||||||
|
|
||||||
async def test_device_info(
|
async def test_device_info(
|
||||||
@ -150,3 +156,192 @@ async def test_prefer_internal_ip(
|
|||||||
mock_overseerr_client.test_webhook_notification_config.call_args_list[1][0][0]
|
mock_overseerr_client.test_webhook_notification_config.call_args_list[1][0][0]
|
||||||
== "https://www.example.com/api/webhook/test-webhook-id"
|
== "https://www.example.com/api/webhook/test-webhook-id"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloudhook_setup(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_overseerr_client_needs_change: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test if set up with active cloud subscription and cloud hook."""
|
||||||
|
|
||||||
|
await mock_cloud(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("homeassistant.components.cloud.async_is_logged_in", return_value=True),
|
||||||
|
patch("homeassistant.components.cloud.async_is_connected", return_value=True),
|
||||||
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.cloud.async_create_cloudhook",
|
||||||
|
return_value="https://hooks.nabu.casa/ABCD",
|
||||||
|
) as fake_create_cloudhook,
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.cloud.async_delete_cloudhook"
|
||||||
|
) as fake_delete_cloudhook,
|
||||||
|
):
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
assert cloud.async_active_subscription(hass) is True
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_config_entry.data[CONF_CLOUDHOOK_URL] == "https://hooks.nabu.casa/ABCD"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
len(
|
||||||
|
mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls
|
||||||
|
)
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert hass.config_entries.async_entries(DOMAIN)
|
||||||
|
fake_create_cloudhook.assert_called()
|
||||||
|
|
||||||
|
for config_entry in hass.config_entries.async_entries(DOMAIN):
|
||||||
|
await hass.config_entries.async_remove(config_entry.entry_id)
|
||||||
|
fake_delete_cloudhook.assert_called_once()
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert not hass.config_entries.async_entries(DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloudhook_consistent(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_cloudhook_config_entry: MockConfigEntry,
|
||||||
|
mock_overseerr_client_needs_change: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test if we keep the cloudhook if it is already set up."""
|
||||||
|
|
||||||
|
await mock_cloud(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("homeassistant.components.cloud.async_is_logged_in", return_value=True),
|
||||||
|
patch("homeassistant.components.cloud.async_is_connected", return_value=True),
|
||||||
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.cloud.async_create_cloudhook",
|
||||||
|
return_value="https://hooks.nabu.casa/ABCD",
|
||||||
|
) as fake_create_cloudhook,
|
||||||
|
):
|
||||||
|
await setup_integration(hass, mock_cloudhook_config_entry)
|
||||||
|
|
||||||
|
assert cloud.async_active_subscription(hass) is True
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL]
|
||||||
|
== "https://hooks.nabu.casa/ABCD"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
len(
|
||||||
|
mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls
|
||||||
|
)
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert hass.config_entries.async_entries(DOMAIN)
|
||||||
|
fake_create_cloudhook.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloudhook_needs_no_change(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_cloudhook_config_entry: MockConfigEntry,
|
||||||
|
mock_overseerr_client_cloudhook: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test if we keep the cloudhook if it is already set up."""
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_cloudhook_config_entry)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
len(mock_overseerr_client_cloudhook.test_webhook_notification_config.mock_calls)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloudhook_not_needed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_overseerr_client_needs_change: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test if we prefer local webhook over cloudhook."""
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
||||||
|
):
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
assert cloud.async_active_subscription(hass) is True
|
||||||
|
|
||||||
|
assert CONF_CLOUDHOOK_URL not in mock_config_entry.data
|
||||||
|
|
||||||
|
assert (
|
||||||
|
len(
|
||||||
|
mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
mock_overseerr_client_needs_change.test_webhook_notification_config.call_args_list[
|
||||||
|
0
|
||||||
|
][0][0]
|
||||||
|
== "http://10.10.10.10:8123/api/webhook/test-webhook-id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloudhook_not_connecting(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_cloudhook_config_entry: MockConfigEntry,
|
||||||
|
mock_overseerr_client_needs_change: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test the cloudhook is not registered if Overseerr cannot connect to it."""
|
||||||
|
|
||||||
|
await mock_cloud(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = (
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("homeassistant.components.cloud.async_is_logged_in", return_value=True),
|
||||||
|
patch("homeassistant.components.cloud.async_is_connected", return_value=True),
|
||||||
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.cloud.async_create_cloudhook",
|
||||||
|
return_value="https://hooks.nabu.casa/ABCD",
|
||||||
|
) as fake_create_cloudhook,
|
||||||
|
):
|
||||||
|
await setup_integration(hass, mock_cloudhook_config_entry)
|
||||||
|
|
||||||
|
assert cloud.async_active_subscription(hass) is True
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL]
|
||||||
|
== "https://hooks.nabu.casa/ABCD"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
len(
|
||||||
|
mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls
|
||||||
|
)
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_overseerr_client_needs_change.set_webhook_notification_config.assert_not_called()
|
||||||
|
|
||||||
|
assert hass.config_entries.async_entries(DOMAIN)
|
||||||
|
fake_create_cloudhook.assert_not_called()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user