Push Overseerr updates via webhook (#134187)

pull/134516/head^2
Joost Lekkerkerker 2025-01-03 08:26:01 +01:00 committed by GitHub
parent 0ef254bc9a
commit 23ed62c1bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 376 additions and 10 deletions

View File

@ -2,9 +2,23 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.const import Platform import json
from homeassistant.core import HomeAssistant
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.webhook import (
async_generate_url,
async_register,
async_unregister,
)
from homeassistant.const import CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.http import HomeAssistantView
from .const import DOMAIN, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS
from .coordinator import OverseerrConfigEntry, OverseerrCoordinator from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR]
@ -19,6 +33,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) ->
entry.runtime_data = coordinator entry.runtime_data = coordinator
webhook_manager = OverseerrWebhookManager(hass, entry)
try:
await webhook_manager.register_webhook()
except OverseerrConnectionError:
LOGGER.error("Failed to register Overseerr webhook")
entry.async_on_unload(webhook_manager.unregister_webhook)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
@ -27,3 +50,61 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
class OverseerrWebhookManager:
"""Overseerr webhook manager."""
def __init__(self, hass: HomeAssistant, entry: OverseerrConfigEntry) -> None:
"""Initialize Overseerr webhook manager."""
self.hass = hass
self.entry = entry
self.client = entry.runtime_data.client
async def register_webhook(self) -> None:
"""Register webhook."""
async_register(
self.hass,
DOMAIN,
self.entry.title,
self.entry.data[CONF_WEBHOOK_ID],
self.handle_webhook,
allowed_methods=[METH_POST],
)
url = async_generate_url(self.hass, self.entry.data[CONF_WEBHOOK_ID])
if not await self.check_need_change(url):
return
LOGGER.debug("Setting Overseerr webhook to %s", url)
if not await self.client.test_webhook_notification_config(url, JSON_PAYLOAD):
LOGGER.debug("Failed to set Overseerr webhook")
return
await self.client.set_webhook_notification_config(
enabled=True,
types=REGISTERED_NOTIFICATIONS,
webhook_url=url,
json_payload=JSON_PAYLOAD,
)
async def check_need_change(self, url: str) -> bool:
"""Check if webhook needs to be changed."""
current_config = await self.client.get_webhook_notification_config()
return (
not current_config.enabled
or current_config.options.webhook_url != url
or current_config.options.json_payload != json.loads(JSON_PAYLOAD)
or current_config.types != REGISTERED_NOTIFICATIONS
)
async def handle_webhook(
self, hass: HomeAssistant, webhook_id: str, request: Request
) -> Response:
"""Handle webhook."""
data = await request.json()
LOGGER.debug("Received webhook payload: %s", data)
if data["notification_type"].startswith("MEDIA"):
await self.entry.runtime_data.async_refresh()
return HomeAssistantView.json({"message": "ok"})
async def unregister_webhook(self) -> None:
"""Unregister webhook."""
async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])

View File

