diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ac8cf..6996443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 _Changes in the next release_ +### Added +- Media browsing and searching features to media-player entity. + +### Breaking Changes +- Renamed `MediaType` to `MediaContentType` and changed enums to lowercase. See media-player entity documentation for more information. +- Changed `str, Enum` to new Python 3.11 `StrEnum` class. + --- ## v0.5.2 - 2026-01-30 diff --git a/tests/test_media_player.py b/tests/test_media_player.py new file mode 100644 index 0000000..71f1e3a --- /dev/null +++ b/tests/test_media_player.py @@ -0,0 +1,159 @@ +""" +Tests for media player entity. +""" + +import unittest +from ucapi.media_player import SearchMediaFilter, MediaClass, BrowseMediaItem + + +class TestMediaPlayer(unittest.TestCase): + """Media player tests.""" + + def test_search_media_filter_media_classes(self): + """Test SearchMediaFilter media_classes with standard and custom values.""" + # Test with standard MediaClass + smf = SearchMediaFilter(media_classes=[MediaClass.ALBUM, MediaClass.TRACK]) + self.assertEqual(smf.media_classes, [MediaClass.ALBUM, MediaClass.TRACK]) + + # Test with strings that match MediaClass + smf = SearchMediaFilter(media_classes=["album", "track"]) + self.assertEqual(smf.media_classes, [MediaClass.ALBUM, MediaClass.TRACK]) + + # Test with custom string values + try: + smf = SearchMediaFilter(media_classes=["custom_class", "another_one"]) + self.assertEqual(smf.media_classes, ["custom_class", "another_one"]) + except ValueError as e: + self.fail( + f"SearchMediaFilter raised ValueError for custom media classes: {e}" + ) + + def test_search_media_filter_mixed_classes(self): + """Test SearchMediaFilter with a mix of MediaClass and custom strings.""" + try: + smf = SearchMediaFilter(media_classes=[MediaClass.ALBUM, "custom_class"]) + self.assertEqual(smf.media_classes, [MediaClass.ALBUM, "custom_class"]) + except ValueError as e: + self.fail( + f"SearchMediaFilter raised ValueError for mixed media classes: {e}" + ) + + def test_search_media_filter_none(self): + """Test SearchMediaFilter with None media_classes.""" + smf = SearchMediaFilter(media_classes=None) + self.assertIsNone(smf.media_classes) + + def test_search_media_filter_from_dict(self): + """Test SearchMediaFilter.from_dict with custom values.""" + data = { + "media_classes": ["album", "custom_class"], + "artist": "Some Artist", + "album": "Some Album", + } + try: + smf = SearchMediaFilter.from_dict(data) + self.assertEqual(smf.media_classes, [MediaClass.ALBUM, "custom_class"]) + self.assertEqual(smf.artist, "Some Artist") + self.assertEqual(smf.album, "Some Album") + except ValueError as e: + self.fail( + f"SearchMediaFilter.from_dict raised ValueError for custom media classes: {e}" + ) + + def test_browse_media_item_validation_mandatory(self): + """Test BrowseMediaItem mandatory field validation.""" + # Valid mandatory fields + item = BrowseMediaItem(media_id="id1", title="Title") + self.assertEqual(item.media_id, "id1") + self.assertEqual(item.title, "Title") + + # media_id empty + with self.assertRaisesRegex( + ValueError, "media_id must be at least 1 characters" + ): + BrowseMediaItem(media_id="", title="Title") + + # media_id too long + with self.assertRaisesRegex( + ValueError, "media_id must be at most 255 characters" + ): + BrowseMediaItem(media_id="a" * 256, title="Title") + + # media_id wrong type + with self.assertRaisesRegex(TypeError, "media_id must be str, got int"): + BrowseMediaItem(media_id=123, title="Title") + + # title empty + with self.assertRaisesRegex(ValueError, "title must be at least 1 characters"): + BrowseMediaItem(media_id="id1", title="") + + # title too long + with self.assertRaisesRegex(ValueError, "title must be at most 255 characters"): + BrowseMediaItem(media_id="id1", title="a" * 256) + + def test_browse_media_item_validation_optional(self): + """Test BrowseMediaItem optional field validation.""" + # subtitle + with self.assertRaisesRegex( + ValueError, "subtitle must be at least 1 characters" + ): + BrowseMediaItem(media_id="id1", title="Title", subtitle="") + with self.assertRaisesRegex( + ValueError, "subtitle must be at most 255 characters" + ): + BrowseMediaItem(media_id="id1", title="Title", subtitle="a" * 256) + + # artist + with self.assertRaisesRegex(ValueError, "artist must be at least 1 characters"): + BrowseMediaItem(media_id="id1", title="Title", artist="") + with self.assertRaisesRegex( + ValueError, "artist must be at most 255 characters" + ): + BrowseMediaItem(media_id="id1", title="Title", artist="a" * 256) + + # album + with self.assertRaisesRegex(ValueError, "album must be at least 1 characters"): + BrowseMediaItem(media_id="id1", title="Title", album="") + with self.assertRaisesRegex(ValueError, "album must be at most 255 characters"): + BrowseMediaItem(media_id="id1", title="Title", album="a" * 256) + + # media_class (only when it's a string) + # Note: media class is allowed to be empty! + BrowseMediaItem(media_id="id1", title="Title", media_class="") + + with self.assertRaisesRegex( + ValueError, "media_class must be at most 255 characters" + ): + BrowseMediaItem(media_id="id1", title="Title", media_class="a" * 256) + # Verify it accepts MediaClass enum without error + BrowseMediaItem(media_id="id1", title="Title", media_class=MediaClass.ALBUM) + + # media_type (only when it's a string) + # Note: media type is allowed to be empty! + BrowseMediaItem(media_id="id1", title="Title", media_type="") + + with self.assertRaisesRegex( + ValueError, "media_type must be at most 255 characters" + ): + BrowseMediaItem(media_id="id1", title="Title", media_type="a" * 256) + + def test_browse_media_item_validation_thumbnail(self): + """Test BrowseMediaItem thumbnail field validation.""" + # Valid length + BrowseMediaItem(media_id="id1", title="Title", thumbnail="a" * 32768) + + # Too short (empty) + with self.assertRaisesRegex( + ValueError, "thumbnail must be at least 1 characters" + ): + BrowseMediaItem(media_id="id1", title="Title", thumbnail="") + + # Too long + with self.assertRaisesRegex( + ValueError, "thumbnail must be at most 32768 characters" + ): + BrowseMediaItem(media_id="id1", title="Title", thumbnail="a" * 32769) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_paging.py b/tests/test_paging.py new file mode 100644 index 0000000..3a16f12 --- /dev/null +++ b/tests/test_paging.py @@ -0,0 +1,131 @@ +"""Unit tests for Paging and Pagination classes.""" + +import json +import unittest +from dataclasses import asdict + +from ucapi.api_definitions import Pagination, Paging + + +class TestPaging(unittest.TestCase): + """Test cases for the Paging class.""" + + def test_paging_default(self): + """Test default Paging instantiation.""" + paging = Paging() + self.assertEqual(paging.page, 1) + self.assertEqual(paging.limit, 10) + self.assertEqual(paging.offset, 0) + + def test_paging_custom(self): + """Test custom Paging instantiation.""" + paging = Paging(page=2, limit=20) + self.assertEqual(paging.page, 2) + self.assertEqual(paging.limit, 20) + self.assertEqual(paging.offset, 20) + + def test_paging_invalid_page(self): + """Test validation for invalid page number.""" + with self.assertRaises(ValueError) as cm: + Paging(page=0) + self.assertIn("Invalid page: 0", str(cm.exception)) + + with self.assertRaises(ValueError): + Paging(page=-1) + + def test_paging_invalid_limit(self): + """Test validation for invalid limit.""" + with self.assertRaises(ValueError) as cm: + Paging(limit=0) + self.assertIn("Invalid limit: 0", str(cm.exception)) + + with self.assertRaises(ValueError): + Paging(limit=-1) + with self.assertRaises(ValueError): + Paging(limit=10000) + + def test_paging_from_dict(self): + """Test constructing Paging from a dictionary.""" + data = {"page": 3, "limit": 50} + paging = Paging.from_dict(data) + self.assertEqual(paging.page, 3) + self.assertEqual(paging.limit, 50) + + def test_paging_from_dict_defaults(self): + """Test constructing Paging from an empty dictionary using defaults.""" + paging = Paging.from_dict({}) + self.assertEqual(paging.page, 1) + self.assertEqual(paging.limit, 10) + + def test_paging_serialization(self): + """Test Paging JSON serialization.""" + paging = Paging(page=2, limit=25) + serialized = asdict(paging) + self.assertEqual(serialized, {"page": 2, "limit": 25}) + + # Verify JSON round-trip + json_str = json.dumps(serialized) + self.assertEqual(json.loads(json_str), {"page": 2, "limit": 25}) + + +class TestPagination(unittest.TestCase): + """Test cases for the Pagination class.""" + + def test_pagination_instantiation(self): + """Test Pagination instantiation with all fields.""" + pagination = Pagination(page=1, limit=10, count=100) + self.assertEqual(pagination.page, 1) + self.assertEqual(pagination.limit, 10) + self.assertEqual(pagination.count, 100) + + def test_pagination_no_count(self): + """Test Pagination instantiation without count.""" + pagination = Pagination(page=2, limit=20) + self.assertEqual(pagination.page, 2) + self.assertEqual(pagination.limit, 20) + self.assertIsNone(pagination.count) + + def test_pagination_invalid_page(self): + """Test validation for invalid page number.""" + with self.assertRaises(ValueError) as cm: + Pagination(page=0, limit=10) + self.assertIn("page must be >= 1", str(cm.exception)) + + def test_pagination_invalid_limit(self): + """Test validation for invalid limit.""" + with self.assertRaises(ValueError) as cm: + Pagination(page=1, limit=-1) + self.assertIn("Invalid limit", str(cm.exception)) + + def test_pagination_limit_out_of_range(self): + """Test validation for invalid limit.""" + with self.assertRaises(ValueError) as cm: + Pagination(page=1, limit=10000) + self.assertIn("Invalid limit", str(cm.exception)) + + def test_pagination_invalid_count(self): + """Test validation for invalid count.""" + with self.assertRaises(ValueError) as cm: + Pagination(page=1, limit=10, count=-1) + self.assertIn("count cannot be negative", str(cm.exception)) + + def test_pagination_serialization(self): + """Test Pagination JSON serialization.""" + pagination = Pagination(page=1, limit=10, count=100) + serialized = asdict(pagination) + self.assertEqual(serialized, {"page": 1, "limit": 10, "count": 100}) + + def test_pagination_serialization_no_count(self): + """Test Pagination JSON serialization when count is None.""" + pagination = Pagination(page=2, limit=20) + json_data = asdict(pagination) + + # Note: In JS/TS, undefined keys are omitted during JSON.stringify. + # In Python, None becomes `null` in JSON. + # This is not an issue: the Remote core service treats `null` as "not existing". + self.assertIn("count", json_data) + self.assertIsNone(json_data["count"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ucapi/__init__.py b/ucapi/__init__.py index 62bc3a9..60c5389 100644 --- a/ucapi/__init__.py +++ b/ucapi/__init__.py @@ -19,6 +19,8 @@ DriverSetupRequest, Events, IntegrationSetupError, + Paging, + Pagination, RequestUserConfirmation, RequestUserInput, SetupAction, @@ -42,7 +44,20 @@ from .climate import Climate # noqa: F401 from .cover import Cover # noqa: F401 from .light import Light # noqa: F401 -from .media_player import MediaPlayer # noqa: F401 +from .media_player import ( # noqa: F401 + BrowseMediaItem, + BrowseOptions, + BrowseResults, + MediaClass, + MediaContentType, + MediaPlayAction, + MediaPlayer, + RepeatMode, + SearchMediaFilter, + SearchMediaItem, + SearchOptions, + SearchResults, +) from .remote import Remote # noqa: F401 from .select import Select # noqa: F401 from .sensor import Sensor # noqa: F401 diff --git a/ucapi/api.py b/ucapi/api.py index 52db748..0204db4 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -29,9 +29,16 @@ from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf from . import api_definitions as uc +from .api_definitions import WsMsgEvents from .entities import Entities from .entity import EntityTypes from .media_player import Attributes as MediaAttr +from .media_player import ( + BrowseResults, + MediaPlayer, + SearchResults, +) +from .msg_definitions import BrowseMediaMsgData, SearchMediaMsgData # Classes are dynamically created at runtime using the Google Protobuf builder pattern. # pylint: disable=no-name-in-module @@ -676,6 +683,7 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: ctx.session.end(VoiceEndReason.TIMEOUT) await self._cleanup_voice_session(key) + # pylint: disable=R0912 async def _handle_ws_request_msg( self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None ) -> None: @@ -711,6 +719,10 @@ async def _handle_ws_request_msg( ) elif msg == uc.WsMessages.ENTITY_COMMAND: await self._entity_command(websocket, req_id, msg_data) + elif msg == uc.WsMessages.BROWSE_MEDIA: + await self._browse_media(websocket, req_id, msg_data) + elif msg == uc.WsMessages.SEARCH_MEDIA: + await self._search_media(websocket, req_id, msg_data) elif msg == uc.WsMessages.SUBSCRIBE_EVENTS: await self._subscribe_events(websocket, msg_data) await self._send_ok_result(websocket, req_id) @@ -922,6 +934,124 @@ async def _entity_command( await self.acknowledge_command(websocket, req_id, result) + async def _browse_media( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: + if not msg_data: + _LOG.warning("Ignoring browse_media command: called with empty msg_data") + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) + return + + entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None + if entity_id is None: + _LOG.warning("Ignoring browse_media command: missing entity_id") + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) + return + + entity = self.configured_entities.get(entity_id) + if entity is None or not isinstance(entity, MediaPlayer): + _LOG.warning( + "Cannot browse media for '%s': no configured entity found or entity is not a media-player", + entity_id, + ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) + return + + # extract request and validate + try: + data = BrowseMediaMsgData(**msg_data) + except (TypeError, ValueError): + _LOG.error( + "Cannot browse media for '%s': wrong format %s", entity_id, msg_data + ) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) + return + + # call integration driver to handle browse request + try: + result = await entity.browse(data) + except Exception: # pylint: disable=W0718 + _LOG.exception("Failed to call MediaPlayer.browse for '%s'", entity_id) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.SERVER_ERROR + ) + return + + if isinstance(result, BrowseResults): + await self._send_ws_response( + websocket, + req_id, + WsMsgEvents.MEDIA_BROWSE, + asdict(result), + uc.StatusCodes.OK, + ) + else: + await self.acknowledge_command(websocket, req_id, result) + + async def _search_media( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: + if not msg_data: + _LOG.warning("Ignoring search_media command: called with empty msg_data") + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) + return + + entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None + if entity_id is None: + _LOG.warning("Ignoring search_media command: missing entity_id") + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) + return + + entity = self.configured_entities.get(entity_id) + if entity is None or not isinstance(entity, MediaPlayer): + _LOG.warning( + "Cannot search media for '%s': no configured entity found or entity is not a media-player", + entity_id, + ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) + return + + try: + data = SearchMediaMsgData(**msg_data) + except (TypeError, ValueError): + _LOG.error( + "Cannot search media for '%s': wrong format %s", entity_id, msg_data + ) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) + return + + try: + result = await entity.search(data) + except Exception: # pylint: disable=W0718 + _LOG.exception("Failed to call MediaPlayer.search for '%s'", entity_id) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.SERVER_ERROR + ) + return + + if isinstance(result, SearchResults): + await self._send_ws_response( + websocket, + req_id, + WsMsgEvents.MEDIA_SEARCH, + asdict(result), + uc.StatusCodes.OK, + ) + else: + await self.acknowledge_command(websocket, req_id, result) + async def _setup_driver( self, websocket, req_id: int, msg_data: dict[str, Any] | None ) -> bool: diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index ad6cc77..b1b45b2 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -59,6 +59,8 @@ class WsMessages(str, Enum): GET_DRIVER_METADATA = "get_driver_metadata" SETUP_DRIVER = "setup_driver" SET_DRIVER_USER_DATA = "set_driver_user_data" + BROWSE_MEDIA = "browse_media" + SEARCH_MEDIA = "search_media" # Does WsMsgEvents need to be public? @@ -78,6 +80,8 @@ class WsMsgEvents(str, Enum): DRIVER_SETUP_CHANGE = "driver_setup_change" ABORT_DRIVER_SETUP = "abort_driver_setup" ASSISTANT_EVENT = "assistant_event" + MEDIA_BROWSE = "media_browse" + MEDIA_SEARCH = "media_search" class Events(str, Enum): @@ -354,3 +358,74 @@ class AssistantEvent: entity_id: str session_id: int data: AssistantEventData | None = None + + +@dataclass(frozen=True) +class Paging: + """ + Paging options. + + Attributes: + page (int): + Page number, 1-based. + limit (int): + Number of items returned per page. + """ + + page: int = 1 + limit: int = 10 + + DEFAULT_PAGE = 1 + DEFAULT_LIMIT = 10 + + def __post_init__(self): + """Validate fields.""" + if self.page < 1: + raise ValueError(f"Invalid page: {self.page}. Must be >= 1.") + if not 1 <= self.limit <= 1000: + raise ValueError( + f"Invalid limit: {self.limit}. Must be between 1 and 1000." + ) + + @property + def offset(self) -> int: + """Calculate 0-based start offset.""" + return self.limit * (self.page - 1) + + @classmethod + def from_dict(cls, data: dict) -> "Paging": + """Construct from a raw dictionary (e.g., from JSON).""" + return cls( + page=data.get("page", cls.DEFAULT_PAGE), + limit=data.get("limit", cls.DEFAULT_LIMIT), + ) + + +@dataclass(frozen=True) +class Pagination: + """ + Pagination metadata returned by the client. + + Attributes: + page (int): + Current page number, 1-based. Must correspond to the requested page. + limit (int): + Number of items returned in this page. + count (int|None): + Optional if known: Total number of available items across all pages. + """ + + page: int + limit: int + count: int | None = None + + def __post_init__(self): + """Validate fields.""" + if self.page < 1: + raise ValueError("page must be >= 1") + if not 0 <= self.limit <= 1000: + raise ValueError( + f"Invalid limit: {self.limit}. Must be between 0 and 1000." + ) + if self.count is not None and self.count < 0: + raise ValueError("count cannot be negative") diff --git a/ucapi/entity.py b/ucapi/entity.py index 3b0466b..c996a80 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -16,6 +16,24 @@ _LOG.setLevel(logging.DEBUG) +def validate_str(name: str, value: str, min_len: int = 1, max_len: int = 255) -> None: + """ + Validate that a string is not empty and within length limits. + + :param name: Field name for error messages. + :param value: The string to validate. + :param min_len: Minimal length of the string. + :param max_len: Maximal length of the string. + """ + if not isinstance(value, str): + raise TypeError(f"{name} must be str, got {type(value).__name__}") + length = len(value) + if length < min_len: + raise ValueError(f"{name} must be at least {min_len} characters") + if length > max_len: + raise ValueError(f"{name} must be at most {max_len} characters") + + class EntityTypes(str, Enum): """Entity types.""" diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 046d0e0..24661ed 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -1,186 +1,644 @@ """ Media-player entity definitions. +See https://unfoldedcircle.github.io/core-api/entities/entity_media_player.html +for the media-player entity documentation. + :copyright: (c) 2023 by Unfolded Circle ApS. :license: MPL-2.0, see LICENSE for more details. """ -from enum import Enum +import logging +from dataclasses import dataclass, field +from enum import StrEnum from typing import Any -from .api_definitions import CommandHandler -from .entity import Entity, EntityTypes +from .api_definitions import ( + CommandHandler, + Pagination, + Paging, + StatusCodes, +) +from .entity import Entity, EntityTypes, validate_str + +_LOG = logging.getLogger(__name__) -class States(str, Enum): +class States(StrEnum): """Media-player entity states.""" UNAVAILABLE = "UNAVAILABLE" + """The entity is currently not available. The UI will render the entity as inactive until the entity becomes active again.""" UNKNOWN = "UNKNOWN" + """The entity is available but the current state is unknown.""" ON = "ON" + """The media player is switched on""" OFF = "OFF" + """The media player is switched off""" PLAYING = "PLAYING" + """The media player is playing something""" PAUSED = "PAUSED" + """The media player is paused""" STANDBY = "STANDBY" + """The device is in low power state and accepting commands""" BUFFERING = "BUFFERING" + """The media player is buffering to start playback""" -class Features(str, Enum): +class Features(StrEnum): """Media-player entity features.""" ON_OFF = "on_off" + """The media player can be switched on and off.""" TOGGLE = "toggle" + """The media player's power state can be toggled.""" VOLUME = "volume" + """The volume level can be set to a specific level.""" VOLUME_UP_DOWN = "volume_up_down" + """The volume can be adjusted up (louder) and down.""" MUTE_TOGGLE = "mute_toggle" + """The mute state can be toggled.""" MUTE = "mute" + """The volume can be muted.""" UNMUTE = "unmute" + """The volume can be un-muted.""" PLAY_PAUSE = "play_pause" + """The player supports starting and pausing media playback.""" STOP = "stop" + """The player supports stopping media playback.""" NEXT = "next" + """The player supports skipping to the next track.""" PREVIOUS = "previous" + """The player supports returning to the previous track.""" FAST_FORWARD = "fast_forward" + """The player supports fast-forwarding the current track.""" REWIND = "rewind" + """The player supports rewinding the current track.""" REPEAT = "repeat" + """The current track or playlist can be repeated.""" SHUFFLE = "shuffle" + """The player supports random playback / shuffling the current playlist.""" SEEK = "seek" + """The player supports seeking the playback position.""" MEDIA_DURATION = "media_duration" + """The player announces the duration of the current media being played.""" MEDIA_POSITION = "media_position" + """The player announces the current position of the media being played.""" MEDIA_TITLE = "media_title" + """The player announces the media title.""" MEDIA_ARTIST = "media_artist" + """The player announces the media artist.""" MEDIA_ALBUM = "media_album" + """The player announces the media album if music is being played.""" MEDIA_IMAGE_URL = "media_image_url" + """The player provides an image url of the media being played.""" MEDIA_TYPE = "media_type" + """The player announces the content type of media being played.""" DPAD = "dpad" + """Directional pad navigation provides cursor_up, _down, _left, _right, _enter commands.""" NUMPAD = "numpad" + """Number pad, provides digit_0 .. digit_9 commands.""" HOME = "home" + """Home navigation support with home and back commands.""" MENU = "menu" + """Menu navigation support with menu and back commands.""" CONTEXT_MENU = "context_menu" + """Context menu (for example, right-clicking or long pressing an item).""" GUIDE = "guide" + """Program guide support with guide and back commands.""" INFO = "info" + """Information popup / menu support with info and back commands.""" COLOR_BUTTONS = "color_buttons" + """Color button support for function_red, _green, _yellow, _blue commands.""" CHANNEL_SWITCHER = "channel_switcher" + """Channel zapping support with channel_up and _down commands.""" SELECT_SOURCE = "select_source" + """Media playback sources or inputs can be selected.""" SELECT_SOUND_MODE = "select_sound_mode" + """Sound modes can be selected, e.g., stereo or surround.""" EJECT = "eject" + """The media can be ejected, e.g., a slot-in CD or USB stick.""" OPEN_CLOSE = "open_close" + """The player supports opening and closing, e.g., a disc tray.""" AUDIO_TRACK = "audio_track" + """The player supports selecting or switching the audio track.""" SUBTITLE = "subtitle" + """The player supports selecting or switching subtitles.""" RECORD = "record" + """The player has recording capabilities with record, my_recordings, live commands.""" SETTINGS = "settings" - - -class Attributes(str, Enum): + """The player supports a settings menu.""" + PLAY_MEDIA = "play_media" + """The player supports playing a specific media item.""" + PLAY_MEDIA_ACTION = "play_media_action" + """The player supports the play_media action parameter to either play or enqueue.""" + CLEAR_PLAYLIST = "clear_playlist" + """The player allows clearing the active playlist.""" + BROWSE_MEDIA = "browse_media" + """The player supports browsing media containers.""" + SEARCH_MEDIA = "search_media" + """The player supports searching for media items.""" + SEARCH_MEDIA_CLASSES = "search_media_classes" + """The player provides a list of media classes as filter for searches.""" + + +class Attributes(StrEnum): """Media-player entity attributes.""" STATE = "state" + """State of the media player, influenced by the play and power commands.""" VOLUME = "volume" + """Current volume level.""" MUTED = "muted" + """Flag if the volume is muted.""" MEDIA_DURATION = "media_duration" + """Media duration in seconds.""" MEDIA_POSITION = "media_position" + """Current media position in seconds.""" MEDIA_POSITION_UPDATED_AT = "media_position_updated_at" + """Optional timestamp when `media_position` was last updated.""" MEDIA_TYPE = "media_type" + """The content type of media being played. Either a ``MediaContentType`` or a custom value.""" MEDIA_IMAGE_URL = "media_image_url" + """URL to retrieve the album art or an image representing what's being played.""" MEDIA_TITLE = "media_title" + """Currently playing media information.""" MEDIA_ARTIST = "media_artist" + """Currently playing media information.""" MEDIA_ALBUM = "media_album" + """Currently playing media information.""" REPEAT = "repeat" + """Current repeat mode.""" SHUFFLE = "shuffle" + """Shuffle mode on or off.""" SOURCE = "source" + """Currently selected media or input source.""" SOURCE_LIST = "source_list" + """Available media or input sources.""" SOUND_MODE = "sound_mode" + """Currently selected sound mode.""" SOUND_MODE_LIST = "sound_mode_list" + """Available sound modes.""" + MEDIA_ID = "media_id" + """The content ID of media being played.""" + MEDIA_PLAYLIST = "media_playlist" + """Title of Playlist currently playing.""" + PLAY_MEDIA_ACTION = "play_media_action" + """List of supported media play actions in ``MediaPlayAction``.""" + SEARCH_MEDIA_CLASSES = "search_media_classes" + """List of ``MediaClass`` values to use as a filter for ``search_media``. + + Custom classes should be avoided. + """ -class Commands(str, Enum): +class Commands(StrEnum): """Media-player entity commands.""" ON = "on" + """Switch on media player.""" OFF = "off" + """Switch off media player.""" TOGGLE = "toggle" + """Toggle the current power state, either from on -> off or from off -> on.""" PLAY_PAUSE = "play_pause" + """Toggle play / pause.""" STOP = "stop" + """Stop playback.""" PREVIOUS = "previous" + """Go back to previous track.""" NEXT = "next" + """Skip to next track.""" FAST_FORWARD = "fast_forward" + """Fast forward current track.""" REWIND = "rewind" + """Rewind current track.""" SEEK = "seek" + """Seek to given position in current track. Position is given in seconds.""" VOLUME = "volume" + """Set volume to given level.""" VOLUME_UP = "volume_up" + """Increase volume.""" VOLUME_DOWN = "volume_down" + """Decrease volume.""" MUTE_TOGGLE = "mute_toggle" + """Toggle mute state.""" MUTE = "mute" + """Mute volume.""" UNMUTE = "unmute" + """Unmute volume.""" REPEAT = "repeat" + """Repeat track or playlist.""" SHUFFLE = "shuffle" + """Shuffle playlist or start random playback.""" CHANNEL_UP = "channel_up" + """Channel up.""" CHANNEL_DOWN = "channel_down" + """Channel down.""" CURSOR_UP = "cursor_up" + """Directional pad up""" CURSOR_DOWN = "cursor_down" + """Directional pad down""" CURSOR_LEFT = "cursor_left" + """Directional pad left""" CURSOR_RIGHT = "cursor_right" + """Directional pad right""" CURSOR_ENTER = "cursor_enter" + """Directional pad enter""" DIGIT_0 = "digit_0" + """Number pad digit 0.""" DIGIT_1 = "digit_1" + """Number pad digit 1.""" DIGIT_2 = "digit_2" + """Number pad digit 2.""" DIGIT_3 = "digit_3" + """Number pad digit 3.""" DIGIT_4 = "digit_4" + """Number pad digit 4.""" DIGIT_5 = "digit_5" + """Number pad digit 5.""" DIGIT_6 = "digit_6" + """Number pad digit 6.""" DIGIT_7 = "digit_7" + """Number pad digit 7.""" DIGIT_8 = "digit_8" + """Number pad digit 8.""" DIGIT_9 = "digit_9" + """Number pad digit 9.""" FUNCTION_RED = "function_red" + """Function red.""" FUNCTION_GREEN = "function_green" + """Function green.""" FUNCTION_YELLOW = "function_yellow" + """Function yellow.""" FUNCTION_BLUE = "function_blue" + """Function blue.""" HOME = "home" + """Home menu""" MENU = "menu" + """General menu""" CONTEXT_MENU = "context_menu" + """Context menu""" GUIDE = "guide" + """Program guide menu.""" INFO = "info" + """Information menu / what's playing.""" BACK = "back" + """Back / exit function for menu navigation.""" SELECT_SOURCE = "select_source" + """Select media playback source or input from the available sources.""" SELECT_SOUND_MODE = "select_sound_mode" + """Select a sound mode from the available modes.""" RECORD = "record" + """Start, stop or open recording menu (device dependant).""" MY_RECORDINGS = "my_recordings" + """Open recordings.""" LIVE = "live" + """Switch to live view.""" EJECT = "eject" + """Eject media.""" OPEN_CLOSE = "open_close" + """Open or close.""" AUDIO_TRACK = "audio_track" + """Switch or select audio track.""" SUBTITLE = "subtitle" + """Switch or select subtitle.""" SETTINGS = "settings" + """Settings menu""" SEARCH = "search" + """Search for media.""" + PLAY_MEDIA = "play_media" + """Play or enqueue a media item.""" + CLEAR_PLAYLIST = "clear_playlist" + """Remove all items from the playback queue. Current playback behavior is integration-dependent (keep playing the current item or clearing everything).""" -class DeviceClasses(str, Enum): +class DeviceClasses(StrEnum): """Media-player entity device classes.""" RECEIVER = "receiver" + """Audio-video receiver.""" SET_TOP_BOX = "set_top_box" + """Set-top box for multichannel video and media playback.""" SPEAKER = "speaker" + """Smart speakers or stereo device.""" STREAMING_BOX = "streaming_box" + """Device for media streaming services.""" TV = "tv" + """Television device.""" -class Options(str, Enum): +class Options(StrEnum): """Media-player entity options.""" SIMPLE_COMMANDS = "simple_commands" + """Additional commands the media-player supports, which are not covered in the feature list. + + Example: ``["EXIT", "THUMBS_UP", "THUMBS_DOWN"]`` + """ VOLUME_STEPS = "volume_steps" + """Number of available volume steps for the set volume command and UI controls. + + Examples: 100 = any value between 0..100, 50 = only odd numbers, 3 = [33, 67, 100] etc. Value 0 = mute. + + Note: if the integration receives an "unexpected" number it is required to round up or down to the next matching value. + """ + + +class MediaContentType(StrEnum): + """Pre-defined media content types. + + The media content type is for playback/content semantics. + It represents the type of the media content to play or that is currently playing. + + An integration may return other values, but the UI will most likely handle them as + an "unknown media." + """ + + ALBUM = "album" + APP = "app" + APPS = "apps" + ARTIST = "artist" + CHANNEL = "channel" + CHANNELS = "channels" + COMPOSER = "composer" + EPISODE = "episode" + GAME = "game" + GENRE = "genre" + IMAGE = "image" + MOVIE = "movie" + MUSIC = "music" + PLAYLIST = "playlist" + PODCAST = "podcast" + RADIO = "radio" + SEASON = "season" + TRACK = "track" + TV_SHOW = "tv_show" + URL = "url" + VIDEO = "video" + + +class MediaClass(StrEnum): + """Pre-defined media classes for media browsing. + + The media class is for browser/structure semantics. + It represents how a media item should be presented and organized in the + media browser hierarchy. + + An integration may return other values, but the UI will most likely treat them as + generic media without custom icons. + """ + + ALBUM = "album" + APP = "app" + ARTIST = "artist" + CHANNEL = "channel" + COMPOSER = "composer" + DIRECTORY = "directory" + EPISODE = "episode" + GAME = "game" + GENRE = "genre" + IMAGE = "image" + MOVIE = "movie" + MUSIC = "music" + PLAYLIST = "playlist" + PODCAST = "podcast" + RADIO = "radio" + SEASON = "season" + TRACK = "track" + TV_SHOW = "tv_show" + URL = "url" + VIDEO = "video" + + +@dataclass +class BrowseOptions: + """ + Browsing media options. + + Attributes: + media_id (str | None): + Optional media content ID to restrict browsing. + media_type (str | None): + Optional media content type to restrict browsing. + stable_ids (bool | None): + Hint to the integration to return stable media IDs. + paging (Paging): + Paging object to limit returned items. Defaults to a default Paging instance. + """ + + media_id: str | None = None + media_type: str | None = None + stable_ids: bool | None = None + paging: Paging = field(default_factory=Paging) + + @classmethod + def from_dict(cls, data: dict) -> "BrowseOptions": + """Construct from a raw dictionary (e.g., from JSON).""" + paging_data = data.get("paging") + paging = ( + Paging.from_dict(paging_data) + if isinstance(paging_data, dict) + else paging_data + ) + + return cls( + media_id=data.get("media_id"), + media_type=data.get("media_type"), + stable_ids=data.get("stable_ids"), + paging=paging if paging is not None else Paging(), + ) + + +@dataclass +class SearchMediaFilter: + """ + Search media filter options. + + Attributes: + media_classes (list[MediaClass | str] | None): + Optional list of media classes to filter the results. + artist (str | None): + Optional artist name. + album (str | None): + Optional album name. + """ + + media_classes: list[MediaClass | str] | None = None + artist: str | None = None + album: str | None = None + + @classmethod + def from_dict(cls, data: dict) -> "SearchMediaFilter": + """Construct from a raw dictionary (e.g., from JSON).""" + return cls( + media_classes=data.get("media_classes"), + artist=data.get("artist"), + album=data.get("album"), + ) + + def __post_init__(self): + """Encode custom fields.""" + if self.media_classes: + self.media_classes = [ + ( + # pylint: disable=protected-access + MediaClass(media_class) + if media_class in MediaClass._value2member_map_ + else media_class + ) + for media_class in self.media_classes + ] + + +@dataclass(kw_only=True) +class SearchOptions: + """ + Searching media options. + + Attributes: + query (str): + Free text search query. + media_id (str | None): + Optional media content ID to restrict searching. + media_type (str | None): + Optional media content type to restrict searching. + stable_ids (bool | None): + Hint to the integration to return stable media IDs. + filter (SearchMediaFilter | None): + Optional media filter to restrict searching. + paging (Paging): + Paging object to limit returned items. Defaults to a default Paging instance. + """ + + query: str + media_id: str | None = None + media_type: str | None = None + stable_ids: bool | None = None + filter: SearchMediaFilter | None = None + paging: Paging = field(default_factory=Paging) + + @classmethod + def from_dict(cls, data: dict) -> "SearchOptions": + """Construct from a raw dictionary (e.g., from JSON).""" + paging_data = data.get("paging") + paging = ( + Paging.from_dict(paging_data) + if isinstance(paging_data, dict) + else paging_data + ) + + filter_data = data.get("filter") + search_filter = ( + SearchMediaFilter.from_dict(filter_data) + if isinstance(filter_data, dict) + else filter_data + ) + + return cls( + query=data.get("query", ""), + media_id=data.get("media_id"), + media_type=data.get("media_type"), + stable_ids=data.get("stable_ids"), + filter=search_filter, + paging=paging if paging is not None else Paging(), + ) + + +@dataclass +class BrowseMediaItem: + """Browse Media Item object.""" + + media_id: str + """Unique identifier of the media item.""" + title: str + """Display name. Max 255 characters.""" + subtitle: str | None = None + """Optional subtitle. Max 255 characters.""" + artist: str | None = None + """Optional artist name. Max 255 characters.""" + album: str | None = None + """Optional album name. Max 255 characters.""" + media_class: MediaClass | str | None = None + """The media class for browsing.""" + media_type: MediaContentType | str | None = None + """The media content type.""" + can_browse: bool | None = None + """If `true`, the item can be browsed (is a container) by using ``media_id`` and ``media_type``.""" + can_play: bool | None = None + """If ``true``, the item can be played directly using the ``play_media`` command with ``media_id`` and ``media_type``.""" + can_search: bool | None = None + """If ``true``, a search can be performed on the item using ``search_media`` with ``media_id`` and ``media_type``.""" + thumbnail: str | None = None + """URL to download the media artwork, or a base64 encoded PNG or JPG image. + The preferred size is 480x480 pixels. + Use the following URI prefix to use a provided icon: ``icon://uc:``, for example, ``icon://uc:music``. + Please use a URL whenever possible. Encoded images should be as small as possible. + """ + duration: int | None = None + """Duration in seconds.""" + items: list["BrowseMediaItem"] | None = None + """Child items if this item is a container. Child items may not contain further child items (only one level + of nesting is supported). A new browse request must be sent for deeper levels. + """ + + def __post_init__(self) -> None: + """Validate the object.""" + # mandatory fields + validate_str("media_id", self.media_id) + validate_str("title", self.title) + + # optional fields + if self.subtitle is not None: + validate_str("subtitle", self.subtitle) + if self.artist is not None: + validate_str("artist", self.artist) + if self.album is not None: + validate_str("album", self.album) + if isinstance(self.media_class, str): + validate_str("media_class", self.media_class, 0) + if isinstance(self.media_type, str): + validate_str("media_type", self.media_type, 0) + if self.thumbnail is not None: + validate_str("thumbnail", self.thumbnail, 1, 32768) + + +@dataclass(kw_only=True) +class BrowseResults: + """ + Browsing media results. + + Attributes: + media (BrowseMediaItem | None): + The browsed media item, or `undefined` if not found. + pagination (Pagination): + Pagination metadata for this result page. + """ + + media: BrowseMediaItem | None = None + pagination: Pagination + + +SearchMediaItem = BrowseMediaItem + +@dataclass +class SearchResults: + """ + Searching media results. -class MediaType(str, Enum): - """Media types.""" + Attributes: + media (list[BrowseMediaItem]): + Array of matching media items. Pass an empty array if no results were found. + pagination (Pagination): + Pagination metadata for this result page. + """ - MUSIC = "MUSIC" - RADIO = "RADIO" - TVSHOW = "TVSHOW" - MOVIE = "MOVIE" - VIDEO = "VIDEO" + media: list[SearchMediaItem] + pagination: Pagination -class RepeatMode(str, Enum): +class RepeatMode(StrEnum): """Repeat modes.""" OFF = "OFF" @@ -188,6 +646,14 @@ class RepeatMode(str, Enum): ONE = "ONE" +class MediaPlayAction(StrEnum): + """Media Play actions.""" + + PLAY_NOW = "PLAY_NOW" + PLAY_NEXT = "PLAY_NEXT" + ADD_TO_QUEUE = "ADD_TO_QUEUE" + + class MediaPlayer(Entity): """ Media-player entity class. @@ -209,7 +675,7 @@ def __init__( cmd_handler: CommandHandler = None, ): """ - Create media-player entity instance. + Create a media-player entity instance. :param identifier: entity identifier :param name: friendly name @@ -231,3 +697,35 @@ def __init__( area=area, cmd_handler=cmd_handler, ) + + async def browse(self, options: BrowseOptions) -> BrowseResults | StatusCodes: + """ + Execute entity browsing request. + + Returns NOT_IMPLEMENTED if no handler is installed. + + :param options: browsing parameters + :return: browsing response or status code if any error occurs + """ + _LOG.warning( + "Media browsing not supported for %s. Request: %s", + self.id, + options, + ) + return StatusCodes.NOT_IMPLEMENTED + + async def search(self, options: SearchOptions) -> SearchResults | StatusCodes: + """ + Execute a media search request. + + Returns NOT_IMPLEMENTED if no handler is installed. + + :param options: search parameters + :return: search response or status code if any error occurs + """ + _LOG.warning( + "Media searching not supported for %s. Request: %s", + self.id, + options, + ) + return StatusCodes.NOT_IMPLEMENTED diff --git a/ucapi/msg_definitions.py b/ucapi/msg_definitions.py new file mode 100644 index 0000000..bd80843 --- /dev/null +++ b/ucapi/msg_definitions.py @@ -0,0 +1,68 @@ +""" +Internal WebSocket message structure definitions. + +See Integration-API for more information: +https://github.com/unfoldedcircle/core-api/tree/main/integration-api + +:copyright: (c) 2026 by Unfolded Circle ApS. +:license: MPL-2.0, see LICENSE for more details. +""" + +from dataclasses import dataclass, field + +from .api_definitions import Paging +from .media_player import BrowseOptions, SearchMediaFilter, SearchOptions + + +@dataclass(kw_only=True) +class BrowseMediaMsgData(BrowseOptions): + """ + Browsing media request message. + + Attributes: + entity_id (str): + media-player entity ID to browse. + """ + + entity_id: str + paging: Paging | dict | None = field(default=None) + + def __post_init__(self): # pylint: disable=W0246 + """Encode custom fields.""" + paging = self.paging + if paging is None: + self.paging = Paging() + elif isinstance(paging, dict): + self.paging = Paging.from_dict(paging) + + +@dataclass(kw_only=True) +class SearchMediaMsgData(SearchOptions): + """ + Search media request message. + + Attributes: + entity_id (str): + media-player entity ID to browse. + query (str): + Free text search query. + filter (SearchMediaFilter|None): + Additional user filter to limit the search scope. + """ + + entity_id: str + query: str + filter: SearchMediaFilter | None = None + paging: Paging | dict | None = field(default=None) + + def __post_init__(self): + """Encode custom fields.""" + paging = self.paging + if paging is None: + self.paging = Paging() + elif isinstance(paging, dict): + self.paging = Paging.from_dict(paging) + + filter_value = self.filter + if isinstance(filter_value, dict): + self.filter = SearchMediaFilter.from_dict(filter_value)