1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-12-06 18:44:14 -05:00

Merge pull request #106 from natekspencer/media-player-enhancements

Enhance media player entity with browse/search capability
This commit is contained in:
Nathan Spencer
2025-11-25 12:27:48 -07:00
committed by GitHub
7 changed files with 462 additions and 45 deletions

View File

@@ -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

View File

@@ -3,9 +3,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
import logging
from typing import Any from typing import Any
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaPlayerEnqueue, MediaPlayerEnqueue,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerEntityDescription, MediaPlayerEntityDescription,
@@ -13,12 +16,27 @@ from homeassistant.components.media_player import (
MediaPlayerState, MediaPlayerState,
MediaType, MediaType,
RepeatMode, RepeatMode,
SearchMedia,
SearchMediaQuery,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator 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 .const import DOMAIN
from .entity import OasisDeviceEntity from .entity import OasisDeviceEntity
from .helpers import get_track_id from .helpers import get_track_id
@@ -33,6 +51,9 @@ from .pyoasiscontrol.const import (
STATUS_STOPPED, STATUS_STOPPED,
STATUS_UPDATING, STATUS_UPDATING,
) )
from .pyoasiscontrol.utils import get_track_ids_from_playlist
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
@@ -66,6 +87,7 @@ DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
"""Oasis device media player entity.""" """Oasis device media player entity."""
_attr_media_content_type = MediaType.IMAGE
_attr_media_image_remotely_accessible = True _attr_media_image_remotely_accessible = True
_attr_supported_features = ( _attr_supported_features = (
MediaPlayerEntityFeature.PAUSE MediaPlayerEntityFeature.PAUSE
@@ -77,13 +99,10 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.MEDIA_ENQUEUE | MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.REPEAT_SET | 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 @property
def media_duration(self) -> int | None: def media_duration(self) -> int | None:
"""Duration of current playing media in seconds.""" """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. 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: Parameters:
media_type (MediaType | str): The media type being requested. media_type (MediaType | str): The media type being requested.
media_id (str): A comma-separated string of track identifiers. 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: 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() self.abort_if_busy()
if media_type == MediaType.PLAYLIST:
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( raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="playlists_unsupported" 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: else:
track = list(filter(None, map(get_track_id, media_id.split(",")))) track_ids = list(filter(None, map(get_track_id, media_id.split(","))))
if not track: if not track_ids:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_media", translation_key="invalid_media",
@@ -269,29 +333,33 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
) )
device = self.device 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: if enqueue == MediaPlayerEnqueue.REPLACE:
await device.async_set_playlist(track) await device.async_stop()
else: await device.async_set_playlist(track_ids)
await device.async_add_track_to_playlist(track) await device.async_play()
return
if enqueue in (MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY): insert_at = (device.playlist_index or 0) + 1
# Move track to next item in the playlist original_len = len(device.playlist)
new_tracks = 1 if isinstance(track, int) else len(track) await device.async_add_track_to_playlist(track_ids)
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)
if ( # Move each newly-added track into the desired position
enqueue in (MediaPlayerEnqueue.PLAY, MediaPlayerEnqueue.REPLACE) for offset, _track_id in enumerate(track_ids):
and device.status_code != 4 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() await device.async_play()
async def async_clear_playlist(self) -> None: async def async_clear_playlist(self) -> None:
@@ -303,3 +371,71 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
""" """
self.abort_if_busy() self.abort_if_busy()
await self.device.async_clear_playlist() 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)

View File

@@ -796,7 +796,11 @@ class OasisMqttClient(OasisClientProtocol):
elif status_name == "OASIS_SPEEED": elif status_name == "OASIS_SPEEED":
data["ball_speed"] = int(payload) data["ball_speed"] = int(payload)
elif status_name == "JOBLIST": 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": elif status_name == "CURRENTJOB":
data["playlist_index"] = int(payload) data["playlist_index"] = int(payload)
elif status_name == "CURRENTLINE": elif status_name == "CURRENTLINE":

View File

@@ -14,7 +14,13 @@ from .const import (
STATUS_SLEEPING, STATUS_SLEEPING,
TRACKS, 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 if TYPE_CHECKING: # avoid runtime circular imports
from .clients import OasisCloudClient from .clients import OasisCloudClient
@@ -282,7 +288,11 @@ class OasisDevice:
) )
return None 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: try:
status: dict[str, Any] = { status: dict[str, Any] = {
@@ -395,10 +405,10 @@ class OasisDevice:
Get the full HTTPS URL for the current track's image if available. Get the full HTTPS URL for the current track's image if available.
Returns: Returns:
str: Full URL to the track image (https://app.grounded.so/uploads/<image>), 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")): if track := self.track:
return f"https://app.grounded.so/uploads/{image}" return get_url_for_image(track.get("image"))
return None return None
@property @property
@@ -616,6 +626,10 @@ class OasisDevice:
client = self._require_client() client = self._require_client()
await client.async_send_change_track_command(self, index) 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: 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. Add one or more tracks to the device's playlist via the attached client.

View File

@@ -200,5 +200,15 @@ def decrypt_svg_content(svg_content: dict[str, str]):
return decrypted 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: def now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)

View File

@@ -157,9 +157,6 @@
}, },
"invalid_media": { "invalid_media": {
"message": "Invalid media: {media}" "message": "Invalid media: {media}"
},
"playlists_unsupported": {
"message": "Playlists are not currently supported"
} }
} }
} }

View File

@@ -157,9 +157,6 @@
}, },
"invalid_media": { "invalid_media": {
"message": "Invalid media: {media}" "message": "Invalid media: {media}"
},
"playlists_unsupported": {
"message": "Playlists are not currently supported"
} }
} }
} }