diff --git a/custom_components/oasis_mini/browse_media.py b/custom_components/oasis_mini/browse_media.py new file mode 100644 index 0000000..d0cdc74 --- /dev/null +++ b/custom_components/oasis_mini/browse_media.py @@ -0,0 +1,259 @@ +"""Support for media browsing/searching.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + MediaType, + SearchError, + SearchMedia, + SearchMediaQuery, +) + +from .pyoasiscontrol import OasisCloudClient +from .pyoasiscontrol.const import TRACKS +from .pyoasiscontrol.utils import get_track_ids_from_playlist, get_url_for_image + +_LOGGER = logging.getLogger(__name__) + +MEDIA_TYPE_OASIS_ROOT = "oasis_library" +MEDIA_TYPE_OASIS_PLAYLISTS = "oasis_playlists" +MEDIA_TYPE_OASIS_PLAYLIST = MediaType.PLAYLIST +MEDIA_TYPE_OASIS_TRACKS = "oasis_tracks" +MEDIA_TYPE_OASIS_TRACK = MediaType.TRACK + + +async def build_root_response() -> BrowseMedia: + """Top-level library node that exposes Tracks and Playlists.""" + children = [ + BrowseMedia( + title="Playlists", + media_class=MediaClass.DIRECTORY, + media_content_id="playlists_root", + media_content_type=MEDIA_TYPE_OASIS_PLAYLISTS, + can_play=False, + can_expand=True, + children_media_class=MediaClass.PLAYLIST, + ), + BrowseMedia( + title="Tracks", + media_class=MediaClass.DIRECTORY, + media_content_id="tracks_root", + media_content_type=MEDIA_TYPE_OASIS_TRACKS, + can_play=False, + can_expand=True, + children_media_class=MediaClass.IMAGE, + ), + ] + + return BrowseMedia( + title="Oasis Library", + media_class=MediaClass.DIRECTORY, + media_content_id="oasis_root", + media_content_type=MEDIA_TYPE_OASIS_ROOT, + can_play=False, + can_expand=True, + children=children, + children_media_class=MediaClass.DIRECTORY, + ) + + +async def build_playlists_root(cloud: OasisCloudClient) -> BrowseMedia: + """Build the 'Playlists' directory from the cloud playlists cache.""" + playlists = await cloud.async_get_playlists(personal_only=False) + + children = [ + BrowseMedia( + title=playlist.get("name") or f"Playlist #{playlist['id']}", + media_class=MediaClass.PLAYLIST, + media_content_id=str(playlist["id"]), + media_content_type=MEDIA_TYPE_OASIS_PLAYLIST, + can_play=True, + can_expand=True, + thumbnail=get_first_image_for_playlist(playlist), + ) + for playlist in playlists + if "id" in playlist + ] + + return BrowseMedia( + title="Playlists", + media_class=MediaClass.DIRECTORY, + media_content_id="playlists_root", + media_content_type=MEDIA_TYPE_OASIS_PLAYLISTS, + can_play=False, + can_expand=True, + children=children, + children_media_class=MediaClass.PLAYLIST, + ) + + +async def build_playlist_item(cloud: OasisCloudClient, playlist_id: int) -> BrowseMedia: + """Build a single playlist node including its track children.""" + playlists = await cloud.async_get_playlists(personal_only=False) + playlist = next((p for p in playlists if p.get("id") == playlist_id), None) + if not playlist: + raise BrowseError(f"Unknown playlist id: {playlist_id}") + + title = playlist.get("name") or f"Playlist #{playlist_id}" + + track_ids = get_track_ids_from_playlist(playlist) + children = [build_track_item(track_id) for track_id in track_ids] + + return BrowseMedia( + title=title, + media_class=MediaClass.PLAYLIST, + media_content_id=str(playlist_id), + media_content_type=MEDIA_TYPE_OASIS_PLAYLIST, + can_play=True, + can_expand=True, + children=children, + children_media_class=MediaClass.IMAGE, + thumbnail=get_first_image_for_playlist(playlist), + ) + + +def build_tracks_root() -> BrowseMedia: + """Build the 'Tracks' directory based on the TRACKS mapping.""" + children = [ + BrowseMedia( + title=meta.get("name") or f"Track #{track_id}", + media_class=MediaClass.IMAGE, + media_content_id=str(track_id), + media_content_type=MEDIA_TYPE_OASIS_TRACK, + can_play=True, + can_expand=False, + thumbnail=get_url_for_image(meta.get("image")), + ) + for track_id, meta in TRACKS.items() + ] + + return BrowseMedia( + title="Tracks", + media_class=MediaClass.DIRECTORY, + media_content_id="tracks_root", + media_content_type=MEDIA_TYPE_OASIS_TRACKS, + can_play=False, + can_expand=True, + children=children, + children_media_class=MediaClass.IMAGE, + ) + + +def build_track_item(track_id: int) -> BrowseMedia: + """Build a single track node for a given track id.""" + meta = TRACKS.get(track_id) or {} + + return BrowseMedia( + title=meta.get("name") or f"Track #{track_id}", + media_class=MediaClass.IMAGE, + media_content_id=str(track_id), + media_content_type=MEDIA_TYPE_OASIS_TRACK, + can_play=True, + can_expand=False, + thumbnail=get_url_for_image(meta.get("image")), + ) + + +def get_first_image_for_playlist(playlist: dict[str, Any]) -> str | None: + """Get the first image from a playlist dictionary.""" + for track in playlist.get("patterns") or []: + if image := track.get("image"): + return get_url_for_image(image) + return None + + +async def async_search_media( + cloud: OasisCloudClient, + query: SearchMediaQuery, +) -> SearchMedia: + """ + Search tracks and/or playlists and return a SearchMedia result. + + - If media_type == MEDIA_TYPE_OASIS_TRACK: search tracks only + - If media_type == MEDIA_TYPE_OASIS_PLAYLIST: search playlists only + - Otherwise: search both tracks and playlists + """ + try: + search_query = (query.search_query or "").strip().lower() + + search_tracks = query.media_content_type in ( + None, + "", + MEDIA_TYPE_OASIS_ROOT, + MEDIA_TYPE_OASIS_TRACKS, + MEDIA_TYPE_OASIS_TRACK, + ) + search_playlists = query.media_content_type in ( + None, + "", + MEDIA_TYPE_OASIS_ROOT, + MEDIA_TYPE_OASIS_PLAYLISTS, + MEDIA_TYPE_OASIS_PLAYLIST, + ) + + track_children: list[BrowseMedia] = [] + playlist_children: list[BrowseMedia] = [] + + if search_tracks: + for track_id, meta in TRACKS.items(): + name = (meta.get("name") or "").lower() + + haystack = name.strip() + if search_query in haystack: + track_children.append(build_track_item(track_id)) + + if search_playlists: + playlists = await cloud.async_get_playlists(personal_only=False) + + for pl in playlists: + playlist_id = pl.get("id") + if playlist_id is None: + continue + + name = (pl.get("name") or "").lower() + if search_query not in name: + continue + + playlist_children.append( + BrowseMedia( + title=pl.get("name") or f"Playlist #{playlist_id}", + media_class=MediaClass.PLAYLIST, + media_content_id=str(playlist_id), + media_content_type=MEDIA_TYPE_OASIS_PLAYLIST, + can_play=True, + can_expand=True, + thumbnail=get_first_image_for_playlist(pl), + ) + ) + + root = BrowseMedia( + title=f"Search results for '{query.search_query}'", + media_class=MediaClass.DIRECTORY, + media_content_id=f"search:{query.search_query}", + media_content_type=MEDIA_TYPE_OASIS_ROOT, + can_play=False, + can_expand=True, + children=[], + ) + + if playlist_children and not track_children: + root.children_media_class = MediaClass.PLAYLIST + else: + root.children_media_class = MediaClass.IMAGE + + root.children.extend(playlist_children) + root.children.extend(track_children) + + return SearchMedia(result=root) + + except Exception as err: + _LOGGER.debug( + "Search error details for %s: %s", query.search_query, err, exc_info=True + ) + raise SearchError(f"Error searching for {query.search_query}") from err diff --git a/custom_components/oasis_mini/media_player.py b/custom_components/oasis_mini/media_player.py index 6b15684..92bc306 100644 --- a/custom_components/oasis_mini/media_player.py +++ b/custom_components/oasis_mini/media_player.py @@ -3,9 +3,12 @@ from __future__ import annotations from datetime import datetime +import logging from typing import Any from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityDescription, @@ -13,12 +16,27 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, RepeatMode, + SearchMedia, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import OasisDeviceConfigEntry, setup_platform_from_coordinator +from .browse_media import ( + MEDIA_TYPE_OASIS_PLAYLIST, + MEDIA_TYPE_OASIS_PLAYLISTS, + MEDIA_TYPE_OASIS_ROOT, + MEDIA_TYPE_OASIS_TRACK, + MEDIA_TYPE_OASIS_TRACKS, + async_search_media, + build_playlist_item, + build_playlists_root, + build_root_response, + build_track_item, + build_tracks_root, +) from .const import DOMAIN from .entity import OasisDeviceEntity from .helpers import get_track_id @@ -33,6 +51,9 @@ from .pyoasiscontrol.const import ( STATUS_STOPPED, STATUS_UPDATING, ) +from .pyoasiscontrol.utils import get_track_ids_from_playlist + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -66,6 +87,7 @@ DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None) class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): """Oasis device media player entity.""" + _attr_media_content_type = MediaType.IMAGE _attr_media_image_remotely_accessible = True _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE @@ -77,13 +99,10 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.MEDIA_ENQUEUE | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.SEARCH_MEDIA ) - @property - def media_content_type(self) -> MediaType: - """Content type of current playing media.""" - return MediaType.IMAGE - @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" @@ -244,24 +263,69 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): """ Play or enqueue one or more Oasis tracks on the device. - Validates the media type and parses one or more track identifiers from `media_id`, then updates the device playlist according to `enqueue`. Depending on the enqueue mode the method can replace the playlist, append tracks, move appended tracks to the next play position, and optionally start playback. + Validates the media type and parses one or more track identifiers from + `media_id`, then updates the device playlist according to `enqueue`. Depending + on the enqueue mode the method can replace the playlist, append tracks, move + appended tracks to the next play position, and optionally start playback. Parameters: media_type (MediaType | str): The media type being requested. media_id (str): A comma-separated string of track identifiers. - enqueue (MediaPlayerEnqueue | None): How to insert the tracks into the playlist; if omitted defaults to NEXT. + enqueue (MediaPlayerEnqueue | None): How to insert the tracks into the playlist; if omitted defaults to PLAY. Raises: - ServiceValidationError: If the device is busy, if `media_type` is a playlist (playlists are unsupported), or if `media_id` does not contain any valid track identifiers. + ServiceValidationError: If the device is busy or if `media_id` does not contain any valid media identifiers. """ self.abort_if_busy() - if media_type == MediaType.PLAYLIST: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="playlists_unsupported" - ) + + track_ids: list[int] = [] + + # Entire playlist from browse + if media_type == MEDIA_TYPE_OASIS_PLAYLIST: + try: + playlist_id = int(media_id) + except (TypeError, ValueError) as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_media", + translation_placeholders={"media": f"playlist {media_id}"}, + ) from err + + playlists = await self.coordinator.cloud_client.async_get_playlists() + playlist = next((p for p in playlists if p.get("id") == playlist_id), None) + if not playlist: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_media", + translation_placeholders={"media": f"playlist {playlist_id}"}, + ) + + track_ids = get_track_ids_from_playlist(playlist) + + if not track_ids: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_media", + translation_placeholders={ + "media": f"playlist {playlist_id} is empty" + }, + ) + + elif media_type == MEDIA_TYPE_OASIS_TRACK: + try: + track_id = int(media_id) + except (TypeError, ValueError) as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_media", + translation_placeholders={"media": f"track {media_id}"}, + ) from err + + track_ids = [track_id] + else: - track = list(filter(None, map(get_track_id, media_id.split(",")))) - if not track: + track_ids = list(filter(None, map(get_track_id, media_id.split(",")))) + if not track_ids: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_media", @@ -269,29 +333,33 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): ) device = self.device - enqueue = MediaPlayerEnqueue.NEXT if not enqueue else enqueue + enqueue = MediaPlayerEnqueue.PLAY if not enqueue else enqueue + + if enqueue == MediaPlayerEnqueue.ADD: + await device.async_add_track_to_playlist(track_ids) + return + if enqueue == MediaPlayerEnqueue.REPLACE: - await device.async_set_playlist(track) - else: - await device.async_add_track_to_playlist(track) + await device.async_stop() + await device.async_set_playlist(track_ids) + await device.async_play() + return - if enqueue in (MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY): - # Move track to next item in the playlist - new_tracks = 1 if isinstance(track, int) else len(track) - if (index := (len(device.playlist) - new_tracks)) != device.playlist_index: - if index != ( - _next := min( - device.playlist_index + 1, len(device.playlist) - new_tracks - ) - ): - await device.async_move_track(index, _next) - if enqueue == MediaPlayerEnqueue.PLAY: - await device.async_change_track(_next) + insert_at = (device.playlist_index or 0) + 1 + original_len = len(device.playlist) + await device.async_add_track_to_playlist(track_ids) - if ( - enqueue in (MediaPlayerEnqueue.PLAY, MediaPlayerEnqueue.REPLACE) - and device.status_code != 4 + # Move each newly-added track into the desired position + for offset, _track_id in enumerate(track_ids): + from_index = original_len + offset # position at end after append + to_index = insert_at + offset # target position in playlist + if from_index > to_index: + await device.async_move_track(from_index, to_index) + + if enqueue == MediaPlayerEnqueue.PLAY or ( + enqueue == MediaPlayerEnqueue.NEXT and device.status_code != STATUS_PLAYING ): + await device.async_change_track(min(insert_at, original_len)) await device.async_play() async def async_clear_playlist(self) -> None: @@ -303,3 +371,71 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): """ self.abort_if_busy() await self.device.async_clear_playlist() + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """ + Provide a browse tree for Oasis playlists and tracks. + + Root (`None` or oasis_root): + - Playlists folder + - Tracks folder + """ + # Root + if media_content_id in (None, "", "oasis_root") or media_content_type in ( + None, + MEDIA_TYPE_OASIS_ROOT, + ): + return await build_root_response() + + # Playlists folder + if ( + media_content_type == MEDIA_TYPE_OASIS_PLAYLISTS + or media_content_id == "playlists_root" + ): + return await build_playlists_root(self.coordinator.cloud_client) + + # Single playlist + if media_content_type == MEDIA_TYPE_OASIS_PLAYLIST: + try: + playlist_id = int(media_content_id) + except (TypeError, ValueError) as err: + raise BrowseError(f"Invalid playlist id: {media_content_id}") from err + + return await build_playlist_item(self.coordinator.cloud_client, playlist_id) + + # Tracks folder + if ( + media_content_type == MEDIA_TYPE_OASIS_TRACKS + or media_content_id == "tracks_root" + ): + return build_tracks_root() + + # Single track + if media_content_type == MEDIA_TYPE_OASIS_TRACK: + try: + track_id = int(media_content_id) + except (TypeError, ValueError) as err: + raise BrowseError(f"Invalid track id: {media_content_id}") from err + + return build_track_item(track_id) + + raise BrowseError( + f"Unsupported media_content_type/id: {media_content_type}/{media_content_id}" + ) + + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """ + Search tracks and/or playlists and return a BrowseMedia tree of matches. + + - If media_type == MEDIA_TYPE_OASIS_TRACK: search tracks only + - If media_type == MEDIA_TYPE_OASIS_PLAYLIST: search playlists only + - Otherwise: search both tracks and playlists + """ + return await async_search_media(self.coordinator.cloud_client, query) diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py index eea5ef9..0f615fd 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py @@ -796,7 +796,11 @@ class OasisMqttClient(OasisClientProtocol): elif status_name == "OASIS_SPEEED": data["ball_speed"] = int(payload) elif status_name == "JOBLIST": - data["playlist"] = [int(x) for x in payload.split(",") if x] + data["playlist"] = [ + track_id + for track_str in payload.split(",") + if (track_id := _parse_int(track_str)) + ] elif status_name == "CURRENTJOB": data["playlist_index"] = int(payload) elif status_name == "CURRENTLINE": diff --git a/custom_components/oasis_mini/pyoasiscontrol/device.py b/custom_components/oasis_mini/pyoasiscontrol/device.py index 260df30..8b4d8e5 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/device.py +++ b/custom_components/oasis_mini/pyoasiscontrol/device.py @@ -14,7 +14,13 @@ from .const import ( STATUS_SLEEPING, TRACKS, ) -from .utils import _bit_to_bool, _parse_int, create_svg, decrypt_svg_content +from .utils import ( + _bit_to_bool, + _parse_int, + create_svg, + decrypt_svg_content, + get_url_for_image, +) if TYPE_CHECKING: # avoid runtime circular imports from .clients import OasisCloudClient @@ -282,7 +288,11 @@ class OasisDevice: ) return None - playlist = [_parse_int(track) for track in values[3].split(",") if track] + playlist = [ + track_id + for track_str in values[3].split(",") + if (track_id := _parse_int(track_str)) + ] try: status: dict[str, Any] = { @@ -395,10 +405,10 @@ class OasisDevice: Get the full HTTPS URL for the current track's image if available. Returns: - str: Full URL to the track image (https://app.grounded.so/uploads/), or `None` if no image is available. + str: Full URL to the track image or `None` if no image is available. """ - if (track := self.track) and (image := track.get("image")): - return f"https://app.grounded.so/uploads/{image}" + if track := self.track: + return get_url_for_image(track.get("image")) return None @property @@ -616,6 +626,10 @@ class OasisDevice: client = self._require_client() await client.async_send_change_track_command(self, index) + async def async_clear_playlist(self) -> None: + """Clear the playlist.""" + await self.async_set_playlist([]) + async def async_add_track_to_playlist(self, track: int | Iterable[int]) -> None: """ Add one or more tracks to the device's playlist via the attached client. diff --git a/custom_components/oasis_mini/pyoasiscontrol/utils.py b/custom_components/oasis_mini/pyoasiscontrol/utils.py index 61de128..bbb13c6 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/utils.py +++ b/custom_components/oasis_mini/pyoasiscontrol/utils.py @@ -200,5 +200,15 @@ def decrypt_svg_content(svg_content: dict[str, str]): return decrypted +def get_track_ids_from_playlist(playlist: dict[str, Any]) -> list[int]: + """Get a list of track ids from a playlist.""" + return [track["id"] for track in (playlist.get("patterns") or []) if "id" in track] + + +def get_url_for_image(image: str | None) -> str | None: + """Get the full URL for an image.""" + return f"https://app.grounded.so/uploads/{image}" if image else None + + def now() -> datetime: return datetime.now(UTC) diff --git a/custom_components/oasis_mini/strings.json b/custom_components/oasis_mini/strings.json index eb18ca7..36a0bec 100755 --- a/custom_components/oasis_mini/strings.json +++ b/custom_components/oasis_mini/strings.json @@ -157,9 +157,6 @@ }, "invalid_media": { "message": "Invalid media: {media}" - }, - "playlists_unsupported": { - "message": "Playlists are not currently supported" } } } \ No newline at end of file diff --git a/custom_components/oasis_mini/translations/en.json b/custom_components/oasis_mini/translations/en.json index d28cca1..b0450f9 100755 --- a/custom_components/oasis_mini/translations/en.json +++ b/custom_components/oasis_mini/translations/en.json @@ -157,9 +157,6 @@ }, "invalid_media": { "message": "Invalid media: {media}" - }, - "playlists_unsupported": { - "message": "Playlists are not currently supported" } } } \ No newline at end of file