Files
core/tests/components/wiim/test_media_player.py
T

1520 lines
50 KiB
Python

"""Tests for the WiiM media player via services and the state machine."""
from collections.abc import Callable
from functools import partial
from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from wiim.consts import PlayingStatus
from wiim.exceptions import WiimRequestException
from wiim.models import (
WiimGroupRole,
WiimGroupSnapshot,
WiimLoopState,
WiimMediaMetadata,
WiimPreset,
WiimQueueItem,
WiimQueueSnapshot,
WiimRepeatMode,
WiimTransportCapabilities,
)
from wiim.wiim_device import WiimDevice
from homeassistant.components.media_player import (
ATTR_ENTITY_PICTURE_LOCAL,
ATTR_GROUP_MEMBERS,
ATTR_INPUT_SOURCE,
ATTR_MEDIA_ALBUM_NAME,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_DURATION,
ATTR_MEDIA_POSITION,
ATTR_MEDIA_REPEAT,
ATTR_MEDIA_SHUFFLE,
ATTR_MEDIA_TITLE,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
DOMAIN as MEDIA_PLAYER_DOMAIN,
SERVICE_BROWSE_MEDIA,
SERVICE_JOIN,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_MEDIA_SEEK,
SERVICE_MEDIA_STOP,
SERVICE_PLAY_MEDIA,
SERVICE_REPEAT_SET,
SERVICE_SELECT_SOURCE,
SERVICE_SHUFFLE_SET,
SERVICE_UNJOIN,
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
BrowseError,
BrowseMedia,
MediaClass,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
RepeatMode,
)
import homeassistant.components.wiim as wiim_component
from homeassistant.components.wiim.const import DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ENTITY_PICTURE,
CONF_HOST,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import fire_general_update, fire_transport_update, setup_integration
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
MEDIA_PLAYER_ENTITY_ID = "media_player.test_wiim_device"
def _build_mock_wiim_device(
*,
udn: str,
name: str,
ip_address: str,
base_device: MagicMock,
) -> AsyncMock:
"""Build a mocked WiiM device for a second integration entry."""
device = AsyncMock(spec=WiimDevice)
device.udn = udn
device.name = name
device.model_name = "WiiM Pro"
device.manufacturer = "Linkplay Tech"
device.firmware_version = "4.8.523456"
device.ip_address = ip_address
device.http_api_url = f"http://{ip_address}:8080"
device.presentation_url = f"http://{ip_address}:8080/web_interface"
device.available = True
device.volume = 40
device.is_muted = False
device.supports_http_api = False
device.playing_status = PlayingStatus.STOPPED
device.play_mode = "Network"
device.loop_state = WiimLoopState(
repeat=WiimRepeatMode.OFF,
shuffle=False,
)
device.output_mode = "speaker"
device.current_media = None
device.supported_input_modes = base_device.supported_input_modes
device.supported_output_modes = base_device.supported_output_modes
device.async_get_transport_capabilities = AsyncMock(
return_value=WiimTransportCapabilities(
can_next=False,
can_previous=False,
can_repeat=False,
can_shuffle=False,
)
)
device.general_event_callback = None
device.av_transport_event_callback = None
device.rendering_control_event_callback = None
device.play_queue_event_callback = None
return device
async def test_state_machine_updates_from_device_callbacks(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test cached device state is reflected in Home Assistant."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.state == MediaPlayerState.IDLE
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.5
assert state.attributes[ATTR_INPUT_SOURCE] == "Network"
assert state.attributes["supported_features"] == int(
MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.GROUPING
)
mock_wiim_device.volume = 60
mock_wiim_device.playing_status = PlayingStatus.PLAYING
mock_wiim_device.play_mode = "Bluetooth"
mock_wiim_device.output_mode = "optical"
mock_wiim_device.loop_state = WiimLoopState(
repeat=WiimRepeatMode.ALL,
shuffle=True,
)
mock_wiim_device.current_media = WiimMediaMetadata(
title="New Song",
artist="Test Artist",
album="Test Album",
uri="http://example.com/song.flac",
duration=180,
position=42,
)
mock_wiim_device.async_get_transport_capabilities.return_value = (
WiimTransportCapabilities(
can_next=True,
can_previous=False,
can_repeat=True,
can_shuffle=True,
)
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.state == MediaPlayerState.PLAYING
assert state.attributes[ATTR_MEDIA_TITLE] == "New Song"
assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Test Album"
assert state.attributes[ATTR_MEDIA_DURATION] == 180
assert state.attributes[ATTR_MEDIA_POSITION] == 42
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.6
assert state.attributes[ATTR_INPUT_SOURCE] == "Bluetooth"
assert state.attributes[ATTR_MEDIA_REPEAT] == RepeatMode.ALL
assert state.attributes[ATTR_MEDIA_SHUFFLE] is True
assert state.attributes["supported_features"] == int(
MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.GROUPING
)
async def test_general_update_handles_offline_device(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test an offline update marks the media player unavailable."""
await setup_integration(hass, mock_config_entry)
mock_wiim_device.available = False
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
mock_wiim_controller.async_update_all_multiroom_status.assert_awaited_once_with()
@pytest.mark.usefixtures("mock_wiim_controller")
async def test_general_update_renews_http_subscriptions(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
) -> None:
"""Test an HTTP-capable device renews subscriptions on a general update."""
await setup_integration(hass, mock_config_entry)
mock_wiim_device.supports_http_api = True
await fire_general_update(hass, mock_wiim_device)
mock_wiim_device.ensure_subscriptions.assert_awaited_once_with()
async def test_state_machine_updates_from_transport_events(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test transport events update the state machine."""
await setup_integration(hass, mock_config_entry)
mock_wiim_device.current_media = WiimMediaMetadata(
title="Queued Song",
duration=240,
position=30,
)
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.PLAYING)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.state == MediaPlayerState.PLAYING
assert state.attributes[ATTR_MEDIA_TITLE] == "Queued Song"
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.PAUSED)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.state == MediaPlayerState.PAUSED
mock_wiim_device.current_media = None
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.STOPPED)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.state == MediaPlayerState.IDLE
assert state.attributes.get(ATTR_MEDIA_TITLE) is None
mock_wiim_device.event_data = {"TransportState": "unknown"}
mock_wiim_device.av_transport_event_callback(MagicMock(), [])
await hass.async_block_till_done()
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
assert state.state == MediaPlayerState.IDLE
@pytest.mark.parametrize(
("service", "device_method"),
[
pytest.param(SERVICE_MEDIA_STOP, "async_stop", id="stop"),
pytest.param(SERVICE_MEDIA_NEXT_TRACK, "async_next", id="next"),
pytest.param(SERVICE_MEDIA_PREVIOUS_TRACK, "async_previous", id="previous"),
],
)
@pytest.mark.usefixtures("mock_wiim_controller")
async def test_transport_services_call_device(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
*,
service: str,
device_method: str,
) -> None:
"""Test transport services call the matching device command."""
mock_wiim_device.async_get_transport_capabilities.return_value = (
WiimTransportCapabilities(
can_next=True,
can_previous=True,
can_repeat=False,
can_shuffle=False,
)
)
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
service,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
blocking=True,
)
getattr(mock_wiim_device, device_method).assert_awaited_once_with()
@pytest.mark.parametrize(
(
"service",
"service_data",
"device_method",
"expected_args",
"state_update",
"state_attr",
"state_value",
),
[
(
SERVICE_VOLUME_SET,
{ATTR_MEDIA_VOLUME_LEVEL: 0.75},
"async_set_volume",
(75,),
{"volume": 75},
ATTR_MEDIA_VOLUME_LEVEL,
0.75,
),
(
SERVICE_VOLUME_MUTE,
{ATTR_MEDIA_VOLUME_MUTED: True},
"async_set_mute",
(True,),
{"is_muted": True},
ATTR_MEDIA_VOLUME_MUTED,
True,
),
(
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: "Bluetooth"},
"async_set_play_mode",
("Bluetooth",),
{"play_mode": "Bluetooth"},
ATTR_INPUT_SOURCE,
"Bluetooth",
),
],
)
async def test_control_services_update_state_machine(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
*,
service: str,
service_data: dict[str, object],
device_method: str,
expected_args: tuple[object, ...],
state_update: dict[str, object],
state_attr: str,
state_value: object,
) -> None:
"""Test control services are exercised through Home Assistant."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
service,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, **service_data},
blocking=True,
)
getattr(mock_wiim_device, device_method).assert_awaited_once_with(*expected_args)
for attr_name, attr_value in state_update.items():
setattr(mock_wiim_device, attr_name, attr_value)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.attributes[state_attr] == state_value
@pytest.mark.parametrize(
"device_error",
[WiimRequestException("request failed"), RuntimeError("command failed")],
)
async def test_command_error_uses_translation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
device_error: Exception,
) -> None:
"""Test command errors raise a translated Home Assistant error."""
await setup_integration(hass, mock_config_entry)
mock_wiim_device.async_play.side_effect = device_error
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_PLAY,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "command_failed"
assert exc_info.value.translation_placeholders == {
"command": "async_media_play",
"entity_id": MEDIA_PLAYER_ENTITY_ID,
}
async def test_repeat_and_shuffle_services_update_state_machine(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test repeat and shuffle go through services and state updates."""
await setup_integration(hass, mock_config_entry)
mock_wiim_device.async_get_transport_capabilities.return_value = (
WiimTransportCapabilities(
can_next=True,
can_previous=True,
can_repeat=True,
can_shuffle=True,
)
)
await fire_general_update(hass, mock_wiim_device)
repeat_loop_mode = object()
mock_wiim_device.build_loop_mode.return_value = repeat_loop_mode
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_REPEAT_SET,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_REPEAT: RepeatMode.ALL},
blocking=True,
)
mock_wiim_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.ALL, False)
mock_wiim_device.async_set_loop_mode.assert_awaited_once_with(repeat_loop_mode)
mock_wiim_device.loop_state = WiimLoopState(
repeat=WiimRepeatMode.ALL,
shuffle=False,
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.attributes[ATTR_MEDIA_REPEAT] == RepeatMode.ALL
mock_wiim_device.build_loop_mode.reset_mock()
mock_wiim_device.async_set_loop_mode.reset_mock()
shuffle_loop_mode = object()
mock_wiim_device.build_loop_mode.return_value = shuffle_loop_mode
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_SHUFFLE_SET,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_SHUFFLE: True},
blocking=True,
)
mock_wiim_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.ALL, True)
mock_wiim_device.async_set_loop_mode.assert_awaited_once_with(shuffle_loop_mode)
mock_wiim_device.loop_state = WiimLoopState(
repeat=WiimRepeatMode.ALL,
shuffle=True,
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.attributes[ATTR_MEDIA_SHUFFLE] is True
async def test_play_pause_and_seek_services_update_state_machine(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test playback services drive the device and state machine."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_PLAY,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
blocking=True,
)
mock_wiim_device.async_play.assert_awaited_once()
mock_wiim_device.current_media = WiimMediaMetadata(
title="Playing Song",
duration=200,
position=12,
)
mock_wiim_device.playing_status = PlayingStatus.PLAYING
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.state == MediaPlayerState.PLAYING
assert state.attributes[ATTR_MEDIA_TITLE] == "Playing Song"
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_PAUSE,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
blocking=True,
)
mock_wiim_device.async_pause.assert_awaited_once()
mock_wiim_device.sync_device_duration_and_position.assert_awaited_once()
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.PAUSED)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.state == MediaPlayerState.PAUSED
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_SEEK,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, "seek_position": 60},
blocking=True,
)
mock_wiim_device.async_seek.assert_awaited_once_with(60)
mock_wiim_device.current_media = WiimMediaMetadata(
title="Playing Song",
duration=200,
position=60,
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.attributes[ATTR_MEDIA_POSITION] == 60
async def test_follower_routes_commands_and_reads_leader_metadata(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test follower commands are routed to the leader device."""
await setup_integration(hass, mock_config_entry)
leader_device = AsyncMock(spec=WiimDevice)
leader_device.udn = "uuid:leader-1234"
leader_device.name = "Leader WiiM Device"
leader_device.playing_status = PlayingStatus.STOPPED
leader_device.play_mode = "Network"
leader_device.loop_state = WiimLoopState(
repeat=WiimRepeatMode.OFF,
shuffle=False,
)
leader_device.output_mode = "speaker"
leader_device.current_media = None
leader_device.async_get_transport_capabilities = AsyncMock(
return_value=WiimTransportCapabilities(
can_next=True,
can_previous=False,
can_repeat=True,
can_shuffle=False,
)
)
mock_wiim_controller.get_group_snapshot.return_value = WiimGroupSnapshot(
role=WiimGroupRole.FOLLOWER,
leader_udn=leader_device.udn,
member_udns=(leader_device.udn, mock_wiim_device.udn),
)
mock_wiim_controller.get_device.side_effect = lambda udn: (
leader_device if udn == leader_device.udn else mock_wiim_device
)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_PLAY,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
blocking=True,
)
leader_device.async_play.assert_awaited_once()
mock_wiim_device.async_play.assert_not_awaited()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_SEEK,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, "seek_position": 90},
blocking=True,
)
leader_device.async_seek.assert_awaited_once_with(90)
mock_wiim_device.async_seek.assert_not_awaited()
leader_device.playing_status = PlayingStatus.PLAYING
leader_device.play_mode = "Spotify"
leader_device.current_media = WiimMediaMetadata(
title="Leader Song",
album="Leader Album",
duration=210,
position=90,
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.state == MediaPlayerState.PLAYING
assert state.attributes[ATTR_MEDIA_TITLE] == "Leader Song"
assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Leader Album"
assert state.attributes[ATTR_INPUT_SOURCE] == "Spotify"
assert state.attributes[ATTR_MEDIA_POSITION] == 90
async def test_group_refresh_dispatcher_sends_to_followers_and_refreshes_member(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test leader group refresh signal makes a real follower entity refresh."""
follower_device = _build_mock_wiim_device(
udn="uuid:follower-1234",
name="Follower WiiM Device",
ip_address="192.168.1.101",
base_device=mock_wiim_device,
)
wiim_component.async_create_wiim_device.side_effect = [
mock_wiim_device,
follower_device,
]
follower_config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.101"},
title="Follower WiiM Device",
unique_id=follower_device.udn,
)
await setup_integration(hass, mock_config_entry)
await setup_integration(hass, follower_config_entry)
def group_snapshot_for(udn: str) -> WiimGroupSnapshot:
if udn == mock_wiim_device.udn:
return WiimGroupSnapshot(
role=WiimGroupRole.LEADER,
leader_udn=mock_wiim_device.udn,
member_udns=(mock_wiim_device.udn, follower_device.udn),
)
return WiimGroupSnapshot(
role=WiimGroupRole.FOLLOWER,
leader_udn=mock_wiim_device.udn,
member_udns=(mock_wiim_device.udn, follower_device.udn),
)
mock_wiim_controller.get_group_snapshot.side_effect = group_snapshot_for
mock_wiim_controller.get_device.side_effect = lambda udn: (
mock_wiim_device if udn == mock_wiim_device.udn else follower_device
)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_JOIN,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_GROUP_MEMBERS: ["media_player.follower_wiim_device"],
},
blocking=True,
)
mock_wiim_controller.async_join_group.assert_awaited_once_with(
mock_wiim_device.udn,
[follower_device.udn],
)
mock_wiim_device.playing_status = PlayingStatus.PLAYING
mock_wiim_device.play_mode = "Spotify"
mock_wiim_device.current_media = WiimMediaMetadata(
title="Leader Signal Song",
album="Leader Signal Album",
duration=240,
position=33,
)
await fire_general_update(hass, mock_wiim_device)
follower_state = hass.states.get("media_player.follower_wiim_device")
assert follower_state is not None
assert follower_state.state == MediaPlayerState.PLAYING
assert follower_state.attributes[ATTR_MEDIA_TITLE] == "Leader Signal Song"
assert follower_state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Leader Signal Album"
assert follower_state.attributes[ATTR_INPUT_SOURCE] == "Spotify"
assert follower_state.attributes[ATTR_MEDIA_POSITION] == 33
async def test_follower_routes_repeat_shuffle_and_source_commands_to_leader(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test follower repeat, shuffle, and source changes are sent to the leader."""
await setup_integration(hass, mock_config_entry)
leader_device = AsyncMock(spec=WiimDevice)
leader_device.udn = "uuid:leader-1234"
leader_device.playing_status = PlayingStatus.STOPPED
leader_device.current_media = None
leader_device.loop_state = WiimLoopState(
repeat=WiimRepeatMode.OFF,
shuffle=False,
)
leader_device.play_mode = "Network"
leader_device.async_get_transport_capabilities = AsyncMock(
return_value=WiimTransportCapabilities(
can_next=True,
can_previous=True,
can_repeat=True,
can_shuffle=True,
)
)
mock_wiim_controller.get_group_snapshot.return_value = WiimGroupSnapshot(
role=WiimGroupRole.FOLLOWER,
leader_udn=leader_device.udn,
member_udns=(leader_device.udn, mock_wiim_device.udn),
)
mock_wiim_controller.get_device.side_effect = lambda udn: (
leader_device if udn == leader_device.udn else mock_wiim_device
)
await fire_general_update(hass, mock_wiim_device)
repeat_loop_mode = object()
leader_device.build_loop_mode.return_value = repeat_loop_mode
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_REPEAT_SET,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_REPEAT: RepeatMode.ALL},
blocking=True,
)
leader_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.ALL, False)
leader_device.async_set_loop_mode.assert_awaited_once_with(repeat_loop_mode)
mock_wiim_device.async_set_loop_mode.assert_not_awaited()
leader_device.build_loop_mode.reset_mock()
leader_device.async_set_loop_mode.reset_mock()
shuffle_loop_mode = object()
leader_device.build_loop_mode.return_value = shuffle_loop_mode
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_SHUFFLE_SET,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_SHUFFLE: True},
blocking=True,
)
leader_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.OFF, True)
leader_device.async_set_loop_mode.assert_awaited_once_with(shuffle_loop_mode)
mock_wiim_device.async_set_loop_mode.assert_not_awaited()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_SELECT_SOURCE,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_INPUT_SOURCE: "Bluetooth"},
blocking=True,
)
leader_device.async_set_play_mode.assert_awaited_once_with("Bluetooth")
mock_wiim_device.async_set_play_mode.assert_not_awaited()
async def test_play_media_services_call_device_commands(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test play_media services are driven through Home Assistant."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_CONTENT_ID: "1",
},
blocking=True,
)
mock_wiim_device.play_preset.assert_awaited_once_with(1)
mock_wiim_device.current_media = WiimMediaMetadata(title="Preset 1")
mock_wiim_device.playing_status = PlayingStatus.PLAYING
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.state == MediaPlayerState.PLAYING
assert state.attributes[ATTR_MEDIA_TITLE] == "Preset 1"
mock_wiim_device.play_preset.reset_mock()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: "wiim_library",
ATTR_MEDIA_CONTENT_ID: "2",
},
blocking=True,
)
mock_wiim_device.play_preset.assert_awaited_once_with(2)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: MediaType.TRACK,
ATTR_MEDIA_CONTENT_ID: "2",
},
blocking=True,
)
mock_wiim_device.async_play_queue_with_index.assert_awaited_once_with(2)
@pytest.mark.parametrize(
("media_type", "media_id", "translation_key", "translation_placeholders"),
[
(
"wiim_library",
"not-a-preset",
"invalid_preset_id",
{"media_id": "not-a-preset"},
),
(
MediaType.TRACK,
"not-a-track",
"invalid_track_id",
{"media_id": "not-a-track"},
),
(
"unsupported",
"1",
"unsupported_media_type",
{"media_type": "unsupported"},
),
(
MediaType.URL,
"http://example.com/song.mp3",
"direct_url_playback_unsupported",
None,
),
],
)
async def test_play_media_validation_error_uses_translation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
*,
media_type: MediaType | str,
media_id: str,
translation_key: str,
translation_placeholders: dict[str, str] | None,
) -> None:
"""Test play media validation errors are translated."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: media_type,
ATTR_MEDIA_CONTENT_ID: media_id,
},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == translation_key
assert exc_info.value.translation_placeholders == translation_placeholders
@pytest.mark.parametrize("media_type", [MediaType.MUSIC, MediaType.URL])
async def test_play_media_url_service_uses_processed_url(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
media_type: MediaType,
) -> None:
"""Test direct URL playback goes through the URL processor."""
await setup_integration(hass, mock_config_entry)
mock_wiim_device.supports_http_api = True
with patch(
"homeassistant.components.wiim.media_player.async_process_play_media_url",
return_value="http://processed/song.mp3",
):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: media_type,
ATTR_MEDIA_CONTENT_ID: "http://example.com/song.mp3",
},
blocking=True,
)
mock_wiim_device.play_url.assert_awaited_once_with("http://processed/song.mp3")
mock_wiim_device.play_preset.assert_not_awaited()
async def test_play_media_source_service_uses_resolved_url(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test media_source playback goes through the resolver."""
await setup_integration(hass, mock_config_entry)
mock_wiim_device.supports_http_api = True
with (
patch(
"homeassistant.components.wiim.media_player.media_source.is_media_source_id",
return_value=True,
),
patch(
"homeassistant.components.wiim.media_player.media_source.async_resolve_media",
AsyncMock(return_value=MagicMock(url="http://resolved/song.mp3")),
),
patch(
"homeassistant.components.wiim.media_player.async_process_play_media_url",
return_value="http://processed/song.mp3",
),
):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/song.mp3",
},
blocking=True,
)
mock_wiim_device.play_url.assert_awaited_once_with("http://processed/song.mp3")
async def test_browse_media_service_returns_wiim_library(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test browsing WiiM presets and queue via the media_player service."""
await setup_integration(hass, mock_config_entry)
root_result = await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_BROWSE_MEDIA,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
blocking=True,
return_response=True,
)
root_browse = root_result[MEDIA_PLAYER_ENTITY_ID]
assert root_browse.title == mock_wiim_device.name
assert [child.title for child in root_browse.children] == ["Presets", "Queue"]
mock_wiim_device.async_get_presets.return_value = (
WiimPreset(1, "Preset 1", "http://image1"),
WiimPreset(2, "Preset 2", "http://image2"),
)
preset_result = await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_BROWSE_MEDIA,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST,
ATTR_MEDIA_CONTENT_ID: "wiim_library/library_root/favorites",
},
blocking=True,
return_response=True,
)
preset_browse = preset_result[MEDIA_PLAYER_ENTITY_ID]
assert [child.title for child in preset_browse.children] == ["Preset 1", "Preset 2"]
mock_wiim_device.async_get_queue_snapshot.return_value = WiimQueueSnapshot(
items=(
WiimQueueItem(1, "Song A", "http://image-a"),
WiimQueueItem(2, "Song B", "http://image-b"),
),
is_active=True,
)
queue_result = await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_BROWSE_MEDIA,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST,
ATTR_MEDIA_CONTENT_ID: "wiim_library/library_root/playlists",
},
blocking=True,
return_response=True,
)
queue_browse = queue_result[MEDIA_PLAYER_ENTITY_ID]
assert [child.title for child in queue_browse.children] == ["Song A", "Song B"]
@pytest.mark.usefixtures("mock_wiim_controller")
async def test_browse_media_does_not_refresh_entity_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
) -> None:
"""Test browsing media does not refresh entity state after success."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
original_volume = state.attributes[ATTR_MEDIA_VOLUME_LEVEL]
mock_wiim_device.volume = 75
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_BROWSE_MEDIA,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
blocking=True,
return_response=True,
)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == original_volume
async def test_browse_media_service_includes_media_sources_when_supported(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test media sources are exposed through browse_media when HTTP API exists."""
await setup_integration(hass, mock_config_entry)
mock_wiim_device.supports_http_api = True
media_source_root = BrowseMedia(
media_class=MediaClass.DIRECTORY,
media_content_id="media-source://media_source",
media_content_type=MediaType.APPS,
title="Media Sources",
can_play=False,
can_expand=True,
children=[
BrowseMedia(
media_class=MediaClass.MUSIC,
media_content_id="media-source://media_source/local/song.mp3",
media_content_type="audio/mpeg",
title="song.mp3",
can_play=True,
can_expand=False,
)
],
)
with patch(
"homeassistant.components.wiim.media_player.media_source.async_browse_media",
AsyncMock(return_value=media_source_root),
):
result = await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_BROWSE_MEDIA,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
blocking=True,
return_response=True,
)
browse = result[MEDIA_PLAYER_ENTITY_ID]
assert [child.title for child in browse.children] == [
"Presets",
"Queue",
"song.mp3",
]
@pytest.mark.parametrize(
(
"media_content_type",
"media_content_id",
"translation_key",
"translation_placeholders",
),
[
pytest.param(
MediaType.MUSIC,
"media-source://media_source/local/song.mp3",
"media_sources_unsupported",
None,
id="media-source-unsupported",
),
pytest.param(
MediaType.PLAYLIST,
"wiim_library/invalid",
"invalid_browse_path",
{"media_content_id": "wiim_library/invalid"},
id="invalid-path",
),
],
)
async def test_browse_media_error_uses_translation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
*,
media_content_type: MediaType,
media_content_id: str,
translation_key: str,
translation_placeholders: dict[str, str] | None,
) -> None:
"""Test browse media errors are translated."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(BrowseError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_BROWSE_MEDIA,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: media_content_type,
ATTR_MEDIA_CONTENT_ID: media_content_id,
},
blocking=True,
return_response=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == translation_key
assert exc_info.value.translation_placeholders == translation_placeholders
@pytest.mark.parametrize(
("sdk_method", "media_content_id"),
[
pytest.param(
"async_get_presets",
"wiim_library/library_root/favorites",
id="presets",
),
pytest.param(
"async_get_queue_snapshot",
"wiim_library/library_root/playlists",
id="queue",
),
],
)
@pytest.mark.usefixtures("mock_wiim_controller")
async def test_browse_media_sdk_error_uses_translation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
*,
sdk_method: str,
media_content_id: str,
) -> None:
"""Test browse media SDK errors raise a translated Home Assistant error."""
await setup_integration(hass, mock_config_entry)
getattr(mock_wiim_device, sdk_method).side_effect = WiimRequestException(
"request failed"
)
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_BROWSE_MEDIA,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST,
ATTR_MEDIA_CONTENT_ID: media_content_id,
},
blocking=True,
return_response=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "command_failed"
assert exc_info.value.translation_placeholders == {
"command": "async_browse_media",
"entity_id": MEDIA_PLAYER_ENTITY_ID,
}
async def test_join_and_unjoin_services_use_resolved_member_udns(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test grouping services call the controller with resolved UDNs."""
follower_device = _build_mock_wiim_device(
udn="uuid:follower-1234",
name="Follower WiiM Device",
ip_address="192.168.1.101",
base_device=mock_wiim_device,
)
second_follower_device = _build_mock_wiim_device(
udn="uuid:follower-5678",
name="Second Follower WiiM Device",
ip_address="192.168.1.102",
base_device=mock_wiim_device,
)
wiim_component.async_create_wiim_device.side_effect = [
mock_wiim_device,
follower_device,
second_follower_device,
]
follower_config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.101"},
title=follower_device.name,
unique_id=follower_device.udn,
)
second_follower_config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.102"},
title=second_follower_device.name,
unique_id=second_follower_device.udn,
)
await setup_integration(hass, mock_config_entry)
await setup_integration(hass, follower_config_entry)
await setup_integration(hass, second_follower_config_entry)
follower_entity_id = "media_player.follower_wiim_device"
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_JOIN,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_GROUP_MEMBERS: [
MEDIA_PLAYER_ENTITY_ID,
follower_entity_id,
],
},
blocking=True,
)
mock_wiim_controller.async_join_group.assert_awaited_once_with(
mock_wiim_device.udn, [follower_device.udn]
)
mock_wiim_controller.async_join_group.reset_mock()
leader_device = AsyncMock(spec=WiimDevice)
leader_device.udn = "uuid:leader-1234"
leader_device.name = "Leader WiiM Device"
leader_device.playing_status = PlayingStatus.STOPPED
leader_device.play_mode = "Network"
leader_device.loop_state = WiimLoopState(
repeat=WiimRepeatMode.OFF,
shuffle=False,
)
leader_device.current_media = None
second_follower_entity_id = "media_player.second_follower_wiim_device"
mock_wiim_controller.get_group_snapshot.return_value = WiimGroupSnapshot(
role=WiimGroupRole.FOLLOWER,
leader_udn=leader_device.udn,
member_udns=(leader_device.udn, mock_wiim_device.udn),
)
mock_wiim_controller.get_device.side_effect = lambda udn: (
leader_device if udn == leader_device.udn else mock_wiim_device
)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_JOIN,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_GROUP_MEMBERS: [
MEDIA_PLAYER_ENTITY_ID,
second_follower_entity_id,
],
},
blocking=True,
)
mock_wiim_controller.async_join_group.assert_awaited_once_with(
leader_device.udn, [second_follower_device.udn]
)
mock_wiim_controller.async_join_group.reset_mock()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_UNJOIN,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
blocking=True,
)
mock_wiim_controller.async_ungroup_device.assert_awaited_once_with(
mock_wiim_device.udn
)
async def test_join_service_invalid_member_uses_translation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
mock_wiim_controller: MagicMock,
) -> None:
"""Test joining an invalid member raises a translated validation error."""
await setup_integration(hass, mock_config_entry)
invalid_entity_id = "media_player.unknown_wiim_device"
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_JOIN,
{
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
ATTR_GROUP_MEMBERS: [invalid_entity_id],
},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "invalid_grouping_entity"
assert exc_info.value.translation_placeholders == {"entity_id": invalid_entity_id}
mock_wiim_controller.async_join_group.assert_not_awaited()
@pytest.mark.parametrize(
("device_ip", "image_url", "expected_disabled_request_options", "content_type"),
[
(
"192.168.1.100",
"https://192.168.1.100/local-artwork.jpg",
{"allow_redirects", "ssl"},
"image/jpeg ; charset=binary",
),
(
"192.168.1.100",
"http://192.168.1.100/local-artwork.jpg",
set(),
"image/jpeg",
),
(
"192.168.1.100",
"https://wiim-artwork.example/remote-artwork.jpg",
set(),
"image/jpeg",
),
("8.8.8.8", "https://8.8.8.8/public-artwork.jpg", set(), "image/jpeg"),
(
"169.254.10.20",
"https://169.254.10.20/link-local-artwork.jpg",
{"allow_redirects", "ssl"},
"image/jpeg",
),
],
)
@pytest.mark.usefixtures("mock_wiim_controller")
async def test_media_image_ssl_verification(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
*,
device_ip: str,
image_url: str,
expected_disabled_request_options: set[str],
content_type: str,
) -> None:
"""Test SSL verification is disabled only for local WiiM HTTPS artwork."""
mock_wiim_device.ip_address = device_ip
await setup_integration(hass, mock_config_entry)
mock_wiim_device.current_media = WiimMediaMetadata(
title="Test artwork",
uri="http://example.com/test-artwork.flac",
image_url=image_url,
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
aioclient_mock.get(
image_url,
content=b"image-bytes",
headers={"Content-Type": content_type},
)
websession = async_get_clientsession(hass)
with patch.object(websession, "get", wraps=websession.get) as mock_get:
response = await (await hass_client()).get(
state.attributes[ATTR_ENTITY_PICTURE_LOCAL]
)
assert response.status == 200
assert await response.read() == b"image-bytes"
assert response.headers["Content-Type"] == "image/jpeg"
image_get_calls = [
call
for call in mock_get.call_args_list
if call.args and str(call.args[0]).partition("#")[0] == image_url
]
assert len(image_get_calls) == 1
image_get_call = image_get_calls[0]
request_options = image_get_call.kwargs
disabled_request_options = {
option
for option in ("allow_redirects", "ssl")
if request_options.get(option) is False
}
assert disabled_request_options == expected_disabled_request_options
@pytest.mark.parametrize(
("status", "exception_factory"),
[
(HTTPStatus.NOT_FOUND, lambda: None),
(HTTPStatus.OK, partial(aiohttp.ClientError)),
],
)
@pytest.mark.usefixtures("mock_wiim_controller")
async def test_local_https_media_image_fetch_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
*,
status: HTTPStatus,
exception_factory: Callable[[], aiohttp.ClientError | None],
) -> None:
"""Test a local HTTPS artwork request error returns no proxy image."""
await setup_integration(hass, mock_config_entry)
image_url = "https://192.168.1.100/unavailable-artwork.jpg"
mock_wiim_device.current_media = WiimMediaMetadata(
title="Unavailable artwork",
uri="http://example.com/unavailable-artwork.flac",
image_url=image_url,
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
aioclient_mock.get(
image_url,
status=status,
exc=exception_factory(),
)
response = await (await hass_client()).get(
state.attributes[ATTR_ENTITY_PICTURE_LOCAL]
)
assert response.status == 404
@pytest.mark.usefixtures("mock_wiim_controller")
async def test_media_image_hash_changes_for_same_local_artwork_url(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
) -> None:
"""Test the media proxy returns new artwork when its URL is reused."""
await setup_integration(hass, mock_config_entry)
image_url = "https://192.168.1.100/changing-album-art.jpg"
client = await hass_client()
mock_wiim_device.current_media = WiimMediaMetadata(
title="First Song",
artist="Artist",
album="Album",
uri="http://example.com/first.flac",
image_url=image_url,
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
assert state.attributes[ATTR_ENTITY_PICTURE] == image_url
first_local_image = state.attributes[ATTR_ENTITY_PICTURE_LOCAL]
aioclient_mock.get(
image_url,
content=b"first-image",
headers={"Content-Type": "image/jpeg"},
)
media_response = await client.get(first_local_image)
assert media_response.status == 200
first_image = await media_response.read()
assert first_image == b"first-image"
mock_wiim_device.current_media = WiimMediaMetadata(
title="Second Song",
artist="Artist",
album="Album",
uri="http://example.com/second.flac",
image_url=image_url,
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
assert state.attributes[ATTR_ENTITY_PICTURE] == image_url
second_local_image = state.attributes[ATTR_ENTITY_PICTURE_LOCAL]
assert second_local_image != first_local_image
aioclient_mock.clear_requests()
aioclient_mock.get(
image_url,
content=b"second-image",
headers={"Content-Type": "image/jpeg"},
)
media_response = await client.get(second_local_image)
assert media_response.status == 200
second_image = await media_response.read()
assert second_image == b"second-image"
assert second_image != first_image