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:
270
custom_components/oasis_mini/browse_media.py
Normal file
270
custom_components/oasis_mini/browse_media.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
_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
|
||||||
|
]
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
# Adjust this if the field name differs
|
||||||
|
track_ids = playlist.get("patterns") or []
|
||||||
|
# Normalize to ints
|
||||||
|
try:
|
||||||
|
track_ids = [track["id"] for track in track_ids]
|
||||||
|
except Exception:
|
||||||
|
track_ids = []
|
||||||
|
|
||||||
|
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=(
|
||||||
|
f"https://app.grounded.so/uploads/{image}"
|
||||||
|
if (image := meta.get("image"))
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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 {}
|
||||||
|
title = meta.get("name") or f"Track #{track_id}"
|
||||||
|
image = meta.get("image")
|
||||||
|
|
||||||
|
return BrowseMedia(
|
||||||
|
title=title,
|
||||||
|
media_class=MediaClass.IMAGE,
|
||||||
|
media_content_id=str(track_id),
|
||||||
|
media_content_type=MEDIA_TYPE_OASIS_TRACK,
|
||||||
|
can_play=True,
|
||||||
|
can_expand=False,
|
||||||
|
thumbnail=f"https://app.grounded.so/uploads/{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"):
|
||||||
|
if image := track.get("image"):
|
||||||
|
return f"https://app.grounded.so/uploads/{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=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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,21 +263,66 @@ 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 = list(filter(None, map(get_track_id, media_id.split(","))))
|
||||||
if not track:
|
if not track:
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ class OasisMqttClient(OasisClientProtocol):
|
|||||||
playlist (list[int]): Ordered list of track indices to apply as the device's playlist.
|
playlist (list[int]): Ordered list of track indices to apply as the device's playlist.
|
||||||
"""
|
"""
|
||||||
track_str = ",".join(map(str, playlist))
|
track_str = ",".join(map(str, playlist))
|
||||||
payload = f"WRIJOBLIST={track_str}"
|
payload = f"WRIJOBLIST={track_str or '0'}"
|
||||||
await self._publish_command(device, payload)
|
await self._publish_command(device, payload)
|
||||||
|
|
||||||
async def async_send_set_repeat_playlist_command(
|
async def async_send_set_repeat_playlist_command(
|
||||||
@@ -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":
|
||||||
|
|||||||
@@ -282,7 +282,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] = {
|
||||||
@@ -616,6 +620,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.
|
||||||
|
|||||||
@@ -200,5 +200,10 @@ 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 [])]
|
||||||
|
|
||||||
|
|
||||||
def now() -> datetime:
|
def now() -> datetime:
|
||||||
return datetime.now(UTC)
|
return datetime.now(UTC)
|
||||||
|
|||||||
@@ -157,9 +157,6 @@
|
|||||||
},
|
},
|
||||||
"invalid_media": {
|
"invalid_media": {
|
||||||
"message": "Invalid media: {media}"
|
"message": "Invalid media: {media}"
|
||||||
},
|
|
||||||
"playlists_unsupported": {
|
|
||||||
"message": "Playlists are not currently supported"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,9 +157,6 @@
|
|||||||
},
|
},
|
||||||
"invalid_media": {
|
"invalid_media": {
|
||||||
"message": "Invalid media: {media}"
|
"message": "Invalid media: {media}"
|
||||||
},
|
|
||||||
"playlists_unsupported": {
|
|
||||||
"message": "Playlists are not currently supported"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user