Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 259 additions & 2 deletions homeassistant/components/yoto/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,31 @@
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
Comment thread
piitaya marked this conversation as resolved.

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"
# The URI authority ("card") names the content type. Only cards exist today;
# reserving it leaves room for groups without breaking URIs.
URI_CARD = "card"

PARALLEL_UPDATES = 0

# Yoto players expose 16 hardware volume steps.
Expand Down Expand Up @@ -56,6 +65,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
Expand Down Expand Up @@ -170,6 +181,220 @@ 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:
# 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
Comment thread
piitaya marked this conversation as resolved.

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))
Comment thread
piitaya marked this conversation as resolved.

Comment thread
piitaya marked this conversation as resolved.
# 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,
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,
Comment thread
piitaya marked this conversation as resolved.
)

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:
Expand All @@ -182,3 +407,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://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:
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://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}://{URI_CARD}/"
if not media_id.startswith(prefix):
raise ValueError(f"Not a Yoto media 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}")
Comment thread
piitaya marked this conversation as resolved.
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
Comment thread
piitaya marked this conversation as resolved.
18 changes: 18 additions & 0 deletions homeassistant/components/yoto/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}
Comment thread
piitaya marked this conversation as resolved.
Expand Down
24 changes: 23 additions & 1 deletion tests/components/yoto/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
import pytest
from yoto_api import (
Card,
Chapter,
Device,
PlaybackEvent,
PlaybackStatus,
PlayerInfo,
PlayerStatus,
Track,
YotoPlayer,
)

Expand All @@ -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),
},
),
},
)


Expand Down
4 changes: 2 additions & 2 deletions tests/components/yoto/snapshots/test_media_player.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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>,
Expand Down
Loading
Loading