@ -7,8 +7,16 @@ from python_overseerr.exceptions import OverseerrError
import voluptuous as vol import voluptuous as vol
from yarl import URL from yarl import URL
from homeassistant.components.webhook import async_generate_id
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL, CONF_URL from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_URL,
CONF_WEBHOOK_ID,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
@ -49,6 +57,7 @@ class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PORT: port, CONF_PORT: port,
CONF_SSL: url.scheme == "https", CONF_SSL: url.scheme == "https",
CONF_API_KEY: user_input[CONF_API_KEY], CONF_API_KEY: user_input[CONF_API_KEY],
CONF_WEBHOOK_ID: async_generate_id(),
}, },
) )
return self.async_show_form( return self.async_show_form(

View File

@ -2,7 +2,44 @@
import logging import logging
from python_overseerr.models import NotificationType
DOMAIN = "overseerr" DOMAIN = "overseerr"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
REQUESTS = "requests" REQUESTS = "requests"
REGISTERED_NOTIFICATIONS = (
NotificationType.REQUEST_PENDING_APPROVAL
| NotificationType.REQUEST_APPROVED
| NotificationType.REQUEST_DECLINED
| NotificationType.REQUEST_AVAILABLE
| NotificationType.REQUEST_PROCESSING_FAILED
| NotificationType.REQUEST_AUTOMATICALLY_APPROVED
)
JSON_PAYLOAD = (
'"{\\"notification_type\\":\\"{{notification_type}}\\",\\"event\\":\\"'
'{{event}}\\",\\"subject\\":\\"{{subject}}\\",\\"message\\":\\"{{messa'
'ge}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":{\\"media_type\\"'
':\\"{{media_type}}\\",\\"tmdbId\\":\\"{{media_tmdbid}}\\",\\"tvdbId\\'
'":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"statu'
's4k\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":'
'\\"{{request_id}}\\",\\"requestedBy_email\\":\\"{{requestedBy_email}}'
'\\",\\"requestedBy_username\\":\\"{{requestedBy_username}}\\",\\"requ'
'estedBy_avatar\\":\\"{{requestedBy_avatar}}\\",\\"requestedBy_setting'
's_discordId\\":\\"{{requestedBy_settings_discordId}}\\",\\"requestedB'
'y_settings_telegramChatId\\":\\"{{requestedBy_settings_telegramChatId'
'}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_ty'
'pe\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",'
'\\"reportedBy_email\\":\\"{{reportedBy_email}}\\",\\"reportedBy_usern'
'ame\\":\\"{{reportedBy_username}}\\",\\"reportedBy_avatar\\":\\"{{rep'
'ortedBy_avatar}}\\",\\"reportedBy_settings_discordId\\":\\"{{reported'
'By_settings_discordId}}\\",\\"reportedBy_settings_telegramChatId\\":'
'\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{comment}}\\":{\\"c'
'omment_message\\":\\"{{comment_message}}\\",\\"commentedBy_email\\":'
'\\"{{commentedBy_email}}\\",\\"commentedBy_username\\":\\"{{commented'
'By_username}}\\",\\"commentedBy_avatar\\":\\"{{commentedBy_avatar}}'
'\\",\\"commentedBy_settings_discordId\\":\\"{{commentedBy_settings_di'
'scordId}}\\",\\"commentedBy_settings_telegramChatId\\":\\"{{commented'
'By_settings_telegramChatId}}\\"},\\"{{extra}}\\":[]\\n}"'
)

View File

@ -3,9 +3,10 @@
"name": "Overseerr", "name": "Overseerr",
"codeowners": ["@joostlek"], "codeowners": ["@joostlek"],
"config_flow": true, "config_flow": true,
"dependencies": ["http", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/overseerr", "documentation": "https://www.home-assistant.io/integrations/overseerr",
"integration_type": "service", "integration_type": "service",
"iot_class": "local_polling", "iot_class": "local_push",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["python-overseerr==0.4.0"] "requirements": ["python-overseerr==0.4.0"]
} }

View File

@ -4587,7 +4587,7 @@
"name": "Overseerr", "name": "Overseerr",
"integration_type": "service", "integration_type": "service",
"config_flow": true, "config_flow": true,
"iot_class": "local_polling" "iot_class": "local_push"
}, },
"ovo_energy": { "ovo_energy": {
"name": "OVO Energy", "name": "OVO Energy",

View File

@ -1,7 +1,15 @@
"""Tests for the Overseerr integration.""" """Tests for the Overseerr integration."""
from typing import Any
from urllib.parse import urlparse
from aiohttp.test_utils import TestClient
from homeassistant.components.webhook import async_generate_url
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import WEBHOOK_ID
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -11,3 +19,21 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
async def call_webhook(
hass: HomeAssistant, data: dict[str, Any], client: TestClient
) -> None:
"""Call the webhook."""
webhook_url = async_generate_url(hass, WEBHOOK_ID)
resp = await client.post(
urlparse(webhook_url).path,
json=data,
)
# Wait for remaining tasks to complete.
await hass.async_block_till_done()
data = await resp.json()
resp.close()

View File

@ -5,9 +5,18 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from python_overseerr import RequestCount from python_overseerr import RequestCount
from python_overseerr.models import WebhookNotificationConfig
from homeassistant.components.overseerr.const import DOMAIN from homeassistant.components.overseerr.const import DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_WEBHOOK_ID,
)
from .const import WEBHOOK_ID
from tests.common import MockConfigEntry, load_fixture from tests.common import MockConfigEntry, load_fixture
@ -39,6 +48,12 @@ def mock_overseerr_client() -> Generator[AsyncMock]:
client.get_request_count.return_value = RequestCount.from_json( client.get_request_count.return_value = RequestCount.from_json(
load_fixture("request_count.json", DOMAIN) load_fixture("request_count.json", DOMAIN)
) )
client.get_webhook_notification_config.return_value = (
WebhookNotificationConfig.from_json(
load_fixture("webhook_config.json", DOMAIN)
)
)
client.test_webhook_notification_config.return_value = True
yield client yield client
@ -53,6 +68,7 @@ def mock_config_entry() -> MockConfigEntry:
CONF_PORT: 80, CONF_PORT: 80,
CONF_SSL: False, CONF_SSL: False,
CONF_API_KEY: "test-key", CONF_API_KEY: "test-key",
CONF_WEBHOOK_ID: WEBHOOK_ID,
}, },
entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", entry_id="01JG00V55WEVTJ0CJHM0GAD7PC",
) )

