Fix apple_tv HomePod streaming failures when device is idle (#170033)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel Feinberg
2026-06-01 16:04:26 +00:00
committed by Franck Nijhof
co-authored by Claude Sonnet 4.6
parent fd69d384be
commit 2f334d657d
3 changed files with 246 additions and 16 deletions
@@ -38,11 +38,13 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import AppleTvConfigEntry, AppleTVManager
from .browse_media import build_app_list
from .const import DOMAIN
from .entity import AppleTVEntity
_LOGGER = logging.getLogger(__name__)
@@ -126,7 +128,6 @@ class AppleTvMediaPlayer(
@callback
def async_device_connected(self, atv: AppleTV) -> None:
"""Handle when connection is made to device."""
# NB: Do not use _is_feature_available here as it only works when playing
if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
atv.push_updater.listener = self
atv.push_updater.start()
@@ -352,21 +353,41 @@ class AppleTvMediaPlayer(
media_id = async_process_play_media_url(self.hass, play_item.url)
media_type = MediaType.MUSIC
if self._is_feature_available(FeatureName.StreamFile) and (
use_stream_file = self._is_feature_available(FeatureName.StreamFile) and (
media_type == MediaType.MUSIC or await is_streamable(media_id)
):
_LOGGER.debug("Streaming %s via RAOP", media_id)
await self.atv.stream.stream_file(media_id)
elif self._is_feature_available(FeatureName.PlayUrl) and (
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
):
_LOGGER.debug("Playing %s via AirPlay", media_id)
await self.atv.stream.play_url(media_id)
else:
_LOGGER.error(
"Media streaming is not possible with current configuration for %s",
media_id,
)
)
try:
if use_stream_file:
_LOGGER.debug("Streaming %s via RAOP", media_id)
await self.atv.stream.stream_file(media_id)
elif self._is_feature_available(FeatureName.PlayUrl) and (
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
):
_LOGGER.debug("Playing %s via AirPlay", media_id)
await self.atv.stream.play_url(media_id)
else:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="streaming_not_supported",
)
except exceptions.NotSupportedError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="streaming_not_supported",
) from ex
except (
exceptions.BlockedStateError,
exceptions.ConnectionLostError,
exceptions.InvalidStateError,
exceptions.OperationTimeoutError,
exceptions.PlaybackError,
exceptions.ProtocolError,
) as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="stream_failed",
) from ex
@property
def media_image_hash(self) -> str | None:
@@ -460,7 +481,7 @@ class AppleTvMediaPlayer(
def _is_feature_available(self, feature: FeatureName) -> bool:
"""Return if a feature is available."""
if self.atv and self._playing:
if self.atv:
return self.atv.features.in_state(FeatureState.Available, feature)
return False
@@ -81,6 +81,12 @@
},
"not_connected": {
"message": "Apple TV is not connected"
},
"stream_failed": {
"message": "Failed to stream media to the Apple TV"
},
"streaming_not_supported": {
"message": "Streaming the requested media is not supported"
}
},
"options": {
@@ -3,20 +3,38 @@
from pathlib import Path
from unittest.mock import AsyncMock, patch
from pyatv.const import FeatureName, FeatureState
from pyatv.exceptions import (
BlockedStateError,
ConnectionLostError,
InvalidStateError,
NotSupportedError,
OperationTimeoutError,
PlaybackError,
ProtocolError,
)
import pytest
from homeassistant.components.apple_tv.const import DOMAIN
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
DOMAIN as MP_DOMAIN,
SERVICE_PLAY_MEDIA,
BrowseMedia,
MediaClass,
MediaType,
)
from homeassistant.components.media_source import PlayMedia
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.typing import WebSocketGenerator
ENTITY_ID = "media_player.living_room_living_room"
_MUSIC_URL = "http://example.local:8123/api/tts_proxy/abc.mp3"
_VIDEO_URL = "http://example.local:8123/video.mp4"
pytestmark = pytest.mark.usefixtures("init_integration")
@@ -86,3 +104,188 @@ async def test_play_media_launches_app(
mock_atv.apps.launch_app.assert_awaited_once_with("com.netflix.Netflix")
mock_atv.stream.stream_file.assert_not_called()
@pytest.mark.parametrize(
("media_type", "media_id", "called_method", "stream_file_state"),
[
pytest.param(
MediaType.MUSIC,
_MUSIC_URL,
"stream_file",
FeatureState.Available,
id="music_via_raop",
),
pytest.param(
MediaType.VIDEO,
_VIDEO_URL,
"play_url",
FeatureState.Unsupported,
id="video_via_airplay",
),
],
)
async def test_play_media_selects_streaming_method(
hass: HomeAssistant,
mock_atv: AsyncMock,
media_type: MediaType,
media_id: str,
called_method: str,
stream_file_state: FeatureState,
) -> None:
"""Streaming path is selected from device feature state, not _playing."""
mock_atv.features.set_state(FeatureName.StreamFile, stream_file_state)
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: media_type,
ATTR_MEDIA_CONTENT_ID: media_id,
},
blocking=True,
)
getattr(mock_atv.stream, called_method).assert_awaited_once_with(media_id)
async def test_play_media_falls_back_to_play_url(
hass: HomeAssistant,
mock_atv: AsyncMock,
) -> None:
"""When StreamFile is unavailable, play_url is used for video."""
mock_atv.features.set_state(FeatureName.StreamFile, FeatureState.Unsupported)
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: MediaType.VIDEO,
ATTR_MEDIA_CONTENT_ID: _VIDEO_URL,
},
blocking=True,
)
mock_atv.stream.play_url.assert_awaited_once_with(_VIDEO_URL)
mock_atv.stream.stream_file.assert_not_called()
async def test_play_media_raises_when_no_streaming_method(
hass: HomeAssistant,
mock_atv: AsyncMock,
) -> None:
"""Raise HomeAssistantError when no streaming method is available."""
mock_atv.features.set_state(FeatureName.StreamFile, FeatureState.Unsupported)
mock_atv.features.set_state(FeatureName.PlayUrl, FeatureState.Unsupported)
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_CONTENT_ID: _MUSIC_URL,
},
blocking=True,
)
assert exc_info.value.translation_key == "streaming_not_supported"
assert exc_info.value.translation_domain == DOMAIN
mock_atv.stream.stream_file.assert_not_called()
mock_atv.stream.play_url.assert_not_called()
@pytest.mark.parametrize(
("stream_attr", "media_type", "media_id", "stream_file_state"),
[
(
"stream_file",
MediaType.MUSIC,
_MUSIC_URL,
FeatureState.Available,
),
(
"play_url",
MediaType.VIDEO,
_VIDEO_URL,
FeatureState.Unsupported,
),
],
)
@pytest.mark.parametrize(
("exc_class", "expected_translation_key"),
[
(BlockedStateError, "stream_failed"),
(ConnectionLostError, "stream_failed"),
(InvalidStateError, "stream_failed"),
(NotSupportedError, "streaming_not_supported"),
(OperationTimeoutError, "stream_failed"),
(PlaybackError, "stream_failed"),
(ProtocolError, "stream_failed"),
],
)
async def test_play_media_raises_ha_error_on_pyatv_failure(
hass: HomeAssistant,
mock_atv: AsyncMock,
stream_attr: str,
media_type: MediaType,
media_id: str,
stream_file_state: FeatureState,
exc_class: type[Exception],
expected_translation_key: str,
) -> None:
"""Pyatv streaming exceptions surface as a translated HomeAssistantError."""
mock_atv.features.set_state(FeatureName.StreamFile, stream_file_state)
getattr(mock_atv.stream, stream_attr).side_effect = exc_class("error")
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: media_type,
ATTR_MEDIA_CONTENT_ID: media_id,
},
blocking=True,
)
assert exc_info.value.translation_key == expected_translation_key
assert exc_info.value.translation_domain == DOMAIN
async def test_browse_media_uses_media_source(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""async_browse_media routes to media_source when streaming is available."""
browse_result = BrowseMedia(
title="Media",
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="",
can_play=False,
can_expand=True,
children=[],
)
with patch(
"homeassistant.components.apple_tv.media_player.media_source.async_browse_media",
new_callable=AsyncMock,
return_value=browse_result,
) as mock_browse:
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"]
mock_browse.assert_called_once()