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 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])

View File

@ -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"],

View File

@ -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",
)

View File

@ -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()