diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index c16b02739ed..704bf99c147 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -17,14 +17,15 @@ from homeassistant.components.webhook import ( from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant 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.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 .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) @@ -129,6 +130,7 @@ class OverseerrWebhookManager: LOGGER.debug("Received webhook payload: %s", data) if data["notification_type"].startswith("MEDIA"): await self.entry.runtime_data.async_refresh() + async_dispatcher_send(hass, EVENT_KEY, data) return HomeAssistantView.json({"message": "ok"}) async def unregister_webhook(self) -> None: diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 48f5436c336..5c33ca3fcec 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -14,6 +14,8 @@ ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" ATTR_REQUESTED_BY = "requested_by" +EVENT_KEY = f"{DOMAIN}_event" + REGISTERED_NOTIFICATIONS = ( NotificationType.REQUEST_PENDING_APPROVAL | NotificationType.REQUEST_APPROVED @@ -23,28 +25,24 @@ REGISTERED_NOTIFICATIONS = ( | 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}"' + '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}' + '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":' + '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_idd\\":\\"{{media_tmdbid}}\\",\\"t' + 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k' + '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id' + '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna' + 'me\\":\\"{{requestedBy_username}}\\",\\"requested_by_avatar\\":\\"{{requestedBy_a' + 'vatar}}\\",\\"requested_by_settings_discord_id\\":\\"{{requestedBy_settings_disco' + 'rdId}}\\",\\"requested_by_settings_telegram_chat_id\\":\\"{{requestedBy_settings_' + 'telegramChatId}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_' + 'type\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",\\"reporte' + 'd_by_email\\":\\"{{reportedBy_email}}\\",\\"reported_by_username\\":\\"{{reported' + 'By_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_ema' + 'il\\":\\"{{commentedBy_email}}\\",\\"commented_by_username\\":\\"{{commentedBy_us' + 'ername}}\\",\\"commented_by_avatar\\":\\"{{commentedBy_avatar}}\\",\\"commented_b' + 'y_settings_discord_id\\":\\"{{commentedBy_settings_discordId}}\\",\\"commented_by' + '_settings_telegram_chat_id\\":\\"{{commentedBy_settings_telegramChatId}}\\"}}"' ) diff --git a/homeassistant/components/overseerr/event.py b/homeassistant/components/overseerr/event.py new file mode 100644 index 00000000000..b1b2efd6ec5 --- /dev/null +++ b/homeassistant/components/overseerr/event.py @@ -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 diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 338c9d91a38..c68963247ee 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -21,6 +21,19 @@ } }, "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": { "total_requests": { "name": "Total requests" diff --git a/tests/components/overseerr/fixtures/webhook_config.json b/tests/components/overseerr/fixtures/webhook_config.json index a4d07d6e9d3..40028e1f80f 100644 --- a/tests/components/overseerr/fixtures/webhook_config.json +++ b/tests/components/overseerr/fixtures/webhook_config.json @@ -2,7 +2,7 @@ "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}", + "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" } } diff --git a/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json index cc8795c9821..75059bcaf96 100644 --- a/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json +++ b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json @@ -1,25 +1,23 @@ { "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": "", + "tmdb_id": "123", + "tvdb_id": "", "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": "" + "requested_by_email": "my@email.com", + "requested_by_username": "henk", + "requested_by_avatar": "https://plex.tv/users/abc/avatar?c=123", + "requested_by_settings_discord_id": "123", + "requested_by_settings_telegram_chat_id": "" }, "issue": null, - "comment": null, - "extra": [] + "comment": null } diff --git a/tests/components/overseerr/snapshots/test_event.ambr b/tests/components/overseerr/snapshots/test_event.ambr new file mode 100644 index 00000000000..9bf23efb8f6 --- /dev/null +++ b/tests/components/overseerr/snapshots/test_event.ambr @@ -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': , + 'device_class': None, + 'device_id': , + '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': , + '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': , + 'entity_id': 'event.overseerr_last_media_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- diff --git a/tests/components/overseerr/test_event.py b/tests/components/overseerr/test_event.py new file mode 100644 index 00000000000..7ad6b53c7ed --- /dev/null +++ b/tests/components/overseerr/test_event.py @@ -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 + )