Implement cloudhooks for Overseerr (#134680)

This commit is contained in:
Joost Lekkerkerker 2025-01-19 21:28:08 +01:00 committed by GitHub
parent 77221f53b3
commit f5d35bca72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 289 additions and 11 deletions

View File

@ -3,12 +3,14 @@
from __future__ import annotations
import json
from typing import cast
from aiohttp.hdrs import METH_POST
from aiohttp.web_request import Request
from aiohttp.web_response import Response
from python_overseerr import OverseerrConnectionError
from homeassistant.components import cloud
from homeassistant.components.webhook import (
async_generate_url,
async_register,
@ -26,6 +28,7 @@ from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
from .services import setup_services
PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR]
CONF_CLOUDHOOK_URL = "cloudhook_url"
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)
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:
"""Overseerr webhook manager."""
@ -86,6 +101,8 @@ class OverseerrWebhookManager:
for url in urls:
if url not in res:
res.append(url)
if CONF_CLOUDHOOK_URL in self.entry.data:
res.append(self.entry.data[CONF_CLOUDHOOK_URL])
return res
async def register_webhook(self) -> None:
@ -101,16 +118,18 @@ class OverseerrWebhookManager:
if not await self.check_need_change():
return
for url in self.webhook_urls:
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,
)
if await self.test_and_set_webhook(url):
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:
"""Check if webhook needs to be changed."""
@ -122,6 +141,19 @@ class OverseerrWebhookManager:
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(
self, hass: HomeAssistant, webhook_id: str, request: Request
) -> Response:
@ -136,3 +168,16 @@ class OverseerrWebhookManager:
async def unregister_webhook(self) -> None:
"""Unregister webhook."""
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])

View File

@ -1,6 +1,7 @@
{
"domain": "overseerr",
"name": "Overseerr",
"after_dependencies": ["cloud"],
"codeowners": ["@joostlek"],
"config_flow": true,
"dependencies": ["http", "webhook"],

View File

@ -7,6 +7,7 @@ import pytest
from python_overseerr import MovieDetails, RequestCount, RequestResponse
from python_overseerr.models import TVDetails, WebhookNotificationConfig
from homeassistant.components.overseerr import CONF_CLOUDHOOK_URL
from homeassistant.components.overseerr.const import DOMAIN
from homeassistant.const import (
CONF_API_KEY,
@ -66,6 +67,24 @@ def mock_overseerr_client() -> Generator[AsyncMock]:
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
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
@ -81,3 +100,21 @@ def mock_config_entry() -> MockConfigEntry:
},
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",
)

View File

@ -1,13 +1,18 @@
"""Tests for the Overseerr integration."""
from typing import Any
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
import pytest
from python_overseerr.models import WebhookNotificationOptions
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.core import HomeAssistant
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 tests.common import MockConfigEntry
from tests.components.cloud import mock_cloud
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]
== "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()