View File

@ -0,0 +1,3 @@
"""Constants for the Overseerr tests."""
WEBHOOK_ID = "test-webhook-id"

View File

@ -0,0 +1,8 @@
{
"enabled": true,
"types": 222,
"options": {
"jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"event\":\"{{event}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdbId\":\"{{media_tmdbid}}\",\"tvdbId\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requestedBy_email\":\"{{requestedBy_email}}\",\"requestedBy_username\":\"{{requestedBy_username}}\",\"requestedBy_avatar\":\"{{requestedBy_avatar}}\",\"requestedBy_settings_discordId\":\"{{requestedBy_settings_discordId}}\",\"requestedBy_settings_telegramChatId\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reportedBy_email\":\"{{reportedBy_email}}\",\"reportedBy_username\":\"{{reportedBy_username}}\",\"reportedBy_avatar\":\"{{reportedBy_avatar}}\",\"reportedBy_settings_discordId\":\"{{reportedBy_settings_discordId}}\",\"reportedBy_settings_telegramChatId\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commentedBy_email\":\"{{commentedBy_email}}\",\"commentedBy_username\":\"{{commentedBy_username}}\",\"commentedBy_avatar\":\"{{commentedBy_avatar}}\",\"commentedBy_settings_discordId\":\"{{commentedBy_settings_discordId}}\",\"commentedBy_settings_telegramChatId\":\"{{commentedBy_settings_telegramChatId}}\"},\"{{extra}}\":[]\n}",
"webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id"
}
}

View File

@ -0,0 +1,25 @@
{
"notification_type": "MEDIA_AUTO_APPROVED",
"event": "Movie Request Automatically Approved",
"subject": "Something (2024)",
"message": "Here is an interesting Linux ISO that was automatically approved.",
"image": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg",
"media": {
"media_type": "movie",
"tmdbId": "123",
"tvdbId": "",
"status": "PENDING",
"status4k": "UNKNOWN"
},
"request": {
"request_id": "16",
"requestedBy_email": "my@email.com",
"requestedBy_username": "henk",
"requestedBy_avatar": "https://plex.tv/users/abc/avatar?c=123",
"requestedBy_settings_discordId": "123",
"requestedBy_settings_telegramChatId": ""
},
"issue": null,
"comment": null,
"extra": []
}

View File

@ -0,0 +1,12 @@
{
"notification_type": "TEST_NOTIFICATION",
"event": "",
"subject": "Test Notification",
"message": "Check check, 1, 2, 3. Are we coming in clear?",
"image": "",
"media": null,
"request": null,
"issue": null,
"comment": null,
"extra": []
}

View File

