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

Enhance media_player with browse/search capability

This commit is contained in:
Nathan Spencer
2025-11-25 18:33:32 +00:00
parent 8abd20a4ff
commit c1754ad959
7 changed files with 457 additions and 40 deletions

View File

@@ -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,21 +263,66 @@ 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:
@@ -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)