From 55b4c452465968f745fb7b6a0403b75e0571d5fe Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:44:13 +0100 Subject: [PATCH 01/25] Added media browsing request --- ucapi/api.py | 41 ++++++++++++++++++++++++++++++++++++++++ ucapi/api_definitions.py | 1 + ucapi/entity.py | 19 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/ucapi/api.py b/ucapi/api.py index d0239cd..1bab7c0 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -28,6 +28,7 @@ from zeroconf import IPVersion from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf +from . import StatusCodes from . import api_definitions as uc from .entities import Entities from .entity import EntityTypes @@ -711,6 +712,8 @@ 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.SUBSCRIBE_EVENTS: await self._subscribe_events(websocket, msg_data) await self._send_ok_result(websocket, req_id) @@ -922,6 +925,44 @@ 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 entity 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 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: + _LOG.warning( + "Cannot browse media for '%s': no configured entity found", + entity_id, + ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) + return + + result = await entity.browse_media( + msg_data, + websocket=websocket, + ) + if isinstance(result, dict): + await self._send_ws_response( + websocket, req_id, "media_browse", result, 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..01449cf 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -59,6 +59,7 @@ 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" # Does WsMsgEvents need to be public? diff --git a/ucapi/entity.py b/ucapi/entity.py index 3b0466b..0f7473d 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -147,3 +147,22 @@ async def command( return await handler(self, cmd_id, params, websocket=websocket) return await handler(self, cmd_id, params) + + # pylint: disable=W0613 + async def browse_media( + self, + params: dict[str, Any], + *, + websocket: Any, + ) -> dict[str, Any] | StatusCodes: + """ + Execute entity browsing request. + + Returns NOT_IMPLEMENTED if no handler is installed. + + :param params: browsing parameters + :param websocket: optional websocket connection. Allows for directed event + callbacks instead of broadcasts. + :return: browsing response or status code if any error occurs + """ + return StatusCodes.NOT_IMPLEMENTED From 5140403ea4fb6c12d5c3e4d4d375208827108429 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:04:39 +0100 Subject: [PATCH 02/25] Added missing methods and commands --- ucapi/api.py | 40 +++++++++++++++++++++++++ ucapi/api_definitions.py | 1 + ucapi/entity.py | 19 ++++++++++++ ucapi/media_player.py | 64 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/ucapi/api.py b/ucapi/api.py index 1bab7c0..9501c10 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -714,6 +714,8 @@ async def _handle_ws_request_msg( 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) @@ -963,6 +965,44 @@ async def _browse_media( 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 entity 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 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: + _LOG.warning( + "Cannot search media for '%s': no configured entity found", + entity_id, + ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) + return + + result = await entity.search_media( + msg_data, + websocket=websocket, + ) + if isinstance(result, dict): + await self._send_ws_response( + websocket, req_id, "search_media", result, 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 01449cf..3f56c3c 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -60,6 +60,7 @@ class WsMessages(str, Enum): 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? diff --git a/ucapi/entity.py b/ucapi/entity.py index 0f7473d..93b6974 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -166,3 +166,22 @@ async def browse_media( :return: browsing response or status code if any error occurs """ return StatusCodes.NOT_IMPLEMENTED + + # pylint: disable=W0613 + async def search_media( + self, + params: dict[str, Any], + *, + websocket: Any, + ) -> dict[str, Any] | StatusCodes: + """ + Execute media search request. + + Returns NOT_IMPLEMENTED if no handler is installed. + + :param params: search parameters + :param websocket: optional websocket connection. Allows for directed event + callbacks instead of broadcasts. + :return: search response or status code if any error occurs + """ + return StatusCodes.NOT_IMPLEMENTED diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 046d0e0..501ae41 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -68,6 +68,11 @@ class Features(str, Enum): SUBTITLE = "subtitle" RECORD = "record" SETTINGS = "settings" + PLAY_MEDIA = "play_media" + CLEAR_PLAYLIST = "clear_playlist" + BROWSE_MEDIA = "browse_media" + SEARCH_MEDIA = "search_media" + SEARCH_MEDIA_CLASSES = "search_media_classes" class Attributes(str, Enum): @@ -151,6 +156,7 @@ class Commands(str, Enum): SUBTITLE = "subtitle" SETTINGS = "settings" SEARCH = "search" + PLAY_MEDIA = "play_media" class DeviceClasses(str, Enum): @@ -188,6 +194,64 @@ class RepeatMode(str, Enum): ONE = "ONE" +class MediaPlayAction(str, Enum): + """Media Play actions.""" + + PLAY_NOW = "PLAY_NOW" + PLAY_NEXT = "PLAY_NEXT" + ADD_TO_QUEUE = "ADD_TO_QUEUE" + + +class MediaContent(str, Enum): + """Media content types for media browsing.""" + + 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(str, Enum): + """Media classes for media browsing.""" + + 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" + SEASON = "season" + TRACK = "track" + TV_SHOW = "tv_show" + URL = "url" + VIDEO = "video" + + class MediaPlayer(Entity): """ Media-player entity class. From 6d0a3a5f2dad06e0e99e83346a90a30ed04e0097 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:55:27 +0100 Subject: [PATCH 03/25] Fixed search media response type --- ucapi/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ucapi/api.py b/ucapi/api.py index 9501c10..93dbd57 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -998,7 +998,7 @@ async def _search_media( ) if isinstance(result, dict): await self._send_ws_response( - websocket, req_id, "search_media", result, StatusCodes.OK + websocket, req_id, "media_search", result, StatusCodes.OK ) else: await self.acknowledge_command(websocket, req_id, result) From e55a4b473032ca8c423ca0434152e92880f9acb5 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:41:29 +0100 Subject: [PATCH 04/25] Refactoring and added typed definitions --- ucapi/api.py | 92 +++++++++++----- ucapi/api_definitions.py | 224 ++++++++++++++++++++++++++++++++++++++- ucapi/entity.py | 38 ------- ucapi/media_player.py | 94 ++++++++-------- 4 files changed, 334 insertions(+), 114 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 93dbd57..4b5e0ef 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -30,9 +30,18 @@ from . import StatusCodes from . import api_definitions as uc +from .api_definitions import ( + BrowseMediaMsgData, + BrowseOptions, + BrowseResults, + SearchMediaMsgData, + SearchOptions, + WsMsgEvents, +) from .entities import Entities from .entity import EntityTypes from .media_player import Attributes as MediaAttr +from .media_player import MediaPlayer # Classes are dynamically created at runtime using the Google Protobuf builder pattern. # pylint: disable=no-name-in-module @@ -677,6 +686,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: @@ -946,24 +956,40 @@ async def _browse_media( return entity = self.configured_entities.get(entity_id) - if entity is None: + if entity is None or not isinstance(entity, MediaPlayer): _LOG.warning( - "Cannot browse media for '%s': no configured entity found", + "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 - - result = await entity.browse_media( - msg_data, - websocket=websocket, - ) - if isinstance(result, dict): - await self._send_ws_response( - websocket, req_id, "media_browse", result, StatusCodes.OK + try: + data = BrowseMediaMsgData(**msg_data) + result = await entity.browse( + BrowseOptions( + media_id=data.media_id, + media_type=data.media_type, + stable_ids=data.stable_ids, + paging=data.paging, + ) + ) + if isinstance(result, BrowseResults): + await self._send_ws_response( + websocket, + req_id, + WsMsgEvents.MEDIA_BROWSE, + asdict(result), + StatusCodes.OK, + ) + else: + await self.acknowledge_command(websocket, req_id, result) + except TypeError: + _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 ) - else: - await self.acknowledge_command(websocket, req_id, result) async def _search_media( self, websocket, req_id: int, msg_data: dict[str, Any] | None @@ -984,24 +1010,42 @@ async def _search_media( return entity = self.configured_entities.get(entity_id) - if entity is None: + if entity is None or not isinstance(entity, MediaPlayer): _LOG.warning( - "Cannot search media for '%s': no configured entity found", + "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 - - result = await entity.search_media( - msg_data, - websocket=websocket, - ) - if isinstance(result, dict): - await self._send_ws_response( - websocket, req_id, "media_search", result, StatusCodes.OK + try: + data = SearchMediaMsgData(**msg_data) + result = await entity.search( + SearchOptions( + query=data.query, + media_id=data.media_id, + media_type=data.media_type, + stable_ids=data.stable_ids, + filter=data.filter, + paging=data.paging, + ) + ) + if isinstance(result, BrowseResults): + await self._send_ws_response( + websocket, + req_id, + WsMsgEvents.MEDIA_BROWSE, + asdict(result), + StatusCodes.OK, + ) + else: + await self.acknowledge_command(websocket, req_id, result) + except TypeError: + _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 ) - else: - await self.acknowledge_command(websocket, req_id, result) async def _setup_driver( self, websocket, req_id: int, msg_data: dict[str, Any] | None diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 3f56c3c..c920bf2 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -5,7 +5,8 @@ :license: MPL-2.0, see LICENSE for more details. """ -from dataclasses import dataclass +import dataclasses +from dataclasses import dataclass, field, fields from enum import Enum, IntEnum from typing import Any, Awaitable, Callable, TypeAlias @@ -80,6 +81,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): @@ -356,3 +359,222 @@ class AssistantEvent: entity_id: str session_id: int data: AssistantEventData | None = None + + +class MediaContentType(str, Enum): + """Media content types for media browsing.""" + + 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(str, Enum): + """Media classes for media browsing.""" + + 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" + SEASON = "season" + TRACK = "track" + TV_SHOW = "tv_show" + URL = "url" + VIDEO = "video" + + +@dataclass +class PagingOptions: + """ + Pagination options. + + Attributes: + page (int | None): + Page number, 1-based. + limit (int | None): + Number of items returned per page. + """ + + page: int | None + limit: int | None + + +@dataclass +class BrowseOptions: + """ + Browsing media options. + + Attributes: + media_id (str | None): + Optional media content ID to restrict browsing. + media_type (MediaContentType | None): + Optional media content type to restrict browsing. + stable_ids (bool | None): + Hint to the integration to return stable media IDs. + paging (PagingOptions | None): + Optional paging object to limit returned items. + """ + + media_id: str | None + media_type: MediaContentType | None + stable_ids: bool | None + paging: PagingOptions | None + + +@dataclass +class SearchMediaFilter: + """ + Search media filter options. + + Attributes: + media_classes (list[MediaClass]|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] | None + artist: str | None + album: str | None + + +@dataclass +class SearchOptions(BrowseOptions): + """ + Browsing media request message. + + Attributes: + query (str): + Free text search query. + filter (MediaContentType | None): + Optional media content type to restrict browsing. + stable_ids (bool | None): + Hint to the integration to return stable media IDs. + paging (PagingOptions | None): + Optional paging object to limit returned items. + """ + + query: str + filter: SearchMediaFilter | None + + +@dataclass +class BrowseMediaMsgData(BrowseOptions): + """ + Browsing media request message. + + Attributes: + entity_id (str): + media-player entity ID to browse. + """ + + entity_id: str + + +@dataclass +class SearchMediaMsgData(BrowseOptions): + """ + 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 + + +@dataclass +class BrowseMediaItem: + """Browse Media Item object.""" + + title: str + media_class: str + media_type: str + media_id: str + can_browse: bool = field(default=False) + can_play: bool = field(default=False) + can_search: bool = field(default=False) + subtitle: str | None = field(default=None) + artist: str | None = field(default=None) + album: str | None = field(default=None) + thumbnail: str | None = field(default=None) + duration: int | None = field(default=None) + items: list["BrowseMediaItem"] | None = field(default=None) + + def __post_init__(self): + """Apply default values on missing fields.""" + for attribute in fields(self): + if ( + not isinstance(attribute.default, dataclasses.MISSING.__class__) + and getattr(self, attribute.name) is None + ): + setattr(self, attribute.name, attribute.default) + + +@dataclass +class BrowseResults: + """ + Browsing media results. + + Attributes: + media (BrowseMediaItem | None): + The browsed media item, or `undefined` if not found. + paging (PagingOptions): + Pagination metadata for this result page. + """ + + media: BrowseMediaItem | None + paging: PagingOptions + + +@dataclass +class SearchResults: + """ + Browsing media results. + + Attributes: + media (list[BrowseMediaItem]): + Array of matching media items. Pass an empty array if no results were found. + paging (PagingOptions): + Pagination metadata for this result page. + """ + + media: list[BrowseMediaItem] + paging: PagingOptions diff --git a/ucapi/entity.py b/ucapi/entity.py index 93b6974..3b0466b 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -147,41 +147,3 @@ async def command( return await handler(self, cmd_id, params, websocket=websocket) return await handler(self, cmd_id, params) - - # pylint: disable=W0613 - async def browse_media( - self, - params: dict[str, Any], - *, - websocket: Any, - ) -> dict[str, Any] | StatusCodes: - """ - Execute entity browsing request. - - Returns NOT_IMPLEMENTED if no handler is installed. - - :param params: browsing parameters - :param websocket: optional websocket connection. Allows for directed event - callbacks instead of broadcasts. - :return: browsing response or status code if any error occurs - """ - return StatusCodes.NOT_IMPLEMENTED - - # pylint: disable=W0613 - async def search_media( - self, - params: dict[str, Any], - *, - websocket: Any, - ) -> dict[str, Any] | StatusCodes: - """ - Execute media search request. - - Returns NOT_IMPLEMENTED if no handler is installed. - - :param params: search parameters - :param websocket: optional websocket connection. Allows for directed event - callbacks instead of broadcasts. - :return: search response or status code if any error occurs - """ - return StatusCodes.NOT_IMPLEMENTED diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 501ae41..a9da243 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -5,12 +5,22 @@ :license: MPL-2.0, see LICENSE for more details. """ +import logging from enum import Enum from typing import Any -from .api_definitions import CommandHandler +from .api_definitions import ( + BrowseOptions, + BrowseResults, + CommandHandler, + SearchOptions, + SearchResults, + StatusCodes, +) from .entity import Entity, EntityTypes +_LOG = logging.getLogger(__name__) + class States(str, Enum): """Media-player entity states.""" @@ -202,56 +212,6 @@ class MediaPlayAction(str, Enum): ADD_TO_QUEUE = "ADD_TO_QUEUE" -class MediaContent(str, Enum): - """Media content types for media browsing.""" - - 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(str, Enum): - """Media classes for media browsing.""" - - 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" - SEASON = "season" - TRACK = "track" - TV_SHOW = "tv_show" - URL = "url" - VIDEO = "video" - - class MediaPlayer(Entity): """ Media-player entity class. @@ -295,3 +255,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, query: SearchOptions) -> SearchResults | StatusCodes: + """ + Execute media search request. + + Returns NOT_IMPLEMENTED if no handler is installed. + + :param query: search parameters + :return: search response or status code if any error occurs + """ + _LOG.warning( + "Media searching not supported for %s. Request: %s", + self.id, + query, + ) + return StatusCodes.NOT_IMPLEMENTED From 69ab522b24f588bef8c7ffb3ed042fc0acb9b601 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:27:02 +0100 Subject: [PATCH 05/25] Finalized & tested updated API with browsing/search support --- ucapi/api.py | 6 ++- ucapi/api_definitions.py | 113 ++++++++++++++++++++++++--------------- ucapi/media_player.py | 6 +-- 3 files changed, 78 insertions(+), 47 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 4b5e0ef..1d9179a 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -34,6 +34,8 @@ BrowseMediaMsgData, BrowseOptions, BrowseResults, + PagingOptions, + SearchMediaFilter, SearchMediaMsgData, SearchOptions, WsMsgEvents, @@ -1025,8 +1027,8 @@ async def _search_media( media_id=data.media_id, media_type=data.media_type, stable_ids=data.stable_ids, - filter=data.filter, - paging=data.paging, + filter=SearchMediaFilter(data.filter) if data.filter else None, + paging=PagingOptions(data.paging), ) ) if isinstance(result, BrowseResults): diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index c920bf2..94a7730 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -5,8 +5,7 @@ :license: MPL-2.0, see LICENSE for more details. """ -import dataclasses -from dataclasses import dataclass, field, fields +from dataclasses import dataclass from enum import Enum, IntEnum from typing import Any, Awaitable, Callable, TypeAlias @@ -423,8 +422,27 @@ class PagingOptions: Number of items returned per page. """ - page: int | None - limit: int | None + page: int | None = None + limit: int | None = None + + +@dataclass +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 (1–100). + count (int|None): + Optional if known: Total number of available items across all pages. + """ + + page: int + limit: int + count: int | None = None @dataclass @@ -443,10 +461,16 @@ class BrowseOptions: Optional paging object to limit returned items. """ - media_id: str | None - media_type: MediaContentType | None - stable_ids: bool | None - paging: PagingOptions | None + media_id: str | None = None + media_type: MediaContentType | None = None + stable_ids: bool | None = None + paging: PagingOptions | None = None + + def __post_init__(self): + if isinstance(self.media_type, str): + self.media_type = MediaContentType(self.media_type) + if isinstance(self.paging, dict): + self.paging = PagingOptions(**self.paging) @dataclass @@ -463,12 +487,18 @@ class SearchMediaFilter: Optional album name. """ - media_classes: list[MediaClass] | None - artist: str | None - album: str | None + media_classes: list[MediaClass] | None = None + artist: str | None = None + album: str | None = None + def __post_init__(self): + if self.media_classes: + self.media_classes = [ + MediaClass(media_class) for media_class in self.media_classes + ] -@dataclass + +@dataclass(kw_only=True) class SearchOptions(BrowseOptions): """ Browsing media request message. @@ -485,10 +515,14 @@ class SearchOptions(BrowseOptions): """ query: str - filter: SearchMediaFilter | None + filter: SearchMediaFilter | None = None + def __post_init__(self): + if isinstance(self.filter, dict): + self.filter = SearchMediaFilter(**self.filter) -@dataclass + +@dataclass(kw_only=True) class BrowseMediaMsgData(BrowseOptions): """ Browsing media request message. @@ -501,7 +535,7 @@ class BrowseMediaMsgData(BrowseOptions): entity_id: str -@dataclass +@dataclass(kw_only=True) class SearchMediaMsgData(BrowseOptions): """ Search media request message. @@ -517,7 +551,11 @@ class SearchMediaMsgData(BrowseOptions): entity_id: str query: str - filter: SearchMediaFilter | None + filter: SearchMediaFilter | None = None + + def __post_init__(self): + if isinstance(self.filter, dict): + self.filter = SearchMediaFilter(**self.filter) @dataclass @@ -528,27 +566,18 @@ class BrowseMediaItem: media_class: str media_type: str media_id: str - can_browse: bool = field(default=False) - can_play: bool = field(default=False) - can_search: bool = field(default=False) - subtitle: str | None = field(default=None) - artist: str | None = field(default=None) - album: str | None = field(default=None) - thumbnail: str | None = field(default=None) - duration: int | None = field(default=None) - items: list["BrowseMediaItem"] | None = field(default=None) - - def __post_init__(self): - """Apply default values on missing fields.""" - for attribute in fields(self): - if ( - not isinstance(attribute.default, dataclasses.MISSING.__class__) - and getattr(self, attribute.name) is None - ): - setattr(self, attribute.name, attribute.default) - - -@dataclass + can_browse: bool | None = None + can_play: bool | None = None + can_search: bool | None = None + subtitle: str | None = None + artist: str | None = None + album: str | None = None + thumbnail: str | None = None + duration: int | None = None + items: list["BrowseMediaItem"] | None = None + + +@dataclass(kw_only=True) class BrowseResults: """ Browsing media results. @@ -556,12 +585,12 @@ class BrowseResults: Attributes: media (BrowseMediaItem | None): The browsed media item, or `undefined` if not found. - paging (PagingOptions): + pagination (Pagination): Pagination metadata for this result page. """ - media: BrowseMediaItem | None - paging: PagingOptions + media: BrowseMediaItem | None = None + pagination: Pagination @dataclass @@ -572,9 +601,9 @@ class SearchResults: Attributes: media (list[BrowseMediaItem]): Array of matching media items. Pass an empty array if no results were found. - paging (PagingOptions): + pagination (Pagination): Pagination metadata for this result page. """ media: list[BrowseMediaItem] - paging: PagingOptions + pagination: Pagination diff --git a/ucapi/media_player.py b/ucapi/media_player.py index a9da243..4ed3e32 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -272,18 +272,18 @@ async def browse(self, options: BrowseOptions) -> BrowseResults | StatusCodes: ) return StatusCodes.NOT_IMPLEMENTED - async def search(self, query: SearchOptions) -> SearchResults | StatusCodes: + async def search(self, options: SearchOptions) -> SearchResults | StatusCodes: """ Execute media search request. Returns NOT_IMPLEMENTED if no handler is installed. - :param query: search parameters + :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, - query, + options, ) return StatusCodes.NOT_IMPLEMENTED From 0665b31cc4e5ba5fef9b0102c7e57cb5962f44ee Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:01:00 +0100 Subject: [PATCH 06/25] Removed forced media_type to MediaContentType as it can be custom --- ucapi/api_definitions.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 94a7730..00f2d21 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -453,7 +453,7 @@ class BrowseOptions: Attributes: media_id (str | None): Optional media content ID to restrict browsing. - media_type (MediaContentType | None): + media_type (str | None): Optional media content type to restrict browsing. stable_ids (bool | None): Hint to the integration to return stable media IDs. @@ -462,13 +462,11 @@ class BrowseOptions: """ media_id: str | None = None - media_type: MediaContentType | None = None + media_type: str | None = None stable_ids: bool | None = None paging: PagingOptions | None = None def __post_init__(self): - if isinstance(self.media_type, str): - self.media_type = MediaContentType(self.media_type) if isinstance(self.paging, dict): self.paging = PagingOptions(**self.paging) @@ -506,12 +504,8 @@ class SearchOptions(BrowseOptions): Attributes: query (str): Free text search query. - filter (MediaContentType | None): - Optional media content type to restrict browsing. - stable_ids (bool | None): - Hint to the integration to return stable media IDs. - paging (PagingOptions | None): - Optional paging object to limit returned items. + filter (SearchMediaFilter | None): + Optional media filter to restrict search. """ query: str From 8f9f553578911a887a26c51921a15b35e733c0ed Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:47:01 +0100 Subject: [PATCH 07/25] Fixed bug --- ucapi/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 1d9179a..6576cea 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -1027,8 +1027,8 @@ async def _search_media( media_id=data.media_id, media_type=data.media_type, stable_ids=data.stable_ids, - filter=SearchMediaFilter(data.filter) if data.filter else None, - paging=PagingOptions(data.paging), + filter=data.filter, + paging=data.paging, ) ) if isinstance(result, BrowseResults): From 7c0e5851f2e53e54ba25846361349b48ff447ed2 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:59:32 +0100 Subject: [PATCH 08/25] Fixed bug --- ucapi/api_definitions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 00f2d21..e375904 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -512,6 +512,7 @@ class SearchOptions(BrowseOptions): filter: SearchMediaFilter | None = None def __post_init__(self): + super().__post_init__() if isinstance(self.filter, dict): self.filter = SearchMediaFilter(**self.filter) @@ -528,6 +529,9 @@ class BrowseMediaMsgData(BrowseOptions): entity_id: str + def __post_init__(self): + super().__post_init__() + @dataclass(kw_only=True) class SearchMediaMsgData(BrowseOptions): @@ -548,6 +552,7 @@ class SearchMediaMsgData(BrowseOptions): filter: SearchMediaFilter | None = None def __post_init__(self): + super().__post_init__() if isinstance(self.filter, dict): self.filter = SearchMediaFilter(**self.filter) From 0081b3d95051c12f960d5b9b501c8fb96ccdd5be Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:14:28 +0100 Subject: [PATCH 09/25] Nailed bug finally --- ucapi/api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 6576cea..d1a3367 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -39,6 +39,7 @@ SearchMediaMsgData, SearchOptions, WsMsgEvents, + SearchResults, ) from .entities import Entities from .entity import EntityTypes @@ -1031,7 +1032,7 @@ async def _search_media( paging=data.paging, ) ) - if isinstance(result, BrowseResults): + if isinstance(result, SearchResults): await self._send_ws_response( websocket, req_id, @@ -1043,7 +1044,7 @@ async def _search_media( await self.acknowledge_command(websocket, req_id, result) except TypeError: _LOG.error( - "Cannot browse media for '%s': wrong format %s", entity_id, msg_data + "Cannot browse media for '%s': wrong format %s", entity_id, msg_data ) await self.acknowledge_command( websocket, req_id, uc.StatusCodes.BAD_REQUEST From 4c16fbf3b95db29f7092bba0d96ad449487c84d7 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:26:55 +0100 Subject: [PATCH 10/25] Fixes reported by Jack --- ucapi/api.py | 6 ++---- ucapi/api_definitions.py | 10 +++++----- ucapi/media_player.py | 5 +++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index d1a3367..7807f1b 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -34,12 +34,10 @@ BrowseMediaMsgData, BrowseOptions, BrowseResults, - PagingOptions, - SearchMediaFilter, SearchMediaMsgData, SearchOptions, - WsMsgEvents, SearchResults, + WsMsgEvents, ) from .entities import Entities from .entity import EntityTypes @@ -1036,7 +1034,7 @@ async def _search_media( await self._send_ws_response( websocket, req_id, - WsMsgEvents.MEDIA_BROWSE, + WsMsgEvents.MEDIA_SEARCH, asdict(result), StatusCodes.OK, ) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index e375904..d0690f7 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -6,7 +6,7 @@ """ from dataclasses import dataclass -from enum import Enum, IntEnum +from enum import Enum, IntEnum, StrEnum from typing import Any, Awaitable, Callable, TypeAlias @@ -360,7 +360,7 @@ class AssistantEvent: data: AssistantEventData | None = None -class MediaContentType(str, Enum): +class MediaContentType(StrEnum): """Media content types for media browsing.""" ALBUM = "album" @@ -386,7 +386,7 @@ class MediaContentType(str, Enum): VIDEO = "video" -class MediaClass(str, Enum): +class MediaClass(StrEnum): """Media classes for media browsing.""" ALBUM = "album" @@ -529,7 +529,7 @@ class BrowseMediaMsgData(BrowseOptions): entity_id: str - def __post_init__(self): + def __post_init__(self): # pylint: disable=W0246 super().__post_init__() @@ -595,7 +595,7 @@ class BrowseResults: @dataclass class SearchResults: """ - Browsing media results. + Searching media results. Attributes: media (list[BrowseMediaItem]): diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 4ed3e32..825c898 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -6,7 +6,7 @@ """ import logging -from enum import Enum +from enum import Enum, StrEnum from typing import Any from .api_definitions import ( @@ -83,6 +83,7 @@ class Features(str, Enum): BROWSE_MEDIA = "browse_media" SEARCH_MEDIA = "search_media" SEARCH_MEDIA_CLASSES = "search_media_classes" + PLAY_MEDIA_ACTION = "play_media_action" class Attributes(str, Enum): @@ -204,7 +205,7 @@ class RepeatMode(str, Enum): ONE = "ONE" -class MediaPlayAction(str, Enum): +class MediaPlayAction(StrEnum): """Media Play actions.""" PLAY_NOW = "PLAY_NOW" From d11cfea3fae125994e0f0187b0df13e5e14801ec Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:54:45 +0100 Subject: [PATCH 11/25] Fixes following Markus review --- ucapi/api_definitions.py | 6 +++++- ucapi/media_player.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index d0690f7..2673b46 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -403,6 +403,7 @@ class MediaClass(StrEnum): MUSIC = "music" PLAYLIST = "playlist" PODCAST = "podcast" + RADIO = "radio" SEASON = "season" TRACK = "track" TV_SHOW = "tv_show" @@ -592,6 +593,9 @@ class BrowseResults: pagination: Pagination +SearchMediaItem = BrowseMediaItem + + @dataclass class SearchResults: """ @@ -604,5 +608,5 @@ class SearchResults: Pagination metadata for this result page. """ - media: list[BrowseMediaItem] + media: list[SearchMediaItem] pagination: Pagination diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 825c898..f09881f 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -106,6 +106,10 @@ class Attributes(str, Enum): SOURCE_LIST = "source_list" SOUND_MODE = "sound_mode" SOUND_MODE_LIST = "sound_mode_list" + MEDIA_ID = "media_id" + MEDIA_PLAYLIST = "media_playlist" + PLAY_MEDIA_ACTION = "play_media_action" + SEARCH_MEDIA_CLASSES = "search_media_classes" class Commands(str, Enum): @@ -168,6 +172,7 @@ class Commands(str, Enum): SETTINGS = "settings" SEARCH = "search" PLAY_MEDIA = "play_media" + CLEAR_PLAYLIST = "clear_playlist" class DeviceClasses(str, Enum): From d413f8197d03612d60d190fbbd04c3e2620522da Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:28:08 +0100 Subject: [PATCH 12/25] Fixed warning --- ucapi/api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 7807f1b..bc5aeb1 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -28,7 +28,6 @@ from zeroconf import IPVersion from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf -from . import StatusCodes from . import api_definitions as uc from .api_definitions import ( BrowseMediaMsgData, @@ -980,7 +979,7 @@ async def _browse_media( req_id, WsMsgEvents.MEDIA_BROWSE, asdict(result), - StatusCodes.OK, + uc.StatusCodes.OK, ) else: await self.acknowledge_command(websocket, req_id, result) @@ -1036,7 +1035,7 @@ async def _search_media( req_id, WsMsgEvents.MEDIA_SEARCH, asdict(result), - StatusCodes.OK, + uc.StatusCodes.OK, ) else: await self.acknowledge_command(websocket, req_id, result) From 55a3d5e37e356935d471374053d820ff4d6f1aaf Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:30:48 +0100 Subject: [PATCH 13/25] Linting --- ucapi/api_definitions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 2673b46..3ea2820 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -468,6 +468,7 @@ class BrowseOptions: paging: PagingOptions | None = None def __post_init__(self): + """Encode custom fields.""" if isinstance(self.paging, dict): self.paging = PagingOptions(**self.paging) @@ -491,6 +492,7 @@ class SearchMediaFilter: album: str | None = None def __post_init__(self): + """Encode custom fields.""" if self.media_classes: self.media_classes = [ MediaClass(media_class) for media_class in self.media_classes @@ -513,6 +515,7 @@ class SearchOptions(BrowseOptions): filter: SearchMediaFilter | None = None def __post_init__(self): + """Encode custom fields.""" super().__post_init__() if isinstance(self.filter, dict): self.filter = SearchMediaFilter(**self.filter) @@ -531,6 +534,7 @@ class BrowseMediaMsgData(BrowseOptions): entity_id: str def __post_init__(self): # pylint: disable=W0246 + """Encode custom fields.""" super().__post_init__() @@ -553,6 +557,7 @@ class SearchMediaMsgData(BrowseOptions): filter: SearchMediaFilter | None = None def __post_init__(self): + """Encode custom fields.""" super().__post_init__() if isinstance(self.filter, dict): self.filter = SearchMediaFilter(**self.filter) From a544238b8ff32fe1437615802ff0b6ac8a2f538c Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Mon, 23 Mar 2026 23:23:17 +0100 Subject: [PATCH 14/25] Add documentation --- ucapi/media_player.py | 46 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/ucapi/media_player.py b/ucapi/media_player.py index f09881f..ade38e0 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -62,28 +62,51 @@ class Features(str, Enum): MEDIA_IMAGE_URL = "media_image_url" MEDIA_TYPE = "media_type" 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" + """The player supports a settings menu.""" PLAY_MEDIA = "play_media" + """The player supports playing a specific media item.""" 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.""" PLAY_MEDIA_ACTION = "play_media_action" + """The player supports the play_media action parameter to either play or enqueue.""" class Attributes(str, Enum): @@ -136,10 +159,15 @@ class Commands(str, Enum): CHANNEL_UP = "channel_up" 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" DIGIT_1 = "digit_1" DIGIT_2 = "digit_2" @@ -155,24 +183,42 @@ class Commands(str, Enum): FUNCTION_YELLOW = "function_yellow" 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" 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): From d27d462fc8bca43d13468c1d107abf0dedaacb3f Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Mon, 23 Mar 2026 23:43:01 +0100 Subject: [PATCH 15/25] Fix BrowseMediaItem - media_class and media_type are optional fields. - use same field order as in API definition --- ucapi/api_definitions.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 3ea2820..277aee9 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -567,16 +567,18 @@ def __post_init__(self): class BrowseMediaItem: """Browse Media Item object.""" - title: str - media_class: str - media_type: str media_id: str - can_browse: bool | None = None - can_play: bool | None = None - can_search: bool | None = None + title: str subtitle: str | None = None artist: str | None = None album: str | None = None + media_class: str | None = None + """Known media classes are defined in the ``MediaClass`` enum.""" + media_type: str | None = None + """Known media content types are defined in the ``MediaContentType`` enum.""" + can_browse: bool | None = None + can_play: bool | None = None + can_search: bool | None = None thumbnail: str | None = None duration: int | None = None items: list["BrowseMediaItem"] | None = None From 7fdc544396afd3be9c237f5dddb03c59f24d6abc Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Tue, 24 Mar 2026 19:31:11 +0100 Subject: [PATCH 16/25] Refactor media classes - move media-player related classes to media_player.py - create msg_definitions.py for internal WS msg data definitions - Replace MediaType with MediaContentType - Use StrEnum for all media-player enums --- CHANGELOG.md | 7 ++ ucapi/api.py | 31 +++--- ucapi/api_definitions.py | 226 +------------------------------------- ucapi/media_player.py | 231 +++++++++++++++++++++++++++++++++++---- ucapi/msg_definitions.py | 55 ++++++++++ 5 files changed, 288 insertions(+), 262 deletions(-) create mode 100644 ucapi/msg_definitions.py 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/ucapi/api.py b/ucapi/api.py index bc5aeb1..2e938bf 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -29,19 +29,18 @@ from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf from . import api_definitions as uc -from .api_definitions import ( - BrowseMediaMsgData, +from .api_definitions import WsMsgEvents +from .entities import Entities +from .entity import EntityTypes +from .media_player import Attributes as MediaAttr +from .media_player import ( BrowseOptions, BrowseResults, - SearchMediaMsgData, + MediaPlayer, SearchOptions, SearchResults, - WsMsgEvents, ) -from .entities import Entities -from .entity import EntityTypes -from .media_player import Attributes as MediaAttr -from .media_player import MediaPlayer +from .msg_definitions import BrowseMediaMsgData, SearchMediaMsgData # Classes are dynamically created at runtime using the Google Protobuf builder pattern. # pylint: disable=no-name-in-module @@ -941,7 +940,7 @@ async def _browse_media( self, websocket, req_id: int, msg_data: dict[str, Any] | None ) -> None: if not msg_data: - _LOG.warning("Ignoring entity command: called with empty msg_data") + _LOG.warning("Ignoring browse_media command: called with empty msg_data") await self.acknowledge_command( websocket, req_id, uc.StatusCodes.BAD_REQUEST ) @@ -949,7 +948,7 @@ async def _browse_media( entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: - _LOG.warning("Ignoring command: missing entity_id") + _LOG.warning("Ignoring browse_media command: missing entity_id") await self.acknowledge_command( websocket, req_id, uc.StatusCodes.BAD_REQUEST ) @@ -958,7 +957,7 @@ async def _browse_media( 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", + "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) @@ -985,7 +984,7 @@ async def _browse_media( await self.acknowledge_command(websocket, req_id, result) except TypeError: _LOG.error( - "Cannot browse media for '%s': wrong format %s", entity_id, msg_data + "Cannot browse media for '%s': wrong format %s", entity_id, msg_data ) await self.acknowledge_command( websocket, req_id, uc.StatusCodes.BAD_REQUEST @@ -995,7 +994,7 @@ async def _search_media( self, websocket, req_id: int, msg_data: dict[str, Any] | None ) -> None: if not msg_data: - _LOG.warning("Ignoring entity command: called with empty msg_data") + _LOG.warning("Ignoring search_media command: called with empty msg_data") await self.acknowledge_command( websocket, req_id, uc.StatusCodes.BAD_REQUEST ) @@ -1003,7 +1002,7 @@ async def _search_media( entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: - _LOG.warning("Ignoring command: missing entity_id") + _LOG.warning("Ignoring search_media command: missing entity_id") await self.acknowledge_command( websocket, req_id, uc.StatusCodes.BAD_REQUEST ) @@ -1012,7 +1011,7 @@ async def _search_media( 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", + "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) @@ -1041,7 +1040,7 @@ async def _search_media( await self.acknowledge_command(websocket, req_id, result) except TypeError: _LOG.error( - "Cannot browse media for '%s': wrong format %s", entity_id, msg_data + "Cannot search media for '%s': wrong format %s", entity_id, msg_data ) await self.acknowledge_command( websocket, req_id, uc.StatusCodes.BAD_REQUEST diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 277aee9..ac1be9b 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -6,7 +6,7 @@ """ from dataclasses import dataclass -from enum import Enum, IntEnum, StrEnum +from enum import Enum, IntEnum from typing import Any, Awaitable, Callable, TypeAlias @@ -360,57 +360,6 @@ class AssistantEvent: data: AssistantEventData | None = None -class MediaContentType(StrEnum): - """Media content types for media browsing.""" - - 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): - """Media classes for media browsing.""" - - 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 PagingOptions: """ @@ -444,176 +393,3 @@ class Pagination: page: int limit: int count: int | None = None - - -@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 (PagingOptions | None): - Optional paging object to limit returned items. - """ - - media_id: str | None = None - media_type: str | None = None - stable_ids: bool | None = None - paging: PagingOptions | None = None - - def __post_init__(self): - """Encode custom fields.""" - if isinstance(self.paging, dict): - self.paging = PagingOptions(**self.paging) - - -@dataclass -class SearchMediaFilter: - """ - Search media filter options. - - Attributes: - media_classes (list[MediaClass]|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] | None = None - artist: str | None = None - album: str | None = None - - def __post_init__(self): - """Encode custom fields.""" - if self.media_classes: - self.media_classes = [ - MediaClass(media_class) for media_class in self.media_classes - ] - - -@dataclass(kw_only=True) -class SearchOptions(BrowseOptions): - """ - Browsing media request message. - - Attributes: - query (str): - Free text search query. - filter (SearchMediaFilter | None): - Optional media filter to restrict search. - """ - - query: str - filter: SearchMediaFilter | None = None - - def __post_init__(self): - """Encode custom fields.""" - super().__post_init__() - if isinstance(self.filter, dict): - self.filter = SearchMediaFilter(**self.filter) - - -@dataclass(kw_only=True) -class BrowseMediaMsgData(BrowseOptions): - """ - Browsing media request message. - - Attributes: - entity_id (str): - media-player entity ID to browse. - """ - - entity_id: str - - def __post_init__(self): # pylint: disable=W0246 - """Encode custom fields.""" - super().__post_init__() - - -@dataclass(kw_only=True) -class SearchMediaMsgData(BrowseOptions): - """ - 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 - - def __post_init__(self): - """Encode custom fields.""" - super().__post_init__() - if isinstance(self.filter, dict): - self.filter = SearchMediaFilter(**self.filter) - - -@dataclass -class BrowseMediaItem: - """Browse Media Item object.""" - - media_id: str - title: str - subtitle: str | None = None - artist: str | None = None - album: str | None = None - media_class: str | None = None - """Known media classes are defined in the ``MediaClass`` enum.""" - media_type: str | None = None - """Known media content types are defined in the ``MediaContentType`` enum.""" - can_browse: bool | None = None - can_play: bool | None = None - can_search: bool | None = None - thumbnail: str | None = None - duration: int | None = None - items: list["BrowseMediaItem"] | None = None - - -@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. - - 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. - """ - - media: list[SearchMediaItem] - pagination: Pagination diff --git a/ucapi/media_player.py b/ucapi/media_player.py index ade38e0..6bf55a3 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -1,20 +1,22 @@ """ 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. """ import logging -from enum import Enum, StrEnum +from dataclasses import dataclass +from enum import StrEnum from typing import Any from .api_definitions import ( - BrowseOptions, - BrowseResults, CommandHandler, - SearchOptions, - SearchResults, + Pagination, + PagingOptions, StatusCodes, ) from .entity import Entity, EntityTypes @@ -22,7 +24,7 @@ _LOG = logging.getLogger(__name__) -class States(str, Enum): +class States(StrEnum): """Media-player entity states.""" UNAVAILABLE = "UNAVAILABLE" @@ -35,7 +37,7 @@ class States(str, Enum): BUFFERING = "BUFFERING" -class Features(str, Enum): +class Features(StrEnum): """Media-player entity features.""" ON_OFF = "on_off" @@ -109,7 +111,7 @@ class Features(str, Enum): """The player supports the play_media action parameter to either play or enqueue.""" -class Attributes(str, Enum): +class Attributes(StrEnum): """Media-player entity attributes.""" STATE = "state" @@ -135,7 +137,7 @@ class Attributes(str, Enum): SEARCH_MEDIA_CLASSES = "search_media_classes" -class Commands(str, Enum): +class Commands(StrEnum): """Media-player entity commands.""" ON = "on" @@ -221,7 +223,7 @@ class Commands(str, Enum): """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" @@ -231,24 +233,211 @@ class DeviceClasses(str, Enum): TV = "tv" -class Options(str, Enum): +class Options(StrEnum): """Media-player entity options.""" SIMPLE_COMMANDS = "simple_commands" VOLUME_STEPS = "volume_steps" -class MediaType(str, Enum): - """Media types.""" +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 (PagingOptions | None): + Optional paging object to limit returned items. + """ + + media_id: str | None = None + media_type: str | None = None + stable_ids: bool | None = None + paging: PagingOptions | None = None + + def __post_init__(self): + """Encode custom fields.""" + if isinstance(self.paging, dict): + self.paging = PagingOptions(**self.paging) + + +@dataclass +class SearchMediaFilter: + """ + Search media filter options. + + Attributes: + media_classes (list[MediaClass]|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] | None = None + artist: str | None = None + album: str | None = None + + def __post_init__(self): + """Encode custom fields.""" + if self.media_classes: + self.media_classes = [ + MediaClass(media_class) for media_class in self.media_classes + ] + + +@dataclass(kw_only=True) +class SearchOptions(BrowseOptions): + """ + Browsing media request message. + + Attributes: + query (str): + Free text search query. + filter (SearchMediaFilter | None): + Optional media filter to restrict search. + """ + + query: str + filter: SearchMediaFilter | None = None + + def __post_init__(self): + """Encode custom fields.""" + super().__post_init__() + if isinstance(self.filter, dict): + self.filter = SearchMediaFilter(**self.filter) + + +@dataclass +class BrowseMediaItem: + """Browse Media Item object.""" + + media_id: str + title: str + subtitle: str | None = None + artist: str | None = None + album: str | None = None + media_class: str | None = None + """Known media classes are defined in the ``MediaClass`` enum.""" + media_type: str | None = None + """Known media content types are defined in the ``MediaContentType`` enum.""" + can_browse: bool | None = None + can_play: bool | None = None + can_search: bool | None = None + thumbnail: str | None = None + duration: int | None = None + items: list["BrowseMediaItem"] | None = None + + +@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. + + 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" @@ -285,7 +474,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 @@ -326,7 +515,7 @@ async def browse(self, options: BrowseOptions) -> BrowseResults | StatusCodes: async def search(self, options: SearchOptions) -> SearchResults | StatusCodes: """ - Execute media search request. + Execute a media search request. Returns NOT_IMPLEMENTED if no handler is installed. diff --git a/ucapi/msg_definitions.py b/ucapi/msg_definitions.py new file mode 100644 index 0000000..7a577b8 --- /dev/null +++ b/ucapi/msg_definitions.py @@ -0,0 +1,55 @@ +""" +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 + +from .media_player import BrowseOptions, SearchMediaFilter + + +@dataclass(kw_only=True) +class BrowseMediaMsgData(BrowseOptions): + """ + Browsing media request message. + + Attributes: + entity_id (str): + media-player entity ID to browse. + """ + + entity_id: str + + def __post_init__(self): # pylint: disable=W0246 + """Encode custom fields.""" + super().__post_init__() + + +@dataclass(kw_only=True) +class SearchMediaMsgData(BrowseOptions): + """ + 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 + + def __post_init__(self): + """Encode custom fields.""" + super().__post_init__() + if isinstance(self.filter, dict): + self.filter = SearchMediaFilter(**self.filter) From e1b8fadbb7776ea83d00c60bd52128acf747be0d Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Tue, 24 Mar 2026 20:01:07 +0100 Subject: [PATCH 17/25] Use MediaClass and MediaContentType in BrowseMediaItem --- ucapi/media_player.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 6bf55a3..8db79f2 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -390,10 +390,8 @@ class BrowseMediaItem: subtitle: str | None = None artist: str | None = None album: str | None = None - media_class: str | None = None - """Known media classes are defined in the ``MediaClass`` enum.""" - media_type: str | None = None - """Known media content types are defined in the ``MediaContentType`` enum.""" + media_class: MediaClass | str | None = None + media_type: MediaContentType | str | None = None can_browse: bool | None = None can_play: bool | None = None can_search: bool | None = None From 792f11e73d01fe06752127935541299ba114a660 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Tue, 24 Mar 2026 20:43:02 +0100 Subject: [PATCH 18/25] Refactor Paging and Pagination classes - Rename `PagingOptions` to `Paging` - Replace `PagingOptions` with `Paging` for better consistency. - Add validation, default values, and utility methods to `Paging`. - Make Paging mandatory in `BrowseOptions` and `SearchOptions` - Update references across `media_player.py` and `api.py` to use the new `Paging` class. --- tests/test_paging.py | 123 +++++++++++++++++++++++++++++++++++++++ ucapi/api.py | 25 ++------ ucapi/api_definitions.py | 50 +++++++++++++--- ucapi/media_player.py | 69 +++++++++++++++++----- ucapi/msg_definitions.py | 27 ++++++--- 5 files changed, 243 insertions(+), 51 deletions(-) create mode 100644 tests/test_paging.py diff --git a/tests/test_paging.py b/tests/test_paging.py new file mode 100644 index 0000000..4cc4890 --- /dev/null +++ b/tests/test_paging.py @@ -0,0 +1,123 @@ +"""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) + + 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("limit cannot be negative", 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/api.py b/ucapi/api.py index 2e938bf..bf107ae 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -34,10 +34,8 @@ from .entity import EntityTypes from .media_player import Attributes as MediaAttr from .media_player import ( - BrowseOptions, BrowseResults, MediaPlayer, - SearchOptions, SearchResults, ) from .msg_definitions import BrowseMediaMsgData, SearchMediaMsgData @@ -963,15 +961,8 @@ async def _browse_media( await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) return try: - data = BrowseMediaMsgData(**msg_data) - result = await entity.browse( - BrowseOptions( - media_id=data.media_id, - media_type=data.media_type, - stable_ids=data.stable_ids, - paging=data.paging, - ) - ) + msg_data = BrowseMediaMsgData(**msg_data) + result = await entity.browse(msg_data) if isinstance(result, BrowseResults): await self._send_ws_response( websocket, @@ -1018,16 +1009,8 @@ async def _search_media( return try: data = SearchMediaMsgData(**msg_data) - result = await entity.search( - SearchOptions( - query=data.query, - media_id=data.media_id, - media_type=data.media_type, - stable_ids=data.stable_ids, - filter=data.filter, - paging=data.paging, - ) - ) + result = await entity.search(data) + if isinstance(result, SearchResults): await self._send_ws_response( websocket, diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index ac1be9b..c2609cf 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -360,23 +360,46 @@ class AssistantEvent: data: AssistantEventData | None = None -@dataclass -class PagingOptions: +@dataclass(frozen=True) +class Paging: """ - Pagination options. + Paging options. Attributes: - page (int | None): + page (int): Page number, 1-based. - limit (int | None): + limit (int): Number of items returned per page. """ - page: int | None = None - limit: int | None = None + page: int = 1 + limit: int = 10 + DEFAULT_PAGE = 1 + DEFAULT_LIMIT = 10 -@dataclass + def __post_init__(self): + """Validate fields.""" + if self.page < 1: + raise ValueError(f"Invalid page: {self.page}. Must be >= 1.") + if self.limit < 1: + raise ValueError(f"Invalid limit: {self.limit}. Must be >= 1.") + + @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. @@ -385,7 +408,7 @@ class Pagination: page (int): Current page number, 1-based. Must correspond to the requested page. limit (int): - Number of items returned in this page (1–100). + Number of items returned in this page. count (int|None): Optional if known: Total number of available items across all pages. """ @@ -393,3 +416,12 @@ class Pagination: 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 self.limit < 0: + raise ValueError("limit cannot be negative") + if self.count is not None and self.count < 0: + raise ValueError("count cannot be negative") diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 8db79f2..b20be66 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -9,14 +9,14 @@ """ import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum from typing import Any from .api_definitions import ( CommandHandler, Pagination, - PagingOptions, + Paging, StatusCodes, ) from .entity import Entity, EntityTypes @@ -318,19 +318,31 @@ class BrowseOptions: Optional media content type to restrict browsing. stable_ids (bool | None): Hint to the integration to return stable media IDs. - paging (PagingOptions | None): - Optional paging object to limit returned items. + 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: PagingOptions | 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 + ) - def __post_init__(self): - """Encode custom fields.""" - if isinstance(self.paging, dict): - self.paging = PagingOptions(**self.paging) + 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 @@ -351,6 +363,15 @@ class SearchMediaFilter: 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: @@ -374,11 +395,31 @@ class SearchOptions(BrowseOptions): query: str filter: SearchMediaFilter | None = None - def __post_init__(self): - """Encode custom fields.""" - super().__post_init__() - if isinstance(self.filter, dict): - self.filter = SearchMediaFilter(**self.filter) + @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"), + paging=paging if paging is not None else Paging(), + filter=search_filter, + ) @dataclass diff --git a/ucapi/msg_definitions.py b/ucapi/msg_definitions.py index 7a577b8..bd80843 100644 --- a/ucapi/msg_definitions.py +++ b/ucapi/msg_definitions.py @@ -8,9 +8,10 @@ :license: MPL-2.0, see LICENSE for more details. """ -from dataclasses import dataclass +from dataclasses import dataclass, field -from .media_player import BrowseOptions, SearchMediaFilter +from .api_definitions import Paging +from .media_player import BrowseOptions, SearchMediaFilter, SearchOptions @dataclass(kw_only=True) @@ -24,14 +25,19 @@ class BrowseMediaMsgData(BrowseOptions): """ entity_id: str + paging: Paging | dict | None = field(default=None) def __post_init__(self): # pylint: disable=W0246 """Encode custom fields.""" - super().__post_init__() + 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(BrowseOptions): +class SearchMediaMsgData(SearchOptions): """ Search media request message. @@ -47,9 +53,16 @@ class SearchMediaMsgData(BrowseOptions): entity_id: str query: str filter: SearchMediaFilter | None = None + paging: Paging | dict | None = field(default=None) def __post_init__(self): """Encode custom fields.""" - super().__post_init__() - if isinstance(self.filter, dict): - self.filter = SearchMediaFilter(**self.filter) + 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) From ee0e117ec9a575c0ebdc177da07fcbe0e33a5613 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Wed, 25 Mar 2026 09:02:05 +0100 Subject: [PATCH 19/25] Separate SearchOptions from BrowseOptions --- ucapi/media_player.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/ucapi/media_player.py b/ucapi/media_player.py index b20be66..fdd35ca 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -381,19 +381,31 @@ def __post_init__(self): @dataclass(kw_only=True) -class SearchOptions(BrowseOptions): +class SearchOptions: """ - Browsing media request message. + 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 search. + 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": @@ -417,8 +429,8 @@ def from_dict(cls, data: dict) -> "SearchOptions": 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(), filter=search_filter, + paging=paging if paging is not None else Paging(), ) From d2c1146dc232ffbb4cd3630341acf751d4586325 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Wed, 25 Mar 2026 09:19:17 +0100 Subject: [PATCH 20/25] Add documentation to media-player classes --- ucapi/media_player.py | 128 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/ucapi/media_player.py b/ucapi/media_player.py index fdd35ca..ac180bb 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -28,41 +28,72 @@ 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(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" @@ -99,6 +130,8 @@ class Features(StrEnum): """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" @@ -107,59 +140,101 @@ class Features(StrEnum): """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.""" - PLAY_MEDIA_ACTION = "play_media_action" - """The player supports the play_media action parameter to either play or enqueue.""" 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(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" @@ -171,19 +246,33 @@ class Commands(StrEnum): 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" @@ -217,6 +306,7 @@ class Commands(StrEnum): SETTINGS = "settings" """Settings menu""" SEARCH = "search" + """Search for media.""" PLAY_MEDIA = "play_media" """Play or enqueue a media item.""" CLEAR_PLAYLIST = "clear_playlist" @@ -227,17 +317,32 @@ 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(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): @@ -439,18 +544,37 @@ 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. + """ @dataclass(kw_only=True) From 99046c330adeb9f0409497af231762d194b65aac Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Wed, 25 Mar 2026 10:15:25 +0100 Subject: [PATCH 21/25] Fix `SearchMediaFilter` to support custom media classes Updated `media_classes` attribute in `SearchMediaFilter` to accept both `MediaClass` and custom string values. Added unit tests. --- tests/test_media_player.py | 59 ++++++++++++++++++++++++++++++++++++++ ucapi/media_player.py | 16 +++++++---- 2 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 tests/test_media_player.py diff --git a/tests/test_media_player.py b/tests/test_media_player.py new file mode 100644 index 0000000..957c7bf --- /dev/null +++ b/tests/test_media_player.py @@ -0,0 +1,59 @@ +import unittest +from ucapi.media_player import SearchMediaFilter, MediaClass + + +class TestMediaPlayer(unittest.TestCase): + 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}" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ucapi/media_player.py b/ucapi/media_player.py index ac180bb..333b1a3 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -456,15 +456,15 @@ class SearchMediaFilter: Search media filter options. Attributes: - media_classes (list[MediaClass]|None): + media_classes (list[MediaClass | str] | None): Optional list of media classes to filter the results. - artist (str|None): + artist (str | None): Optional artist name. - album (str|None): + album (str | None): Optional album name. """ - media_classes: list[MediaClass] | None = None + media_classes: list[MediaClass | str] | None = None artist: str | None = None album: str | None = None @@ -481,7 +481,13 @@ def __post_init__(self): """Encode custom fields.""" if self.media_classes: self.media_classes = [ - MediaClass(media_class) for media_class in 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 ] From 0ce29c7f86748da06e74b16241b8a1713260c3de Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Wed, 25 Mar 2026 11:00:31 +0100 Subject: [PATCH 22/25] Improve error handling in browse and search --- ucapi/api.py | 75 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index a237544..0204db4 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -960,26 +960,39 @@ async def _browse_media( ) await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) return + + # extract request and validate try: - msg_data = BrowseMediaMsgData(**msg_data) - result = await entity.browse(msg_data) - 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) - except TypeError: + 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 @@ -1007,27 +1020,37 @@ async def _search_media( ) await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) return + try: data = SearchMediaMsgData(**msg_data) - result = await entity.search(data) - - 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) - except TypeError: + 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 From 70f18fe868dc442b2c7e22ea2468c6e6a495ada7 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Wed, 25 Mar 2026 17:55:55 +0100 Subject: [PATCH 23/25] Add text field validation in BrowseMediaItem --- tests/test_media_player.py | 104 ++++++++++++++++++++++++++++++++++++- ucapi/entity.py | 18 +++++++ ucapi/media_player.py | 22 +++++++- 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/tests/test_media_player.py b/tests/test_media_player.py index 957c7bf..19663f0 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -1,8 +1,14 @@ +""" +Tests for media player entity. +""" + import unittest -from ucapi.media_player import SearchMediaFilter, MediaClass +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 @@ -54,6 +60,102 @@ def test_search_media_filter_from_dict(self): 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) + with self.assertRaisesRegex( + ValueError, "media_class must be at least 1 characters" + ): + 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) + with self.assertRaisesRegex( + ValueError, "media_type must be at least 1 characters" + ): + 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/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 333b1a3..42c9a60 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -19,7 +19,7 @@ Paging, StatusCodes, ) -from .entity import Entity, EntityTypes +from .entity import Entity, EntityTypes, validate_str _LOG = logging.getLogger(__name__) @@ -582,6 +582,26 @@ class BrowseMediaItem: 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) + if isinstance(self.media_type, str): + validate_str("media_type", self.media_type) + if self.thumbnail is not None: + validate_str("thumbnail", self.thumbnail, 1, 32768) + @dataclass(kw_only=True) class BrowseResults: From 6615bce9775103d3bd24832e2bf38586c0bd3d7c Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Wed, 25 Mar 2026 18:04:05 +0100 Subject: [PATCH 24/25] fixup! Add text field validation in BrowseMediaItem --- tests/test_media_player.py | 14 ++++++-------- ucapi/media_player.py | 4 ++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/test_media_player.py b/tests/test_media_player.py index 19663f0..71f1e3a 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -118,10 +118,9 @@ def test_browse_media_item_validation_optional(self): BrowseMediaItem(media_id="id1", title="Title", album="a" * 256) # media_class (only when it's a string) - with self.assertRaisesRegex( - ValueError, "media_class must be at least 1 characters" - ): - BrowseMediaItem(media_id="id1", title="Title", media_class="") + # 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" ): @@ -130,10 +129,9 @@ def test_browse_media_item_validation_optional(self): BrowseMediaItem(media_id="id1", title="Title", media_class=MediaClass.ALBUM) # media_type (only when it's a string) - with self.assertRaisesRegex( - ValueError, "media_type must be at least 1 characters" - ): - BrowseMediaItem(media_id="id1", title="Title", media_type="") + # 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" ): diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 42c9a60..24661ed 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -596,9 +596,9 @@ def __post_init__(self) -> None: if self.album is not None: validate_str("album", self.album) if isinstance(self.media_class, str): - validate_str("media_class", self.media_class) + validate_str("media_class", self.media_class, 0) if isinstance(self.media_type, str): - validate_str("media_type", self.media_type) + validate_str("media_type", self.media_type, 0) if self.thumbnail is not None: validate_str("thumbnail", self.thumbnail, 1, 32768) From 1b867d864628df75ca5585520663a65533031ac0 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Thu, 26 Mar 2026 21:48:34 +0100 Subject: [PATCH 25/25] PR feedback: add exports and validate paging limit --- tests/test_paging.py | 10 +++++++++- ucapi/__init__.py | 17 ++++++++++++++++- ucapi/api_definitions.py | 12 ++++++++---- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/tests/test_paging.py b/tests/test_paging.py index 4cc4890..3a16f12 100644 --- a/tests/test_paging.py +++ b/tests/test_paging.py @@ -41,6 +41,8 @@ def test_paging_invalid_limit(self): 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.""" @@ -93,7 +95,13 @@ def test_pagination_invalid_limit(self): """Test validation for invalid limit.""" with self.assertRaises(ValueError) as cm: Pagination(page=1, limit=-1) - self.assertIn("limit cannot be negative", str(cm.exception)) + 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.""" 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_definitions.py b/ucapi/api_definitions.py index c2609cf..b1b45b2 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -382,8 +382,10 @@ def __post_init__(self): """Validate fields.""" if self.page < 1: raise ValueError(f"Invalid page: {self.page}. Must be >= 1.") - if self.limit < 1: - raise ValueError(f"Invalid limit: {self.limit}. 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: @@ -421,7 +423,9 @@ def __post_init__(self): """Validate fields.""" if self.page < 1: raise ValueError("page must be >= 1") - if self.limit < 0: - raise ValueError("limit cannot be negative") + 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")