From c62174c286a6757adce7bd45b350fbf6973c527f Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 27 May 2026 00:20:42 +0200 Subject: [PATCH 1/7] Add browse and play media support to Yoto --- homeassistant/components/yoto/media_player.py | 242 +++++++++++- homeassistant/components/yoto/strings.json | 15 + tests/components/yoto/conftest.py | 24 +- .../yoto/snapshots/test_media_player.ambr | 4 +- tests/components/yoto/test_media_player.py | 345 +++++++++++++++++- 5 files changed, 619 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/yoto/media_player.py b/homeassistant/components/yoto/media_player.py index 5aa472f39ff691..3d00075f6c88cc 100644 --- a/homeassistant/components/yoto/media_player.py +++ b/homeassistant/components/yoto/media_player.py @@ -4,22 +4,28 @@ from datetime import datetime from typing import Any -from yoto_api import Card, PlaybackStatus, YotoError, YotoPlayer +from yoto_api import Card, Chapter, PlaybackStatus, Track, YotoError, YotoPlayer from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator from .entity import YotoEntity +URI_SCHEME = "yoto" + PARALLEL_UPDATES = 0 # Yoto players expose 16 hardware volume steps. @@ -56,6 +62,8 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity): MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.PREVIOUS_TRACK @@ -170,6 +178,204 @@ async def async_media_previous_track(self) -> None: """Skip to the previous track on the active card.""" await self._async_run(self.coordinator.client.previous_track, self._player_id) + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play a Yoto card, chapter, or track from the browse tree.""" + try: + card_id, chapter_key, track_key = _parse_uri(media_id) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_media_id", + translation_placeholders={"media_id": media_id}, + ) from err + + client = self.coordinator.client + card = client.library.get(card_id) + if card is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_card", + translation_placeholders={"card_id": card_id}, + ) + + if chapter_key is not None: + chapter = (card.chapters or {}).get(chapter_key) + if chapter is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_chapter", + translation_placeholders={ + "chapter_key": chapter_key, + "card_id": card_id, + }, + ) + # Playing a chapter means playing it from its first track. + if track_key is None and chapter.tracks: + track_key = next(iter(chapter.tracks)) + + # Targeted plays (chapter or track) always start from the + # beginning; only a bare card play honours the card's own + # resume setting. + seconds_in = 0 if track_key is not None else None + try: + await client.play_card( + self._player_id, + card_id, + chapter_key=chapter_key, + track_key=track_key, + seconds_in=seconds_in, + ) + except YotoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="play_failed", + translation_placeholders={"error": str(err)}, + ) from err + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Browse the Yoto card library.""" + if not media_content_id: + return self._browse_root() + + try: + card_id, chapter_key, _ = _parse_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 + + card = self.coordinator.client.library.get(card_id) + if card is None: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="unknown_card", + translation_placeholders={"card_id": card_id}, + ) + + if not card.chapters: + try: + await self.coordinator.client.update_card_detail(card_id) + except YotoError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="card_detail_failed", + translation_placeholders={"error": str(err)}, + ) from err + + if chapter_key is not None: + chapter = (card.chapters or {}).get(chapter_key) + if chapter is None: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="unknown_chapter", + translation_placeholders={ + "chapter_key": chapter_key, + "card_id": card_id, + }, + ) + return self._browse_chapter(card_id, chapter_key, chapter) + + return self._browse_card(card) + + def _browse_root(self) -> BrowseMedia: + """List every card in the user's library.""" + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type=MediaType.MUSIC, + title="Yoto library", + can_play=False, + can_expand=True, + children=[ + self._card_node(card) + for card in self.coordinator.client.library.values() + ], + children_media_class=MediaClass.ALBUM, + ) + + def _browse_card(self, card: Card) -> BrowseMedia: + """List a card's chapters, collapsing single-chapter cards to tracks.""" + chapters = card.chapters or {} + # Single-chapter cards skip the chapter level: the card expands + # straight into tracks, avoiding a one-item submenu for the common + # single-story case. + if len(chapters) == 1: + chapter_key, chapter = next(iter(chapters.items())) + children = [ + self._track_node(card.id, chapter_key, track_key, track) + for track_key, track in (chapter.tracks or {}).items() + ] + else: + children = [ + self._chapter_node(card.id, key, chapter) + for key, chapter in chapters.items() + ] + node = self._card_node(card) + node.children = children + node.children_media_class = MediaClass.MUSIC + return node + + def _browse_chapter( + self, card_id: str, chapter_key: str, chapter: Chapter + ) -> BrowseMedia: + """List the tracks of a chapter.""" + node = self._chapter_node(card_id, chapter_key, chapter) + node.can_expand = True + node.children = [ + self._track_node(card_id, chapter_key, track_key, track) + for track_key, track in (chapter.tracks or {}).items() + ] + node.children_media_class = MediaClass.MUSIC + return node + + def _card_node(self, card: Card) -> BrowseMedia: + """Build a browse node for a card.""" + return BrowseMedia( + media_class=MediaClass.ALBUM, + media_content_id=_build_uri(card.id), + media_content_type=MediaType.ALBUM, + title=card.title or card.id, + can_play=True, + can_expand=True, + thumbnail=card.cover_image_large, + ) + + def _chapter_node( + self, card_id: str, chapter_key: str, chapter: Chapter + ) -> BrowseMedia: + """Build a browse node for a chapter.""" + return BrowseMedia( + media_class=MediaClass.MUSIC, + media_content_id=_build_uri(card_id, chapter_key), + media_content_type=MediaType.MUSIC, + title=chapter.title or chapter_key, + can_play=True, + can_expand=False, + thumbnail=chapter.icon, + ) + + def _track_node( + self, card_id: str, chapter_key: str, track_key: str, track: Track + ) -> BrowseMedia: + """Build a browse node for a track.""" + return BrowseMedia( + media_class=MediaClass.MUSIC, + media_content_id=_build_uri(card_id, chapter_key, track_key), + media_content_type=MediaType.MUSIC, + title=track.title or track_key, + can_play=True, + can_expand=False, + thumbnail=track.icon, + ) + async def _async_run( self, func: Callable[..., Awaitable[Any]], /, *args: Any ) -> None: @@ -182,3 +388,35 @@ async def _async_run( translation_key="command_failed", translation_placeholders={"error": str(err)}, ) from err + + +def _build_uri( + card_id: str, + chapter_key: str | None = None, + track_key: str | None = None, +) -> str: + """Build a yoto:// URI from card/chapter/track parts.""" + segments = [card_id] + if chapter_key is not None: + segments.append(chapter_key) + if track_key is not None: + segments.append(track_key) + return f"{URI_SCHEME}://{'/'.join(segments)}" + + +def _parse_uri(media_id: str) -> tuple[str, str | None, str | None]: + """Parse a yoto:// URI into card/chapter/track parts. + + Parsed manually so card IDs keep their original casing (URL parsers + lower-case the authority component per RFC 3986). + """ + prefix = f"{URI_SCHEME}://" + if not media_id.startswith(prefix): + raise ValueError(f"Not a Yoto media identifier: {media_id}") + parts = [segment for segment in media_id[len(prefix) :].split("/") if segment] + if not parts: + raise ValueError(f"Not a Yoto media 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 diff --git a/homeassistant/components/yoto/strings.json b/homeassistant/components/yoto/strings.json index 7fa5d400632ebd..75d035f45b20d8 100644 --- a/homeassistant/components/yoto/strings.json +++ b/homeassistant/components/yoto/strings.json @@ -31,12 +31,27 @@ } }, "exceptions": { + "card_detail_failed": { + "message": "Could not load Yoto card details: {error}" + }, "command_failed": { "message": "Yoto command failed: {error}" }, + "invalid_media_id": { + "message": "Not a Yoto media identifier: {media_id}" + }, "oauth2_implementation_unavailable": { "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" }, + "play_failed": { + "message": "Failed to play Yoto card: {error}" + }, + "unknown_card": { + "message": "Unknown Yoto card: {card_id}" + }, + "unknown_chapter": { + "message": "Unknown chapter {chapter_key} on card {card_id}" + }, "update_error": { "message": "Error communicating with Yoto: {error}" } diff --git a/tests/components/yoto/conftest.py b/tests/components/yoto/conftest.py index fe3033d5f15dc6..967eb50aaed72a 100644 --- a/tests/components/yoto/conftest.py +++ b/tests/components/yoto/conftest.py @@ -9,11 +9,13 @@ import pytest from yoto_api import ( Card, + Chapter, Device, PlaybackEvent, PlaybackStatus, PlayerInfo, PlayerStatus, + Track, YotoPlayer, ) @@ -36,12 +38,32 @@ def _build_card() -> Card: - """Build a representative Yoto library card.""" + """Build a representative Yoto library card with chapters and tracks.""" return Card( id=CARD_ID, title="Outer Space", author="Ladybird Audio Adventures", cover_image_large="https://example.test/cover.jpg", + chapters={ + "01": Chapter( + key="01", + title="Introduction", + icon="https://example.test/ch01.png", + tracks={ + "01-INT": Track(key="01-INT", title="Welcome", duration=120), + "01-MAIN": Track( + key="01-MAIN", title="The Story Begins", duration=240 + ), + }, + ), + "02": Chapter( + key="02", + title="Planets", + tracks={ + "02-MER": Track(key="02-MER", title="Mercury", duration=180), + }, + ), + }, ) diff --git a/tests/components/yoto/snapshots/test_media_player.ambr b/tests/components/yoto/snapshots/test_media_player.ambr index 63cd745ed56715..0431b9ac67d961 100644 --- a/tests/components/yoto/snapshots/test_media_player.ambr +++ b/tests/components/yoto/snapshots/test_media_player.ambr @@ -31,7 +31,7 @@ 'platform': 'yoto', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'player-test', 'unit_of_measurement': None, @@ -50,7 +50,7 @@ 'media_position': 120, 'media_position_updated_at': datetime.datetime(2026, 5, 8, 12, 0, tzinfo=datetime.timezone.utc), 'media_title': 'Introduction', - 'supported_features': , + 'supported_features': , 'volume_level': 0.5, }), 'context': , diff --git a/tests/components/yoto/test_media_player.py b/tests/components/yoto/test_media_player.py index 1f731b6f3a565c..7a829f44ad8aff 100644 --- a/tests/components/yoto/test_media_player.py +++ b/tests/components/yoto/test_media_player.py @@ -1,11 +1,12 @@ """Tests for the Yoto media player platform.""" +from typing import Any from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from yoto_api import YotoError +from yoto_api import Chapter, YotoError from homeassistant.components.media_player import ( ATTR_MEDIA_SEEK_POSITION, @@ -17,16 +18,19 @@ SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, + SERVICE_PLAY_MEDIA, SERVICE_VOLUME_SET, + MediaPlayerState, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from . import setup_integration from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator ENTITY_ID = "media_player.nursery_yoto" @@ -160,22 +164,351 @@ async def test_state_idle_before_first_event( state = hass.states.get(ENTITY_ID) assert state is not None - assert state.state == "idle" + assert state.state == MediaPlayerState.IDLE +@pytest.mark.parametrize( + ("media_content_id", "expected_call"), + [ + ( + "yoto://card-test", + {"chapter_key": None, "track_key": None, "seconds_in": None}, + ), + ( + "yoto://card-test/01", + {"chapter_key": "01", "track_key": "01-INT", "seconds_in": 0}, + ), + ( + "yoto://card-test/01/01-INT", + {"chapter_key": "01", "track_key": "01-INT", "seconds_in": 0}, + ), + ], +) +async def test_play_media( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + media_content_id: str, + expected_call: dict[str, Any], +) -> None: + """play_media routes a yoto:// URI to the right play_card call.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + "media_content_type": "music", + "media_content_id": media_content_id, + }, + blocking=True, + ) + + mock_yoto_client.play_card.assert_called_once_with( + "player-test", "card-test", **expected_call + ) + + +@pytest.mark.parametrize( + "media_content_id", + ["spotify:track:abc", "yoto://"], +) +async def test_play_media_invalid_uri_raises( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + media_content_id: str, +) -> None: + """A media_id that isn't a complete yoto:// URI is rejected.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + "media_content_type": "music", + "media_content_id": media_content_id, + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + "media_content_id", + [ + pytest.param("yoto://does-not-exist", id="unknown_card"), + pytest.param("yoto://card-test/does-not-exist", id="unknown_chapter"), + ], +) +async def test_play_media_unknown_target_raises( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + media_content_id: str, +) -> None: + """A yoto:// URI pointing at unknown content is rejected.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + "media_content_type": "music", + "media_content_id": media_content_id, + }, + blocking=True, + ) + + mock_yoto_client.play_card.assert_not_called() + + +async def test_browse_media_root_lists_cards( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Browsing without a content id lists every library 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} + ) + response = await client.receive_json() + + assert response["success"] + children = response["result"]["children"] + assert len(children) == 1 + assert children[0]["title"] == "Outer Space" + assert children[0]["media_content_id"] == "yoto://card-test" + assert children[0]["can_play"] is True + assert children[0]["can_expand"] is True + + +async def test_browse_media_card_shows_chapters( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Browsing a multi-chapter card shows its chapters.""" + 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": "album", + "media_content_id": "yoto://card-test", + } + ) + response = await client.receive_json() + + assert response["success"] + children = response["result"]["children"] + assert [c["title"] for c in children] == ["Introduction", "Planets"] + assert children[0]["media_content_id"] == "yoto://card-test/01" + + +async def test_browse_media_single_chapter_card_collapses_to_tracks( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """A card with a single chapter shows its tracks directly.""" + card = mock_yoto_client.library["card-test"] + card.chapters = {"01": card.chapters["01"]} + + 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": "album", + "media_content_id": "yoto://card-test", + } + ) + response = await client.receive_json() + + assert response["success"] + children = response["result"]["children"] + assert [c["title"] for c in children] == ["Welcome", "The Story Begins"] + assert children[0]["media_content_id"] == "yoto://card-test/01/01-INT" + + +async def test_browse_media_chapter_shows_tracks( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Browsing a chapter lists its tracks.""" + 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": "playlist", + "media_content_id": "yoto://card-test/01", + } + ) + response = await client.receive_json() + + assert response["success"] + children = response["result"]["children"] + assert [c["title"] for c in children] == ["Welcome", "The Story Begins"] + assert children[0]["media_content_id"] == "yoto://card-test/01/01-INT" + + +async def test_browse_media_fetches_card_detail_lazily( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Browsing a card without loaded chapters triggers update_card_detail.""" + card = mock_yoto_client.library["card-test"] + card.chapters = None + + async def _populate(card_id: str) -> None: + card.chapters = {"01": Chapter(key="01", title="Intro", tracks={})} + + mock_yoto_client.update_card_detail.side_effect = _populate + + 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": "album", + "media_content_id": "yoto://card-test", + } + ) + response = await client.receive_json() + + assert response["success"] + mock_yoto_client.update_card_detail.assert_called_once_with("card-test") + + +async def test_browse_media_unknown_card_raises( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Browsing a card 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": "album", + "media_content_id": "yoto://does-not-exist", + } + ) + response = await client.receive_json() + assert response["success"] is False + + +async def test_browse_media_unknown_chapter_raises( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Browsing a chapter that's not in the card 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": "playlist", + "media_content_id": "yoto://card-test/does-not-exist", + } + ) + response = await client.receive_json() + assert response["success"] is False + + +async def test_browse_media_card_detail_failure_raises( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """A failure fetching card chapters bubbles up as a browse error.""" + card = mock_yoto_client.library["card-test"] + card.chapters = None + mock_yoto_client.update_card_detail.side_effect = YotoError("offline") + + 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": "album", + "media_content_id": "yoto://card-test", + } + ) + response = await client.receive_json() + assert response["success"] is False + + +@pytest.mark.parametrize( + ("client_method", "service", "service_data"), + [ + pytest.param("pause", SERVICE_MEDIA_PAUSE, {}, id="playback"), + pytest.param( + "play_card", + SERVICE_PLAY_MEDIA, + {"media_content_type": "music", "media_content_id": "yoto://card-test"}, + id="play_media", + ), + ], +) async def test_command_error_raises( hass: HomeAssistant, mock_yoto_client: MagicMock, mock_config_entry: MockConfigEntry, + client_method: str, + service: str, + service_data: dict[str, Any], ) -> None: """Yoto command failures surface as HomeAssistantError.""" await setup_integration(hass, mock_config_entry) - mock_yoto_client.pause.side_effect = YotoError("nope") + getattr(mock_yoto_client, client_method).side_effect = YotoError("nope") with pytest.raises(HomeAssistantError): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, - SERVICE_MEDIA_PAUSE, - {ATTR_ENTITY_ID: ENTITY_ID}, + service, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, blocking=True, ) From 61e1cb01fb79c17c059ee4de6a4239d3b3814fea Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 27 May 2026 10:08:26 +0200 Subject: [PATCH 2/7] Fix media type --- homeassistant/components/yoto/media_player.py | 28 ++++++++----------- tests/components/yoto/test_media_player.py | 3 ++ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/yoto/media_player.py b/homeassistant/components/yoto/media_player.py index 3d00075f6c88cc..b93048dd288901 100644 --- a/homeassistant/components/yoto/media_player.py +++ b/homeassistant/components/yoto/media_player.py @@ -211,13 +211,11 @@ async def async_play_media( "card_id": card_id, }, ) - # Playing a chapter means playing it from its first track. + # A chapter plays from its first track. if track_key is None and chapter.tracks: track_key = next(iter(chapter.tracks)) - # Targeted plays (chapter or track) always start from the - # beginning; only a bare card play honours the card's own - # resume setting. + # Chapter/track plays start at 0; a card play keeps its resume point. seconds_in = 0 if track_key is not None else None try: await client.play_card( @@ -304,9 +302,7 @@ def _browse_root(self) -> BrowseMedia: def _browse_card(self, card: Card) -> BrowseMedia: """List a card's chapters, collapsing single-chapter cards to tracks.""" chapters = card.chapters or {} - # Single-chapter cards skip the chapter level: the card expands - # straight into tracks, avoiding a one-item submenu for the common - # single-story case. + # Single-chapter cards expand straight to tracks (skip a one-item level). if len(chapters) == 1: chapter_key, chapter = next(iter(chapters.items())) children = [ @@ -315,12 +311,11 @@ def _browse_card(self, card: Card) -> BrowseMedia: ] else: children = [ - self._chapter_node(card.id, key, chapter) - for key, chapter in chapters.items() + self._chapter_node(card.id, chapter_key, chapter) + for chapter_key, chapter in chapters.items() ] node = self._card_node(card) node.children = children - node.children_media_class = MediaClass.MUSIC return node def _browse_chapter( @@ -333,15 +328,15 @@ def _browse_chapter( self._track_node(card_id, chapter_key, track_key, track) for track_key, track in (chapter.tracks or {}).items() ] - node.children_media_class = MediaClass.MUSIC return node def _card_node(self, card: Card) -> BrowseMedia: """Build a browse node for a card.""" + # MUSIC (not ALBUM) so children render in list view with thumbnails. return BrowseMedia( - media_class=MediaClass.ALBUM, + media_class=MediaClass.MUSIC, media_content_id=_build_uri(card.id), - media_content_type=MediaType.ALBUM, + media_content_type=MediaType.MUSIC, title=card.title or card.id, can_play=True, can_expand=True, @@ -352,13 +347,14 @@ def _chapter_node( self, card_id: str, chapter_key: str, chapter: Chapter ) -> BrowseMedia: """Build a browse node for a chapter.""" + # Single-track chapters are leaves: click plays the track directly. return BrowseMedia( media_class=MediaClass.MUSIC, media_content_id=_build_uri(card_id, chapter_key), media_content_type=MediaType.MUSIC, title=chapter.title or chapter_key, can_play=True, - can_expand=False, + can_expand=len(chapter.tracks or {}) > 1, thumbnail=chapter.icon, ) @@ -407,8 +403,8 @@ def _build_uri( def _parse_uri(media_id: str) -> tuple[str, str | None, str | None]: """Parse a yoto:// URI into card/chapter/track parts. - Parsed manually so card IDs keep their original casing (URL parsers - lower-case the authority component per RFC 3986). + Parsed manually because URL parsers lower-case the authority and Yoto + IDs are case-sensitive. """ prefix = f"{URI_SCHEME}://" if not media_id.startswith(prefix): diff --git a/tests/components/yoto/test_media_player.py b/tests/components/yoto/test_media_player.py index 7a829f44ad8aff..5a2beb26129ee5 100644 --- a/tests/components/yoto/test_media_player.py +++ b/tests/components/yoto/test_media_player.py @@ -316,6 +316,9 @@ async def test_browse_media_card_shows_chapters( children = response["result"]["children"] assert [c["title"] for c in children] == ["Introduction", "Planets"] assert children[0]["media_content_id"] == "yoto://card-test/01" + # "Introduction" has 2 tracks → expandable; "Planets" has 1 track → leaf. + assert children[0]["can_expand"] is True + assert children[1]["can_expand"] is False async def test_browse_media_single_chapter_card_collapses_to_tracks( From 60bcd7783f7dd2c93d62f510fe3762c9ef300bcc Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 27 May 2026 10:16:54 +0200 Subject: [PATCH 3/7] Improve tests --- homeassistant/components/yoto/media_player.py | 2 +- tests/components/yoto/test_media_player.py | 95 +++++++++++++++---- 2 files changed, 76 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/yoto/media_player.py b/homeassistant/components/yoto/media_player.py index b93048dd288901..e69543a173b67a 100644 --- a/homeassistant/components/yoto/media_player.py +++ b/homeassistant/components/yoto/media_player.py @@ -347,7 +347,7 @@ def _chapter_node( self, card_id: str, chapter_key: str, chapter: Chapter ) -> BrowseMedia: """Build a browse node for a chapter.""" - # Single-track chapters are leaves: click plays the track directly. + # Single-track chapters aren't expandable: click plays the track. return BrowseMedia( media_class=MediaClass.MUSIC, media_content_id=_build_uri(card_id, chapter_key), diff --git a/tests/components/yoto/test_media_player.py b/tests/components/yoto/test_media_player.py index 5a2beb26129ee5..415de67d67655b 100644 --- a/tests/components/yoto/test_media_player.py +++ b/tests/components/yoto/test_media_player.py @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from yoto_api import Chapter, YotoError +from yoto_api import Chapter, Track, YotoError from homeassistant.components.media_player import ( ATTR_MEDIA_SEEK_POSITION, @@ -37,6 +37,27 @@ pytestmark = pytest.mark.usefixtures("setup_credentials") +def _build_chapters(structure: list[tuple[str, int]]) -> dict[str, Chapter]: + """Build chapters from a list of ``(chapter_title, track_count)`` tuples.""" + chapters = {} + for index, (title, track_count) in enumerate(structure, start=1): + chapter_key = f"{index:02d}" + chapters[chapter_key] = Chapter( + key=chapter_key, + title=title, + icon=f"https://example.test/ch{chapter_key}.png", + tracks={ + f"{chapter_key}-{track:02d}": Track( + key=f"{chapter_key}-{track:02d}", + title=f"{title} - Track {track}", + duration=60, + ) + for track in range(1, track_count + 1) + }, + ) + return chapters + + @pytest.mark.usefixtures("mock_token_hex", "mock_yoto_client") async def test_entity_state( hass: HomeAssistant, @@ -210,13 +231,13 @@ async def test_play_media( ) +@pytest.mark.usefixtures("mock_yoto_client") @pytest.mark.parametrize( "media_content_id", ["spotify:track:abc", "yoto://"], ) async def test_play_media_invalid_uri_raises( hass: HomeAssistant, - mock_yoto_client: MagicMock, mock_config_entry: MockConfigEntry, media_content_id: str, ) -> None: @@ -267,9 +288,9 @@ async def test_play_media_unknown_target_raises( mock_yoto_client.play_card.assert_not_called() +@pytest.mark.usefixtures("mock_yoto_client") async def test_browse_media_root_lists_cards( hass: HomeAssistant, - mock_yoto_client: MagicMock, mock_config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: @@ -291,13 +312,16 @@ async def test_browse_media_root_lists_cards( assert children[0]["can_expand"] is True -async def test_browse_media_card_shows_chapters( +async def test_browse_card_with_multiple_chapters_and_multiple_tracks( hass: HomeAssistant, mock_yoto_client: MagicMock, mock_config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: - """Browsing a multi-chapter card shows its chapters.""" + """N-N: multi-chapter card, multi-track chapters: list expandable chapters.""" + card = mock_yoto_client.library["card-test"] + card.chapters = _build_chapters([("Intro", 2), ("Planets", 3)]) + await setup_integration(hass, mock_config_entry) client = await hass_ws_client() @@ -306,7 +330,7 @@ async def test_browse_media_card_shows_chapters( "id": 1, "type": "media_player/browse_media", "entity_id": ENTITY_ID, - "media_content_type": "album", + "media_content_type": "music", "media_content_id": "yoto://card-test", } ) @@ -314,22 +338,19 @@ async def test_browse_media_card_shows_chapters( assert response["success"] children = response["result"]["children"] - assert [c["title"] for c in children] == ["Introduction", "Planets"] - assert children[0]["media_content_id"] == "yoto://card-test/01" - # "Introduction" has 2 tracks → expandable; "Planets" has 1 track → leaf. - assert children[0]["can_expand"] is True - assert children[1]["can_expand"] is False + assert [c["title"] for c in children] == ["Intro", "Planets"] + assert all(c["can_expand"] for c in children) -async def test_browse_media_single_chapter_card_collapses_to_tracks( +async def test_browse_card_with_multiple_chapters_and_single_track( hass: HomeAssistant, mock_yoto_client: MagicMock, mock_config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: - """A card with a single chapter shows its tracks directly.""" + """N-1: multi-chapter card, single-track chapters: list non-expandable chapters.""" card = mock_yoto_client.library["card-test"] - card.chapters = {"01": card.chapters["01"]} + card.chapters = _build_chapters([("Song A", 1), ("Song B", 1), ("Song C", 1)]) await setup_integration(hass, mock_config_entry) client = await hass_ws_client() @@ -339,7 +360,7 @@ async def test_browse_media_single_chapter_card_collapses_to_tracks( "id": 1, "type": "media_player/browse_media", "entity_id": ENTITY_ID, - "media_content_type": "album", + "media_content_type": "music", "media_content_id": "yoto://card-test", } ) @@ -347,15 +368,49 @@ async def test_browse_media_single_chapter_card_collapses_to_tracks( assert response["success"] children = response["result"]["children"] - assert [c["title"] for c in children] == ["Welcome", "The Story Begins"] - assert children[0]["media_content_id"] == "yoto://card-test/01/01-INT" + assert [c["title"] for c in children] == ["Song A", "Song B", "Song C"] + assert not any(c["can_expand"] for c in children) -async def test_browse_media_chapter_shows_tracks( +async def test_browse_card_with_single_chapter_collapses_to_tracks( hass: HomeAssistant, mock_yoto_client: MagicMock, mock_config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, +) -> None: + """1-N: single-chapter card expands straight to tracks (skips chapter level).""" + card = mock_yoto_client.library["card-test"] + card.chapters = _build_chapters([("Only chapter", 3)]) + + 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://card-test", + } + ) + response = await client.receive_json() + + assert response["success"] + children = response["result"]["children"] + assert [c["title"] for c in children] == [ + "Only chapter - Track 1", + "Only chapter - Track 2", + "Only chapter - Track 3", + ] + assert children[0]["media_content_id"] == "yoto://card-test/01/01-01" + + +@pytest.mark.usefixtures("mock_yoto_client") +async def test_browse_media_chapter_shows_tracks( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, ) -> None: """Browsing a chapter lists its tracks.""" await setup_integration(hass, mock_config_entry) @@ -411,9 +466,9 @@ async def _populate(card_id: str) -> None: mock_yoto_client.update_card_detail.assert_called_once_with("card-test") +@pytest.mark.usefixtures("mock_yoto_client") async def test_browse_media_unknown_card_raises( hass: HomeAssistant, - mock_yoto_client: MagicMock, mock_config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: @@ -434,9 +489,9 @@ async def test_browse_media_unknown_card_raises( assert response["success"] is False +@pytest.mark.usefixtures("mock_yoto_client") async def test_browse_media_unknown_chapter_raises( hass: HomeAssistant, - mock_yoto_client: MagicMock, mock_config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: From 1b608d8d5c5819037ad1cb50a4554d67212092eb Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 27 May 2026 10:49:39 +0200 Subject: [PATCH 4/7] Fix comments and bump lib --- homeassistant/components/yoto/media_player.py | 34 +++++++++++++++---- homeassistant/components/yoto/strings.json | 5 ++- tests/components/yoto/test_media_player.py | 11 ++++-- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/yoto/media_player.py b/homeassistant/components/yoto/media_player.py index e69543a173b67a..3d0ffeecfbc46b 100644 --- a/homeassistant/components/yoto/media_player.py +++ b/homeassistant/components/yoto/media_player.py @@ -201,7 +201,18 @@ async def async_play_media( ) if chapter_key is not None: - chapter = (card.chapters or {}).get(chapter_key) + # Library list may not include chapters yet; fetch detail on demand. + if not card.chapters: + try: + await client.update_card_detail(card_id) + except YotoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="card_detail_failed", + translation_placeholders={"error": str(err)}, + ) from err + + chapter = card.chapters.get(chapter_key) if chapter is None: raise ServiceValidationError( translation_domain=DOMAIN, @@ -211,6 +222,15 @@ async def async_play_media( "card_id": card_id, }, ) + if track_key is not None and track_key not in chapter.tracks: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_track", + translation_placeholders={ + "track_key": track_key, + "card_id": card_id, + }, + ) # A chapter plays from its first track. if track_key is None and chapter.tracks: track_key = next(iter(chapter.tracks)) @@ -269,7 +289,7 @@ async def async_browse_media( ) from err if chapter_key is not None: - chapter = (card.chapters or {}).get(chapter_key) + chapter = card.chapters.get(chapter_key) if chapter is None: raise BrowseError( translation_domain=DOMAIN, @@ -301,13 +321,13 @@ def _browse_root(self) -> BrowseMedia: def _browse_card(self, card: Card) -> BrowseMedia: """List a card's chapters, collapsing single-chapter cards to tracks.""" - chapters = card.chapters or {} + chapters = card.chapters # Single-chapter cards expand straight to tracks (skip a one-item level). if len(chapters) == 1: chapter_key, chapter = next(iter(chapters.items())) children = [ self._track_node(card.id, chapter_key, track_key, track) - for track_key, track in (chapter.tracks or {}).items() + for track_key, track in chapter.tracks.items() ] else: children = [ @@ -326,7 +346,7 @@ def _browse_chapter( node.can_expand = True node.children = [ self._track_node(card_id, chapter_key, track_key, track) - for track_key, track in (chapter.tracks or {}).items() + for track_key, track in chapter.tracks.items() ] return node @@ -354,7 +374,7 @@ def _chapter_node( media_content_type=MediaType.MUSIC, title=chapter.title or chapter_key, can_play=True, - can_expand=len(chapter.tracks or {}) > 1, + can_expand=len(chapter.tracks) > 1, thumbnail=chapter.icon, ) @@ -410,7 +430,7 @@ def _parse_uri(media_id: str) -> tuple[str, str | None, str | None]: if not media_id.startswith(prefix): raise ValueError(f"Not a Yoto media identifier: {media_id}") parts = [segment for segment in media_id[len(prefix) :].split("/") if segment] - if not parts: + if not parts or len(parts) > 3: raise ValueError(f"Not a Yoto media identifier: {media_id}") card_id = parts[0] chapter_key = parts[1] if len(parts) > 1 else None diff --git a/homeassistant/components/yoto/strings.json b/homeassistant/components/yoto/strings.json index 75d035f45b20d8..a8c673ee61c216 100644 --- a/homeassistant/components/yoto/strings.json +++ b/homeassistant/components/yoto/strings.json @@ -44,7 +44,7 @@ "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" }, "play_failed": { - "message": "Failed to play Yoto card: {error}" + "message": "Failed to play Yoto media: {error}" }, "unknown_card": { "message": "Unknown Yoto card: {card_id}" @@ -52,6 +52,9 @@ "unknown_chapter": { "message": "Unknown chapter {chapter_key} on card {card_id}" }, + "unknown_track": { + "message": "Unknown track {track_key} on card {card_id}" + }, "update_error": { "message": "Error communicating with Yoto: {error}" } diff --git a/tests/components/yoto/test_media_player.py b/tests/components/yoto/test_media_player.py index 415de67d67655b..157aa4cf127611 100644 --- a/tests/components/yoto/test_media_player.py +++ b/tests/components/yoto/test_media_player.py @@ -234,7 +234,11 @@ async def test_play_media( @pytest.mark.usefixtures("mock_yoto_client") @pytest.mark.parametrize( "media_content_id", - ["spotify:track:abc", "yoto://"], + [ + pytest.param("spotify:track:abc", id="wrong_scheme"), + pytest.param("yoto://", id="empty_path"), + pytest.param("yoto://card/chapter/track/extra", id="too_many_segments"), + ], ) async def test_play_media_invalid_uri_raises( hass: HomeAssistant, @@ -262,6 +266,7 @@ async def test_play_media_invalid_uri_raises( [ pytest.param("yoto://does-not-exist", id="unknown_card"), pytest.param("yoto://card-test/does-not-exist", id="unknown_chapter"), + pytest.param("yoto://card-test/01/does-not-exist", id="unknown_track"), ], ) async def test_play_media_unknown_target_raises( @@ -441,7 +446,7 @@ async def test_browse_media_fetches_card_detail_lazily( ) -> None: """Browsing a card without loaded chapters triggers update_card_detail.""" card = mock_yoto_client.library["card-test"] - card.chapters = None + card.chapters = {} async def _populate(card_id: str) -> None: card.chapters = {"01": Chapter(key="01", title="Intro", tracks={})} @@ -520,7 +525,7 @@ async def test_browse_media_card_detail_failure_raises( ) -> None: """A failure fetching card chapters bubbles up as a browse error.""" card = mock_yoto_client.library["card-test"] - card.chapters = None + card.chapters = {} mock_yoto_client.update_card_detail.side_effect = YotoError("offline") await setup_integration(hass, mock_config_entry) From 7dfafd54182acea53673fed5cb89094ff6f8cbbd Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 27 May 2026 21:16:04 +0200 Subject: [PATCH 5/7] Add card prefix --- homeassistant/components/yoto/media_player.py | 11 +++-- tests/components/yoto/test_media_player.py | 44 ++++++++++--------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/yoto/media_player.py b/homeassistant/components/yoto/media_player.py index 3d0ffeecfbc46b..36387f96c995a2 100644 --- a/homeassistant/components/yoto/media_player.py +++ b/homeassistant/components/yoto/media_player.py @@ -25,6 +25,9 @@ from .entity import YotoEntity URI_SCHEME = "yoto" +# First path segment names the content type. Only cards exist today; +# reserving it leaves room for groups without breaking URIs. +URI_CARD = "card" PARALLEL_UPDATES = 0 @@ -411,8 +414,8 @@ def _build_uri( chapter_key: str | None = None, track_key: str | None = None, ) -> str: - """Build a yoto:// URI from card/chapter/track parts.""" - segments = [card_id] + """Build a yoto://card/... URI from card/chapter/track parts.""" + segments = [URI_CARD, card_id] if chapter_key is not None: segments.append(chapter_key) if track_key is not None: @@ -421,12 +424,12 @@ def _build_uri( def _parse_uri(media_id: str) -> tuple[str, str | None, str | None]: - """Parse a yoto:// URI into card/chapter/track parts. + """Parse a yoto://card/... URI into card/chapter/track parts. Parsed manually because URL parsers lower-case the authority and Yoto IDs are case-sensitive. """ - prefix = f"{URI_SCHEME}://" + prefix = f"{URI_SCHEME}://{URI_CARD}/" if not media_id.startswith(prefix): raise ValueError(f"Not a Yoto media identifier: {media_id}") parts = [segment for segment in media_id[len(prefix) :].split("/") if segment] diff --git a/tests/components/yoto/test_media_player.py b/tests/components/yoto/test_media_player.py index 157aa4cf127611..e86e9f4863efab 100644 --- a/tests/components/yoto/test_media_player.py +++ b/tests/components/yoto/test_media_player.py @@ -192,15 +192,15 @@ async def test_state_idle_before_first_event( ("media_content_id", "expected_call"), [ ( - "yoto://card-test", + "yoto://card/card-test", {"chapter_key": None, "track_key": None, "seconds_in": None}, ), ( - "yoto://card-test/01", + "yoto://card/card-test/01", {"chapter_key": "01", "track_key": "01-INT", "seconds_in": 0}, ), ( - "yoto://card-test/01/01-INT", + "yoto://card/card-test/01/01-INT", {"chapter_key": "01", "track_key": "01-INT", "seconds_in": 0}, ), ], @@ -236,8 +236,9 @@ async def test_play_media( "media_content_id", [ pytest.param("spotify:track:abc", id="wrong_scheme"), - pytest.param("yoto://", id="empty_path"), - pytest.param("yoto://card/chapter/track/extra", id="too_many_segments"), + pytest.param("yoto://card-test/01", id="missing_card_prefix"), + pytest.param("yoto://card/", id="missing_card_id"), + pytest.param("yoto://card/card-test/01/01-INT/extra", id="too_many_segments"), ], ) async def test_play_media_invalid_uri_raises( @@ -264,9 +265,9 @@ async def test_play_media_invalid_uri_raises( @pytest.mark.parametrize( "media_content_id", [ - pytest.param("yoto://does-not-exist", id="unknown_card"), - pytest.param("yoto://card-test/does-not-exist", id="unknown_chapter"), - pytest.param("yoto://card-test/01/does-not-exist", id="unknown_track"), + pytest.param("yoto://card/does-not-exist", id="unknown_card"), + pytest.param("yoto://card/card-test/does-not-exist", id="unknown_chapter"), + pytest.param("yoto://card/card-test/01/does-not-exist", id="unknown_track"), ], ) async def test_play_media_unknown_target_raises( @@ -312,7 +313,7 @@ async def test_browse_media_root_lists_cards( children = response["result"]["children"] assert len(children) == 1 assert children[0]["title"] == "Outer Space" - assert children[0]["media_content_id"] == "yoto://card-test" + assert children[0]["media_content_id"] == "yoto://card/card-test" assert children[0]["can_play"] is True assert children[0]["can_expand"] is True @@ -336,7 +337,7 @@ async def test_browse_card_with_multiple_chapters_and_multiple_tracks( "type": "media_player/browse_media", "entity_id": ENTITY_ID, "media_content_type": "music", - "media_content_id": "yoto://card-test", + "media_content_id": "yoto://card/card-test", } ) response = await client.receive_json() @@ -366,7 +367,7 @@ async def test_browse_card_with_multiple_chapters_and_single_track( "type": "media_player/browse_media", "entity_id": ENTITY_ID, "media_content_type": "music", - "media_content_id": "yoto://card-test", + "media_content_id": "yoto://card/card-test", } ) response = await client.receive_json() @@ -396,7 +397,7 @@ async def test_browse_card_with_single_chapter_collapses_to_tracks( "type": "media_player/browse_media", "entity_id": ENTITY_ID, "media_content_type": "music", - "media_content_id": "yoto://card-test", + "media_content_id": "yoto://card/card-test", } ) response = await client.receive_json() @@ -408,7 +409,7 @@ async def test_browse_card_with_single_chapter_collapses_to_tracks( "Only chapter - Track 2", "Only chapter - Track 3", ] - assert children[0]["media_content_id"] == "yoto://card-test/01/01-01" + assert children[0]["media_content_id"] == "yoto://card/card-test/01/01-01" @pytest.mark.usefixtures("mock_yoto_client") @@ -427,7 +428,7 @@ async def test_browse_media_chapter_shows_tracks( "type": "media_player/browse_media", "entity_id": ENTITY_ID, "media_content_type": "playlist", - "media_content_id": "yoto://card-test/01", + "media_content_id": "yoto://card/card-test/01", } ) response = await client.receive_json() @@ -435,7 +436,7 @@ async def test_browse_media_chapter_shows_tracks( assert response["success"] children = response["result"]["children"] assert [c["title"] for c in children] == ["Welcome", "The Story Begins"] - assert children[0]["media_content_id"] == "yoto://card-test/01/01-INT" + assert children[0]["media_content_id"] == "yoto://card/card-test/01/01-INT" async def test_browse_media_fetches_card_detail_lazily( @@ -462,7 +463,7 @@ async def _populate(card_id: str) -> None: "type": "media_player/browse_media", "entity_id": ENTITY_ID, "media_content_type": "album", - "media_content_id": "yoto://card-test", + "media_content_id": "yoto://card/card-test", } ) response = await client.receive_json() @@ -487,7 +488,7 @@ async def test_browse_media_unknown_card_raises( "type": "media_player/browse_media", "entity_id": ENTITY_ID, "media_content_type": "album", - "media_content_id": "yoto://does-not-exist", + "media_content_id": "yoto://card/does-not-exist", } ) response = await client.receive_json() @@ -510,7 +511,7 @@ async def test_browse_media_unknown_chapter_raises( "type": "media_player/browse_media", "entity_id": ENTITY_ID, "media_content_type": "playlist", - "media_content_id": "yoto://card-test/does-not-exist", + "media_content_id": "yoto://card/card-test/does-not-exist", } ) response = await client.receive_json() @@ -537,7 +538,7 @@ async def test_browse_media_card_detail_failure_raises( "type": "media_player/browse_media", "entity_id": ENTITY_ID, "media_content_type": "album", - "media_content_id": "yoto://card-test", + "media_content_id": "yoto://card/card-test", } ) response = await client.receive_json() @@ -551,7 +552,10 @@ async def test_browse_media_card_detail_failure_raises( pytest.param( "play_card", SERVICE_PLAY_MEDIA, - {"media_content_type": "music", "media_content_id": "yoto://card-test"}, + { + "media_content_type": "music", + "media_content_id": "yoto://card/card-test", + }, id="play_media", ), ], From 4fd95fee3bb72a1aa91dc1ea0e976767b046bc25 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 1 Jun 2026 17:15:22 +0200 Subject: [PATCH 6/7] Update comment --- homeassistant/components/yoto/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yoto/media_player.py b/homeassistant/components/yoto/media_player.py index 36387f96c995a2..891390f7a98b69 100644 --- a/homeassistant/components/yoto/media_player.py +++ b/homeassistant/components/yoto/media_player.py @@ -25,7 +25,7 @@ from .entity import YotoEntity URI_SCHEME = "yoto" -# First path segment names the content type. Only cards exist today; +# The URI authority ("card") names the content type. Only cards exist today; # reserving it leaves room for groups without breaking URIs. URI_CARD = "card" From 43bf95daebdd1681c33365630763df7cfbc23c81 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 1 Jun 2026 17:27:40 +0200 Subject: [PATCH 7/7] Copilot feedbacks --- homeassistant/components/yoto/media_player.py | 8 +++--- tests/components/yoto/test_media_player.py | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/yoto/media_player.py b/homeassistant/components/yoto/media_player.py index 891390f7a98b69..1a1bd670108937 100644 --- a/homeassistant/components/yoto/media_player.py +++ b/homeassistant/components/yoto/media_player.py @@ -238,8 +238,8 @@ async def async_play_media( if track_key is None and chapter.tracks: track_key = next(iter(chapter.tracks)) - # Chapter/track plays start at 0; a card play keeps its resume point. - seconds_in = 0 if track_key is not None else None + # Targeted chapter/track plays start at 0; a card play keeps its resume point. + seconds_in = 0 if chapter_key is not None else None try: await client.play_card( self._player_id, @@ -432,8 +432,8 @@ def _parse_uri(media_id: str) -> tuple[str, str | None, str | None]: prefix = f"{URI_SCHEME}://{URI_CARD}/" if not media_id.startswith(prefix): raise ValueError(f"Not a Yoto media identifier: {media_id}") - parts = [segment for segment in media_id[len(prefix) :].split("/") if segment] - if not parts or len(parts) > 3: + 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}") card_id = parts[0] chapter_key = parts[1] if len(parts) > 1 else None diff --git a/tests/components/yoto/test_media_player.py b/tests/components/yoto/test_media_player.py index e86e9f4863efab..a2ef208b2c1749 100644 --- a/tests/components/yoto/test_media_player.py +++ b/tests/components/yoto/test_media_player.py @@ -239,6 +239,7 @@ async def test_play_media( pytest.param("yoto://card-test/01", id="missing_card_prefix"), 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"), ], ) async def test_play_media_invalid_uri_raises( @@ -294,6 +295,33 @@ async def test_play_media_unknown_target_raises( mock_yoto_client.play_card.assert_not_called() +async def test_play_media_card_detail_failure_raises( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A failure fetching card chapters surfaces as HomeAssistantError.""" + card = mock_yoto_client.library["card-test"] + card.chapters = {} + mock_yoto_client.update_card_detail.side_effect = YotoError("offline") + + await setup_integration(hass, mock_config_entry) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + "media_content_type": "music", + "media_content_id": "yoto://card/card-test/01", + }, + blocking=True, + ) + + mock_yoto_client.play_card.assert_not_called() + + @pytest.mark.usefixtures("mock_yoto_client") async def test_browse_media_root_lists_cards( hass: HomeAssistant,