mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
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:
co-authored by
Claude Sonnet 4.6
parent
fd69d384be
commit
2f334d657d
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user