Add event entities to Overseerr (#134975)

This commit is contained in:
Joost Lekkerkerker 2025-01-09 12:48:09 +01:00 committed by GitHub
parent c4ac648a2b
commit d7315f4500
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 342 additions and 37 deletions

View File

@ -17,14 +17,15 @@ from homeassistant.components.webhook import (
from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.const import CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.http import HomeAssistantView from homeassistant.helpers.http import HomeAssistantView
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS from .const import DOMAIN, EVENT_KEY, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS
from .coordinator import OverseerrConfigEntry, OverseerrCoordinator from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
from .services import setup_services from .services import setup_services
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -129,6 +130,7 @@ class OverseerrWebhookManager:
LOGGER.debug("Received webhook payload: %s", data) LOGGER.debug("Received webhook payload: %s", data)
if data["notification_type"].startswith("MEDIA"): if data["notification_type"].startswith("MEDIA"):
await self.entry.runtime_data.async_refresh() await self.entry.runtime_data.async_refresh()
async_dispatcher_send(hass, EVENT_KEY, data)
return HomeAssistantView.json({"message": "ok"}) return HomeAssistantView.json({"message": "ok"})
async def unregister_webhook(self) -> None: async def unregister_webhook(self) -> None:

View File

@ -14,6 +14,8 @@ ATTR_STATUS = "status"
ATTR_SORT_ORDER = "sort_order" ATTR_SORT_ORDER = "sort_order"
ATTR_REQUESTED_BY = "requested_by" ATTR_REQUESTED_BY = "requested_by"
EVENT_KEY = f"{DOMAIN}_event"
REGISTERED_NOTIFICATIONS = ( REGISTERED_NOTIFICATIONS = (
NotificationType.REQUEST_PENDING_APPROVAL NotificationType.REQUEST_PENDING_APPROVAL
| NotificationType.REQUEST_APPROVED | NotificationType.REQUEST_APPROVED
@ -23,28 +25,24 @@ REGISTERED_NOTIFICATIONS = (
| NotificationType.REQUEST_AUTOMATICALLY_APPROVED | NotificationType.REQUEST_AUTOMATICALLY_APPROVED
) )
JSON_PAYLOAD = ( JSON_PAYLOAD = (
'"{\\"notification_type\\":\\"{{notification_type}}\\",\\"event\\":\\"' '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}'
'{{event}}\\",\\"subject\\":\\"{{subject}}\\",\\"message\\":\\"{{messa' '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":'
'ge}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":{\\"media_type\\"' '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_idd\\":\\"{{media_tmdbid}}\\",\\"t'
':\\"{{media_type}}\\",\\"tmdbId\\":\\"{{media_tmdbid}}\\",\\"tvdbId\\' 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k'
'":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"statu' '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id'
's4k\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":' '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna'
'\\"{{request_id}}\\",\\"requestedBy_email\\":\\"{{requestedBy_email}}' 'me\\":\\"{{requestedBy_username}}\\",\\"requested_by_avatar\\":\\"{{requestedBy_a'
'\\",\\"requestedBy_username\\":\\"{{requestedBy_username}}\\",\\"requ' 'vatar}}\\",\\"requested_by_settings_discord_id\\":\\"{{requestedBy_settings_disco'
'estedBy_avatar\\":\\"{{requestedBy_avatar}}\\",\\"requestedBy_setting' 'rdId}}\\",\\"requested_by_settings_telegram_chat_id\\":\\"{{requestedBy_settings_'
's_discordId\\":\\"{{requestedBy_settings_discordId}}\\",\\"requestedB' 'telegramChatId}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_'
'y_settings_telegramChatId\\":\\"{{requestedBy_settings_telegramChatId' 'type\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",\\"reporte'
'}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_ty' 'd_by_email\\":\\"{{reportedBy_email}}\\",\\"reported_by_username\\":\\"{{reported'
'pe\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",' 'By_username}}\\",\\"reported_by_avatar\\":\\"{{reportedBy_avatar}}\\",\\"reported'
'\\"reportedBy_email\\":\\"{{reportedBy_email}}\\",\\"reportedBy_usern' '_by_settings_discord_id\\":\\"{{reportedBy_settings_discordId}}\\",\\"reported_by'
'ame\\":\\"{{reportedBy_username}}\\",\\"reportedBy_avatar\\":\\"{{rep' '_settings_telegram_chat_id\\":\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{'
'ortedBy_avatar}}\\",\\"reportedBy_settings_discordId\\":\\"{{reported' 'comment}}\\":{\\"comment_message\\":\\"{{comment_message}}\\",\\"commented_by_ema'
'By_settings_discordId}}\\",\\"reportedBy_settings_telegramChatId\\":' 'il\\":\\"{{commentedBy_email}}\\",\\"commented_by_username\\":\\"{{commentedBy_us'
'\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{comment}}\\":{\\"c' 'ername}}\\",\\"commented_by_avatar\\":\\"{{commentedBy_avatar}}\\",\\"commented_b'
'omment_message\\":\\"{{comment_message}}\\",\\"commentedBy_email\\":' 'y_settings_discord_id\\":\\"{{commentedBy_settings_discordId}}\\",\\"commented_by'
'\\"{{commentedBy_email}}\\",\\"commentedBy_username\\":\\"{{commented' '_settings_telegram_chat_id\\":\\"{{commentedBy_settings_telegramChatId}}\\"}}"'
'By_username}}\\",\\"commentedBy_avatar\\":\\"{{commentedBy_avatar}}'
'\\",\\"commentedBy_settings_discordId\\":\\"{{commentedBy_settings_di'
'scordId}}\\",\\"commentedBy_settings_telegramChatId\\":\\"{{commented'
'By_settings_telegramChatId}}\\"},\\"{{extra}}\\":[]\\n}"'
) )

View File

@ -0,0 +1,99 @@
"""Support for Overseerr events."""
from dataclasses import dataclass
from typing import Any
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EVENT_KEY
from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
from .entity import OverseerrEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class OverseerrEventEntityDescription(EventEntityDescription):
"""Describes Overseerr config event entity."""
nullable_fields: list[str]
EVENTS: tuple[OverseerrEventEntityDescription, ...] = (
OverseerrEventEntityDescription(
key="media",
translation_key="last_media_event",
event_types=[
"pending",
"approved",
"available",
"failed",
"declined",
"auto_approved",
],
nullable_fields=["comment", "issue"],
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: OverseerrConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Overseerr sensor entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
OverseerrEvent(coordinator, description) for description in EVENTS
)
class OverseerrEvent(OverseerrEntity, EventEntity):
"""Defines a Overseerr event entity."""
def __init__(
self,
coordinator: OverseerrCoordinator,
description: OverseerrEventEntityDescription,
) -> None:
"""Initialize Overseerr event entity."""
super().__init__(coordinator, description.key)
self.entity_description = description
self._attr_available = True
async def async_added_to_hass(self) -> None:
"""Subscribe to updates."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(self.hass, EVENT_KEY, self._handle_update)
)
async def _handle_update(self, event: dict[str, Any]) -> None:
"""Handle incoming event."""
event_type = event["notification_type"].lower()
if event_type.split("_")[0] == self.entity_description.key:
self._trigger_event(event_type[6:], event)
self.async_write_ha_state()
@callback
def _handle_coordinator_update(self) -> None:
if super().available != self._attr_available:
self._attr_available = super().available
super()._handle_coordinator_update()
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._attr_available
def parse_event(event: dict[str, Any], nullable_fields: list[str]) -> dict[str, Any]:
"""Parse event."""
event.pop("notification_type")
for field in nullable_fields:
event.pop(field)
return event

View File

@ -21,6 +21,19 @@
} }
}, },
"entity": { "entity": {
"event": {
"last_media_event": {
"name": "Last media event",
"state": {
"pending": "Pending",
"approved": "Approved",
"available": "Available",
"failed": "Failed",
"declined": "Declined",
"auto_approved": "Auto-approved"
}
}
},
"sensor": { "sensor": {
"total_requests": { "total_requests": {
"name": "Total requests" "name": "Total requests"

View File

@ -2,7 +2,7 @@
"enabled": true, "enabled": true,
"types": 222, "types": 222,
"options": { "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}", "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_idd\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}",
"webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id" "webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id"
} }
} }

View File

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

View File

@ -0,0 +1,86 @@
# serializer version: 1
# name: test_entities[event.overseerr_last_media_event-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'event_types': list([
'pending',
'approved',
'available',
'failed',
'declined',
'auto_approved',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.overseerr_last_media_event',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Last media event',
'platform': 'overseerr',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'last_media_event',
'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-media',
'unit_of_measurement': None,
})
# ---
# name: test_entities[event.overseerr_last_media_event-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'comment': None,
'event_type': 'auto_approved',
'event_types': list([
'pending',
'approved',
'available',
'failed',
'declined',
'auto_approved',
]),
'friendly_name': 'Overseerr Last media event',
'image': 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg',
'issue': None,
'media': dict({
'media_type': 'movie',
'status': 'PENDING',
'status4k': 'UNKNOWN',
'tmdb_id': '123',
'tvdb_id': '',
}),
'message': 'Here is an interesting Linux ISO that was automatically approved.',
'notification_type': 'MEDIA_AUTO_APPROVED',
'request': dict({
'request_id': '16',
'requested_by_avatar': 'https://plex.tv/users/abc/avatar?c=123',
'requested_by_email': 'my@email.com',
'requested_by_settings_discord_id': '123',
'requested_by_settings_telegram_chat_id': '',
'requested_by_username': 'henk',
}),
'subject': 'Something (2024)',
}),
'context': <ANY>,
'entity_id': 'event.overseerr_last_media_event',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2023-10-21T00:00:00.000+00:00',
})
# ---

View File

@ -0,0 +1,109 @@
"""Tests for the Overseerr event platform."""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from future.backports.datetime import timedelta
import pytest
from python_overseerr import OverseerrConnectionError
from syrupy import SnapshotAssertion
from homeassistant.components.overseerr import DOMAIN
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import call_webhook, setup_integration
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
load_json_object_fixture,
snapshot_platform,
)
from tests.typing import ClientSessionGenerator
@pytest.mark.freeze_time("2023-10-21")
async def test_entities(
hass: HomeAssistant,
mock_overseerr_client: AsyncMock,
mock_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.overseerr.PLATFORMS", [Platform.EVENT]):
await setup_integration(hass, mock_config_entry)
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()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.freeze_time("2023-10-21")
async def test_event_does_not_write_state(
hass: HomeAssistant,
mock_overseerr_client: AsyncMock,
mock_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test event entities don't write state on coordinator update."""
await setup_integration(hass, mock_config_entry)
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(
"event.overseerr_last_media_event"
).last_reported == datetime(2023, 10, 21, 0, 0, 0, tzinfo=UTC)
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(
"event.overseerr_last_media_event"
).last_reported == datetime(2023, 10, 21, 0, 0, 0, tzinfo=UTC)
async def test_event_goes_unavailable(
hass: HomeAssistant,
mock_overseerr_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test event entities go unavailable when we can't fetch data."""
await setup_integration(hass, mock_config_entry)
assert (
hass.states.get("event.overseerr_last_media_event").state != STATE_UNAVAILABLE
)
mock_overseerr_client.get_request_count.side_effect = OverseerrConnectionError(
"Boom"
)
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get("event.overseerr_last_media_event").state == STATE_UNAVAILABLE
)