@ -1,18 +1,38 @@
"""Tests for the Overseerr config flow.""" """Tests for the Overseerr config flow."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, patch
import pytest
from python_overseerr.exceptions import OverseerrConnectionError from python_overseerr.exceptions import OverseerrConnectionError
from homeassistant.components.overseerr.const import DOMAIN from homeassistant.components.overseerr.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL, CONF_URL from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_URL,
CONF_WEBHOOK_ID,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from .const import WEBHOOK_ID
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def patch_webhook_id() -> None:
"""Patch webhook ID generation."""
with patch(
"homeassistant.components.overseerr.config_flow.async_generate_id",
return_value=WEBHOOK_ID,
):
yield
async def test_full_flow( async def test_full_flow(
hass: HomeAssistant, hass: HomeAssistant,
mock_overseerr_client: AsyncMock, mock_overseerr_client: AsyncMock,
@ -37,6 +57,7 @@ async def test_full_flow(
CONF_PORT: 80, CONF_PORT: 80,
CONF_SSL: False, CONF_SSL: False,
CONF_API_KEY: "test-key", CONF_API_KEY: "test-key",
CONF_WEBHOOK_ID: "test-webhook-id",
} }

View File

@ -1,9 +1,13 @@
"""Tests for the Overseerr integration.""" """Tests for the Overseerr integration."""
from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest
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.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
@ -27,3 +31,100 @@ async def test_device_info(
) )
assert device_entry is not None assert device_entry is not None
assert device_entry == snapshot assert device_entry == snapshot
async def test_proper_webhook_configuration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_overseerr_client: AsyncMock,
) -> None:
"""Test the webhook configuration."""
await setup_integration(hass, mock_config_entry)
assert REGISTERED_NOTIFICATIONS == 222
mock_overseerr_client.test_webhook_notification_config.assert_not_called()
mock_overseerr_client.set_webhook_notification_config.assert_not_called()
@pytest.mark.parametrize(
"update_mock",
[
{"return_value.enabled": False},
{"return_value.types": 4},
{"return_value.types": 4062},
{
"return_value.options": WebhookNotificationOptions(
webhook_url="http://example.com", json_payload=JSON_PAYLOAD
)
},
{
"return_value.options": WebhookNotificationOptions(
webhook_url="http://10.10.10.10:8123/api/webhook/test-webhook-id",
json_payload='"{\\"message\\": \\"{{title}}\\"}"',
)
},
],
ids=[
"Disabled",
"Smaller scope",
"Bigger scope",
"Webhook URL",
"JSON Payload",
],
)
async def test_webhook_configuration_need_update(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_overseerr_client: AsyncMock,
update_mock: dict[str, Any],
) -> None:
"""Test the webhook configuration."""
mock_overseerr_client.get_webhook_notification_config.configure_mock(**update_mock)
await setup_integration(hass, mock_config_entry)
mock_overseerr_client.test_webhook_notification_config.assert_called_once()
mock_overseerr_client.set_webhook_notification_config.assert_called_once()
@pytest.mark.parametrize(
"update_mock",
[
{"return_value.enabled": False},
{"return_value.types": 4},
{"return_value.types": 4062},
{
"return_value.options": WebhookNotificationOptions(
webhook_url="http://example.com", json_payload=JSON_PAYLOAD
)
},
{
"return_value.options": WebhookNotificationOptions(
webhook_url="http://10.10.10.10:8123/api/webhook/test-webhook-id",
json_payload='"{\\"message\\": \\"{{title}}\\"}"',
)
},
],
ids=[
"Disabled",
"Smaller scope",
"Bigger scope",
"Webhook URL",
"JSON Payload",
],
)
async def test_webhook_failing_test(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_overseerr_client: AsyncMock,
update_mock: dict[str, Any],
) -> None:
"""Test the webhook configuration."""
mock_overseerr_client.test_webhook_notification_config.return_value = False
mock_overseerr_client.get_webhook_notification_config.configure_mock(**update_mock)
await setup_integration(hass, mock_config_entry)
mock_overseerr_client.test_webhook_notification_config.assert_called_once()
mock_overseerr_client.set_webhook_notification_config.assert_not_called()

View File

@ -4,13 +4,15 @@ from unittest.mock import AsyncMock, patch
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.overseerr import DOMAIN
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from . import setup_integration from . import call_webhook, setup_integration
from tests.common import MockConfigEntry, snapshot_platform from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform
from tests.typing import ClientSessionGenerator
async def test_all_entities( async def test_all_entities(
@ -25,3 +27,27 @@ async def test_all_entities(
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_webhook_trigger_update(
hass: HomeAssistant,
mock_overseerr_client: AsyncMock,
mock_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test all entities."""
await setup_integration(hass, mock_config_entry)
assert hass.states.get("sensor.overseerr_available_requests").state == "8"
mock_overseerr_client.get_request_count.return_value.available = 7
client = await hass_client_no_auth()
await call_webhook(
hass,
load_json_object_fixture("webhook_request_automatically_approved.json", DOMAIN),
client,
)
await hass.async_block_till_done()
assert hass.states.get("sensor.overseerr_available_requests").state == "7"