Add card group browsing to the Yoto media browser (#173152)

This commit is contained in:
Paul Bottein
2026-06-06 12:12:58 -04:00
committed by GitHub
parent b6f38c3cbb
commit b9b36d9e12
5 changed files with 212 additions and 25 deletions
+5 -1
View File
@@ -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."""
+74 -19
View File
@@ -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}"
},
+13
View File
@@ -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
+117 -5
View File
@@ -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(