mirror of
https://github.com/home-assistant/core.git
synced 2026-05-27 19:25:18 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd86e84470 | |||
| b9f98274af | |||
| 02c7582fb6 | |||
| 00ebccf168 |
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["yoto_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["yoto-api==3.1.3"]
|
||||
"requirements": ["yoto-api==3.1.4"]
|
||||
}
|
||||
|
||||
@@ -4,22 +4,28 @@ from collections.abc import Awaitable, Callable
|
||||
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
|
||||
@@ -169,6 +177,220 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
"""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:
|
||||
# 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,
|
||||
translation_key="unknown_chapter",
|
||||
translation_placeholders={
|
||||
"chapter_key": chapter_key,
|
||||
"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))
|
||||
|
||||
# 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(
|
||||
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.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
|
||||
# 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.items()
|
||||
]
|
||||
else:
|
||||
children = [
|
||||
self._chapter_node(card.id, chapter_key, chapter)
|
||||
for chapter_key, chapter in chapters.items()
|
||||
]
|
||||
node = self._card_node(card)
|
||||
node.children = children
|
||||
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.items()
|
||||
]
|
||||
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.MUSIC,
|
||||
media_content_id=_build_uri(card.id),
|
||||
media_content_type=MediaType.MUSIC,
|
||||
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."""
|
||||
# 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),
|
||||
media_content_type=MediaType.MUSIC,
|
||||
title=chapter.title or chapter_key,
|
||||
can_play=True,
|
||||
can_expand=len(chapter.tracks) > 1,
|
||||
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:
|
||||
@@ -181,3 +403,35 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
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 because URL parsers lower-case the authority and Yoto
|
||||
IDs are case-sensitive.
|
||||
"""
|
||||
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 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
|
||||
track_key = parts[2] if len(parts) > 2 else None
|
||||
return card_id, chapter_key, track_key
|
||||
|
||||
@@ -31,12 +31,30 @@
|
||||
}
|
||||
},
|
||||
"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 media: {error}"
|
||||
},
|
||||
"unknown_card": {
|
||||
"message": "Unknown Yoto card: {card_id}"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -3411,7 +3411,7 @@ yeelightsunflower==0.0.10
|
||||
yolink-api==0.6.5
|
||||
|
||||
# homeassistant.components.yoto
|
||||
yoto-api==3.1.3
|
||||
yoto-api==3.1.4
|
||||
|
||||
# homeassistant.components.youless
|
||||
youless-api==2.2.0
|
||||
|
||||
@@ -9,11 +9,13 @@ import jwt
|
||||
import pytest
|
||||
from yoto_api import (
|
||||
Card,
|
||||
Chapter,
|
||||
Device,
|
||||
PlaybackEvent,
|
||||
PlaybackStatus,
|
||||
PlayerInfo,
|
||||
PlayerStatus,
|
||||
Track,
|
||||
YotoPlayer,
|
||||
)
|
||||
|
||||
@@ -36,12 +38,32 @@ ACCESS_TOKEN = jwt.encode({"sub": USER_ID}, "test-secret-long-enough-for-hmac-sh
|
||||
|
||||
|
||||
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),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
'platform': 'yoto',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <MediaPlayerEntityFeature: 21559>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 153143>,
|
||||
'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': <MediaPlayerEntityFeature: 21559>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 153143>,
|
||||
'volume_level': 0.5,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -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, Track, YotoError
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_SEEK_POSITION,
|
||||
@@ -17,22 +18,46 @@ from homeassistant.components.media_player import (
|
||||
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"
|
||||
|
||||
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,
|
||||
@@ -160,22 +185,393 @@ 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.usefixtures("mock_yoto_client")
|
||||
@pytest.mark.parametrize(
|
||||
"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"),
|
||||
],
|
||||
)
|
||||
async def test_play_media_invalid_uri_raises(
|
||||
hass: HomeAssistant,
|
||||
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"),
|
||||
pytest.param("yoto://card-test/01/does-not-exist", id="unknown_track"),
|
||||
],
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_yoto_client")
|
||||
async def test_browse_media_root_lists_cards(
|
||||
hass: HomeAssistant,
|
||||
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_card_with_multiple_chapters_and_multiple_tracks(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""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()
|
||||
|
||||
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] == ["Intro", "Planets"]
|
||||
assert all(c["can_expand"] for c in children)
|
||||
|
||||
|
||||
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:
|
||||
"""N-1: multi-chapter card, single-track chapters: list non-expandable chapters."""
|
||||
card = mock_yoto_client.library["card-test"]
|
||||
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()
|
||||
|
||||
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] == ["Song A", "Song B", "Song C"]
|
||||
assert not any(c["can_expand"] for c in children)
|
||||
|
||||
|
||||
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)
|
||||
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 = {}
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_yoto_client")
|
||||
async def test_browse_media_unknown_card_raises(
|
||||
hass: HomeAssistant,
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_yoto_client")
|
||||
async def test_browse_media_unknown_chapter_raises(
|
||||
hass: HomeAssistant,
|
||||
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 = {}
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user