Skip to content

Commit

Permalink
Push Overseerr updates via webhook (home-assistant#134187)
Browse files Browse the repository at this point in the history
  • Loading branch information
joostlek authored Jan 3, 2025
1 parent 0ef254b commit 23ed62c
Show file tree
Hide file tree
Showing 14 changed files with 375 additions and 9 deletions.
83 changes: 82 additions & 1 deletion homeassistant/components/overseerr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,23 @@

from __future__ import annotations

from homeassistant.const import Platform
import json

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

PLATFORMS: list[Platform] = [Platform.SENSOR]
Expand All @@ -19,6 +33,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) ->

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)

return True
Expand All @@ -27,3 +50,61 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> bool:
"""Unload a config entry."""
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])
11 changes: 10 additions & 1 deletion homeassistant/components/overseerr/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@
import voluptuous as vol
from yarl import URL

from homeassistant.components.webhook import async_generate_id
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 .const import DOMAIN
Expand Down Expand Up @@ -49,6 +57,7 @@ async def async_step_user(
CONF_PORT: port,
CONF_SSL: url.scheme == "https",
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_WEBHOOK_ID: async_generate_id(),
},
)
return self.async_show_form(
Expand Down
37 changes: 37 additions & 0 deletions homeassistant/components/overseerr/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,44 @@

import logging

from python_overseerr.models import NotificationType

DOMAIN = "overseerr"
LOGGER = logging.getLogger(__package__)

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}"'
)
3 changes: 2 additions & 1 deletion homeassistant/components/overseerr/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
"name": "Overseerr",
"codeowners": ["@joostlek"],
"config_flow": true,
"dependencies": ["http", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/overseerr",
"integration_type": "service",
"iot_class": "local_polling",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["python-overseerr==0.4.0"]
}
2 changes: 1 addition & 1 deletion homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -4587,7 +4587,7 @@
"name": "Overseerr",
"integration_type": "service",
"config_flow": true,
"iot_class": "local_polling"
"iot_class": "local_push"
},
"ovo_energy": {
"name": "OVO Energy",
Expand Down
26 changes: 26 additions & 0 deletions tests/components/overseerr/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
"""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 .const import WEBHOOK_ID

from tests.common import MockConfigEntry


Expand All @@ -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.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()
18 changes: 17 additions & 1 deletion tests/components/overseerr/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,18 @@

import pytest
from python_overseerr import RequestCount
from python_overseerr.models import WebhookNotificationConfig

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

Expand Down Expand Up @@ -39,6 +48,12 @@ def mock_overseerr_client() -> Generator[AsyncMock]:
client.get_request_count.return_value = RequestCount.from_json(
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


Expand All @@ -53,6 +68,7 @@ def mock_config_entry() -> MockConfigEntry:
CONF_PORT: 80,
CONF_SSL: False,
CONF_API_KEY: "test-key",
CONF_WEBHOOK_ID: WEBHOOK_ID,
},
entry_id="01JG00V55WEVTJ0CJHM0GAD7PC",
)
3 changes: 3 additions & 0 deletions tests/components/overseerr/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Overseerr tests."""

WEBHOOK_ID = "test-webhook-id"
8 changes: 8 additions & 0 deletions tests/components/overseerr/fixtures/webhook_config.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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": []
}
12 changes: 12 additions & 0 deletions tests/components/overseerr/fixtures/webhook_test_notification.json
Original file line number Diff line number Diff line change
@@ -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": []
}
25 changes: 23 additions & 2 deletions tests/components/overseerr/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
"""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 homeassistant.components.overseerr.const import DOMAIN
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.data_entry_flow import FlowResultType

from .const import WEBHOOK_ID

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(
hass: HomeAssistant,
mock_overseerr_client: AsyncMock,
Expand All @@ -37,6 +57,7 @@ async def test_full_flow(
CONF_PORT: 80,
CONF_SSL: False,
CONF_API_KEY: "test-key",
CONF_WEBHOOK_ID: "test-webhook-id",
}


Expand Down
Loading

0 comments on commit 23ed62c

Please sign in to comment.