mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Add card group browsing to the Yoto media browser (#173152)
This commit is contained in:
@@ -114,11 +114,15 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
|
||||
return self.client.players
|
||||
|
||||
async def _async_load_library(self) -> None:
|
||||
"""Load the card library; failures only affect titles and artwork."""
|
||||
"""Load the card library and groups; failures only affect browsing."""
|
||||
try:
|
||||
await self.client.update_library()
|
||||
except YotoError as err:
|
||||
_LOGGER.warning("Could not load Yoto card library: %s", err)
|
||||
try:
|
||||
await self.client.update_groups()
|
||||
except YotoError as err:
|
||||
_LOGGER.warning("Could not load Yoto card groups: %s", err)
|
||||
|
||||
async def _async_status_push_tick(self, _now: datetime) -> None:
|
||||
"""Ask each player to push a fresh status snapshot over MQTT."""
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from yoto_api import Card, Chapter, PlaybackStatus, Track, YotoError, YotoPlayer
|
||||
from yoto_api import Card, Chapter, Group, PlaybackStatus, Track, YotoError, YotoPlayer
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseError,
|
||||
@@ -25,9 +25,8 @@ from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
|
||||
from .entity import YotoEntity
|
||||
|
||||
URI_SCHEME = "yoto"
|
||||
# The URI authority ("card") names the content type. Only cards exist today;
|
||||
# reserving it leaves room for groups without breaking URIs.
|
||||
URI_CARD = "card"
|
||||
URI_GROUP = "group"
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -186,7 +185,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
) -> None:
|
||||
"""Play a Yoto card, chapter, or track from the browse tree."""
|
||||
try:
|
||||
card_id, chapter_key, track_key = _parse_uri(media_id)
|
||||
card_id, chapter_key, track_key = _parse_card_uri(media_id)
|
||||
except ValueError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -264,8 +263,27 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
if not media_content_id:
|
||||
return self._browse_root()
|
||||
|
||||
client = self.coordinator.client
|
||||
if media_content_id.startswith(f"{URI_SCHEME}://{URI_GROUP}/"):
|
||||
try:
|
||||
group_id = _parse_group_uri(media_content_id)
|
||||
except ValueError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_media_id",
|
||||
translation_placeholders={"media_id": media_content_id},
|
||||
) from err
|
||||
group = client.groups.get(group_id)
|
||||
if group is None:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_group",
|
||||
translation_placeholders={"group_id": group_id},
|
||||
)
|
||||
return self._browse_group(group)
|
||||
|
||||
try:
|
||||
card_id, chapter_key, _ = _parse_uri(media_content_id)
|
||||
card_id, chapter_key, _ = _parse_card_uri(media_content_id)
|
||||
except ValueError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -273,7 +291,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
translation_placeholders={"media_id": media_content_id},
|
||||
) from err
|
||||
|
||||
card = self.coordinator.client.library.get(card_id)
|
||||
card = client.library.get(card_id)
|
||||
if card is None:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -283,7 +301,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
|
||||
if not card.chapters:
|
||||
try:
|
||||
await self.coordinator.client.update_card_detail(card_id)
|
||||
await client.update_card_detail(card_id)
|
||||
except YotoError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -307,7 +325,12 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
return self._browse_card(card)
|
||||
|
||||
def _browse_root(self) -> BrowseMedia:
|
||||
"""List every card in the user's library."""
|
||||
"""List every card and group in the user's library."""
|
||||
client = self.coordinator.client
|
||||
children: list[BrowseMedia] = [
|
||||
self._card_node(card) for card in client.library.values()
|
||||
]
|
||||
children.extend(self._group_node(group) for group in client.groups.values())
|
||||
return BrowseMedia(
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id="",
|
||||
@@ -315,13 +338,19 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
title="Yoto library",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[
|
||||
self._card_node(card)
|
||||
for card in self.coordinator.client.library.values()
|
||||
],
|
||||
children=children,
|
||||
children_media_class=MediaClass.ALBUM,
|
||||
)
|
||||
|
||||
def _browse_group(self, group: Group) -> BrowseMedia:
|
||||
"""List the cards in a group."""
|
||||
library = self.coordinator.client.library
|
||||
cards = [library[card_id] for card_id in group.card_ids if card_id in library]
|
||||
node = self._group_node(group)
|
||||
node.children = [self._card_node(card) for card in cards]
|
||||
node.children_media_class = MediaClass.ALBUM
|
||||
return node
|
||||
|
||||
def _browse_card(self, card: Card) -> BrowseMedia:
|
||||
"""List a card's chapters, collapsing single-chapter cards to tracks."""
|
||||
chapters = card.chapters
|
||||
@@ -366,6 +395,18 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
thumbnail=card.cover_image_large,
|
||||
)
|
||||
|
||||
def _group_node(self, group: Group) -> BrowseMedia:
|
||||
"""Build a browse node for a group."""
|
||||
return BrowseMedia(
|
||||
media_class=MediaClass.PLAYLIST,
|
||||
media_content_id=_build_group_uri(group.id),
|
||||
media_content_type=MediaType.PLAYLIST,
|
||||
title=group.name or group.id,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
thumbnail=group.image_url,
|
||||
)
|
||||
|
||||
def _chapter_node(
|
||||
self, card_id: str, chapter_key: str, chapter: Chapter
|
||||
) -> BrowseMedia:
|
||||
@@ -423,19 +464,33 @@ def _build_uri(
|
||||
return f"{URI_SCHEME}://{'/'.join(segments)}"
|
||||
|
||||
|
||||
def _parse_uri(media_id: str) -> tuple[str, str | None, str | None]:
|
||||
"""Parse a yoto://card/... URI into card/chapter/track parts.
|
||||
def _build_group_uri(group_id: str) -> str:
|
||||
"""Build a yoto://group/... URI."""
|
||||
return f"{URI_SCHEME}://{URI_GROUP}/{group_id}"
|
||||
|
||||
Parsed manually because URL parsers lower-case the authority and Yoto
|
||||
IDs are case-sensitive.
|
||||
"""
|
||||
|
||||
# URIs parsed manually because URL parsers lower-case the authority and Yoto
|
||||
# IDs are case-sensitive.
|
||||
def _parse_card_uri(media_id: str) -> tuple[str, str | None, str | None]:
|
||||
"""Parse a yoto://card/... URI into card/chapter/track parts."""
|
||||
prefix = f"{URI_SCHEME}://{URI_CARD}/"
|
||||
if not media_id.startswith(prefix):
|
||||
raise ValueError(f"Not a Yoto media identifier: {media_id}")
|
||||
raise ValueError(f"Not a Yoto card identifier: {media_id}")
|
||||
parts = media_id[len(prefix) :].split("/")
|
||||
if not parts or len(parts) > 3 or any(not segment for segment in parts):
|
||||
raise ValueError(f"Not a Yoto media identifier: {media_id}")
|
||||
raise ValueError(f"Not a Yoto card identifier: {media_id}")
|
||||
card_id = parts[0]
|
||||
chapter_key = parts[1] if len(parts) > 1 else None
|
||||
track_key = parts[2] if len(parts) > 2 else None
|
||||
return card_id, chapter_key, track_key
|
||||
|
||||
|
||||
def _parse_group_uri(media_id: str) -> str:
|
||||
"""Parse a yoto://group/<group_id> URI."""
|
||||
prefix = f"{URI_SCHEME}://{URI_GROUP}/"
|
||||
if not media_id.startswith(prefix):
|
||||
raise ValueError(f"Not a Yoto group identifier: {media_id}")
|
||||
parts = media_id[len(prefix) :].split("/")
|
||||
if len(parts) != 1 or not parts[0]:
|
||||
raise ValueError(f"Not a Yoto group identifier: {media_id}")
|
||||
return parts[0]
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
"unknown_chapter": {
|
||||
"message": "Unknown chapter {chapter_key} on card {card_id}"
|
||||
},
|
||||
"unknown_group": {
|
||||
"message": "Unknown Yoto group: {group_id}"
|
||||
},
|
||||
"unknown_track": {
|
||||
"message": "Unknown track {track_key} on card {card_id}"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ from yoto_api import (
|
||||
Card,
|
||||
Chapter,
|
||||
Device,
|
||||
Group,
|
||||
PlaybackEvent,
|
||||
PlaybackStatus,
|
||||
PlayerInfo,
|
||||
@@ -33,6 +34,7 @@ from tests.common import MockConfigEntry
|
||||
USER_ID = "auth0|user-test"
|
||||
PLAYER_ID = "player-test"
|
||||
CARD_ID = "card-test"
|
||||
GROUP_ID = "group-test"
|
||||
SCOPES = " ".join(YOTO_SCOPES)
|
||||
ACCESS_TOKEN = jwt.encode({"sub": USER_ID}, "test-secret-long-enough-for-hmac-sha256")
|
||||
|
||||
@@ -120,6 +122,16 @@ def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
yield mock_setup
|
||||
|
||||
|
||||
def _build_group() -> Group:
|
||||
"""Build a representative Yoto card group."""
|
||||
return Group(
|
||||
id=GROUP_ID,
|
||||
name="Bedtime",
|
||||
image_url="https://example.test/group.jpg",
|
||||
card_ids=[CARD_ID],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_yoto_client() -> Generator[MagicMock]:
|
||||
"""Patch YotoClient used by the runtime to a configurable mock."""
|
||||
@@ -129,6 +141,7 @@ def mock_yoto_client() -> Generator[MagicMock]:
|
||||
client = client_class.return_value
|
||||
client.players = {PLAYER_ID: _build_player()}
|
||||
client.library = {CARD_ID: _build_card()}
|
||||
client.groups = {GROUP_ID: _build_group()}
|
||||
client.token = MagicMock(refresh_token="mock-refresh-token")
|
||||
yield client
|
||||
|
||||
|
||||
@@ -240,6 +240,7 @@ async def test_play_media(
|
||||
pytest.param("yoto://card/", id="missing_card_id"),
|
||||
pytest.param("yoto://card/card-test/01/01-INT/extra", id="too_many_segments"),
|
||||
pytest.param("yoto://card/card-test//01-INT", id="empty_segment"),
|
||||
pytest.param("yoto://group/group-test", id="group_uri_not_playable"),
|
||||
],
|
||||
)
|
||||
async def test_play_media_invalid_uri_raises(
|
||||
@@ -323,12 +324,12 @@ async def test_play_media_card_detail_failure_raises(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_yoto_client")
|
||||
async def test_browse_media_root_lists_cards(
|
||||
async def test_browse_media_root_lists_cards_and_groups(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Browsing without a content id lists every library card."""
|
||||
"""Browsing the root lists every library card followed by every group."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
client = await hass_ws_client()
|
||||
|
||||
@@ -339,11 +340,122 @@ async def test_browse_media_root_lists_cards(
|
||||
|
||||
assert response["success"]
|
||||
children = response["result"]["children"]
|
||||
assert len(children) == 1
|
||||
assert children[0]["title"] == "Outer Space"
|
||||
assert [c["title"] for c in children] == ["Outer Space", "Bedtime"]
|
||||
assert children[0]["media_content_id"] == "yoto://card/card-test"
|
||||
assert children[0]["can_play"] is True
|
||||
assert children[0]["can_expand"] is True
|
||||
assert children[1]["media_content_id"] == "yoto://group/group-test"
|
||||
assert children[1]["can_play"] is False
|
||||
assert children[1]["can_expand"] is True
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_yoto_client")
|
||||
async def test_browse_group_lists_cards(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Browsing a group lists its member cards."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
client = await hass_ws_client()
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "media_player/browse_media",
|
||||
"entity_id": ENTITY_ID,
|
||||
"media_content_type": "music",
|
||||
"media_content_id": "yoto://group/group-test",
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
result = response["result"]
|
||||
assert result["children_media_class"] == "album"
|
||||
children = result["children"]
|
||||
assert [c["title"] for c in children] == ["Outer Space"]
|
||||
assert children[0]["media_content_id"] == "yoto://card/card-test"
|
||||
|
||||
|
||||
async def test_browse_group_skips_cards_missing_from_library(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_yoto_client: MagicMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""A group may reference cards absent from the library; those are skipped."""
|
||||
mock_yoto_client.groups["group-test"].card_ids = ["card-test", "missing-card"]
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
client = await hass_ws_client()
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "media_player/browse_media",
|
||||
"entity_id": ENTITY_ID,
|
||||
"media_content_type": "music",
|
||||
"media_content_id": "yoto://group/group-test",
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
children = response["result"]["children"]
|
||||
assert [c["title"] for c in children] == ["Outer Space"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_yoto_client")
|
||||
async def test_browse_unknown_group_raises(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Browsing a group that's not in the library returns a browse error."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
client = await hass_ws_client()
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "media_player/browse_media",
|
||||
"entity_id": ENTITY_ID,
|
||||
"media_content_type": "music",
|
||||
"media_content_id": "yoto://group/does-not-exist",
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_yoto_client")
|
||||
@pytest.mark.parametrize(
|
||||
"media_content_id",
|
||||
[
|
||||
pytest.param("yoto://group/", id="missing_group_id"),
|
||||
pytest.param("yoto://group/group-test/extra", id="too_many_segments"),
|
||||
],
|
||||
)
|
||||
async def test_browse_invalid_group_uri_raises(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
media_content_id: str,
|
||||
) -> None:
|
||||
"""A malformed yoto://group/ URI returns a browse error."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
client = await hass_ws_client()
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "media_player/browse_media",
|
||||
"entity_id": ENTITY_ID,
|
||||
"media_content_type": "music",
|
||||
"media_content_id": media_content_id,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"] is False
|
||||
|
||||
|
||||
async def test_browse_card_with_multiple_chapters_and_multiple_tracks(
|
||||
|
||||
Reference in New Issue
Block a user