diff --git a/homeassistant/components/collection_image/image.py b/homeassistant/components/collection_image/image.py index daac907bb114..f3e0821b3893 100644 --- a/homeassistant/components/collection_image/image.py +++ b/homeassistant/components/collection_image/image.py @@ -53,6 +53,7 @@ class CollectionImageImageEntity(ImageEntity): """Implement the image entity for Collection Image.""" path: Path | None + _current_image_id: str | None = None def __init__( self, @@ -105,12 +106,23 @@ class CollectionImageImageEntity(ImageEntity): self.set_unavailable() return + # Don't allow random shuffle to return the same image we are currently viewing. + if self._current_image_id: + filtered_new = [ + item + for item in filtered + if item.media_content_id != self._current_image_id + ] + if filtered_new: + filtered = filtered_new + child = random.choice(filtered) self._attr_available = True await self.update_image(child.media_content_id) async def update_image(self, image_id: str) -> None: """Update the entity from the image_id.""" + self._cached_image = None try: resolved = await async_resolve_media(self.hass, image_id, self.entity_id) @@ -122,6 +134,8 @@ class CollectionImageImageEntity(ImageEntity): self._attr_content_type = DEFAULT_CONTENT_TYPE self.async_write_ha_state() return + finally: + self._current_image_id = image_id if resolved.url: self.path = None diff --git a/tests/components/collection_image/conftest.py b/tests/components/collection_image/conftest.py index dbe3345628b0..4578cac2eef7 100644 --- a/tests/components/collection_image/conftest.py +++ b/tests/components/collection_image/conftest.py @@ -13,9 +13,13 @@ from homeassistant.core import HomeAssistant from .const import ( MOCK_MEDIA_DIR_URI_1, + MOCK_MEDIA_DIR_URI_2, MOCK_MEDIA_DIR_URI_BROWSE_ERROR, MOCK_MEDIA_DIR_URI_EMPTY, MOCK_MEDIA_IMAGE_URI_1, + MOCK_MEDIA_IMAGE_URI_2, + MOCK_MEDIA_IMAGE_URI_3, + MOCK_MEDIA_IMAGE_URI_4, TEST_IMAGE, ) from .helpers import directory, image @@ -60,6 +64,11 @@ def config_entry() -> MockConfigEntry: @pytest.fixture def media_source_state() -> MediaSourceState: """Return default configurable responses for the media-source mock.""" + generic_resolve = PlayMedia( + url="", + mime_type="image/png", + path=TEST_IMAGE, + ) return MediaSourceState( browse_results={ MOCK_MEDIA_DIR_URI_1: directory( @@ -74,6 +83,12 @@ def media_source_state() -> MediaSourceState: ), image(MOCK_MEDIA_IMAGE_URI_1), ), + MOCK_MEDIA_DIR_URI_2: directory( + "Three pictures", + image(MOCK_MEDIA_IMAGE_URI_2), + image(MOCK_MEDIA_IMAGE_URI_3), + image(MOCK_MEDIA_IMAGE_URI_4), + ), MOCK_MEDIA_DIR_URI_EMPTY: directory("Empty folder"), }, browse_exceptions={ @@ -82,11 +97,10 @@ def media_source_state() -> MediaSourceState: ) }, resolve_results={ - MOCK_MEDIA_IMAGE_URI_1: PlayMedia( - url="", - mime_type="image/png", - path=TEST_IMAGE, - ), + MOCK_MEDIA_IMAGE_URI_1: generic_resolve, + MOCK_MEDIA_IMAGE_URI_2: generic_resolve, + MOCK_MEDIA_IMAGE_URI_3: generic_resolve, + MOCK_MEDIA_IMAGE_URI_4: generic_resolve, }, ) diff --git a/tests/components/collection_image/const.py b/tests/components/collection_image/const.py index ce14fac9e441..2bca302edf6b 100644 --- a/tests/components/collection_image/const.py +++ b/tests/components/collection_image/const.py @@ -3,10 +3,15 @@ from pathlib import Path TEST_IMAGE = Path(__file__).parent / "test.png" +TEST_IMAGE_2 = Path(__file__).parent / "test2.png" DEFAULT_ENTITY_ID = "image.random_image" MOCK_MEDIA_DIR_URI_1 = "media-source://mymedia" +MOCK_MEDIA_DIR_URI_2 = "media-source://mymedia_multi" MOCK_MEDIA_DIR_URI_EMPTY = "media-source://mymedia_empty" MOCK_MEDIA_DIR_URI_BROWSE_ERROR = "media-source://mymedia_error" MOCK_MEDIA_IMAGE_URI_1 = "media-source://mymedia/photo" +MOCK_MEDIA_IMAGE_URI_2 = "media-source://mymedia/photo2" +MOCK_MEDIA_IMAGE_URI_3 = "media-source://mymedia/photo3" +MOCK_MEDIA_IMAGE_URI_4 = "media-source://mymedia/photo4" diff --git a/tests/components/collection_image/test2.png b/tests/components/collection_image/test2.png new file mode 100644 index 000000000000..ae83ddc6c5cb Binary files /dev/null and b/tests/components/collection_image/test2.png differ diff --git a/tests/components/collection_image/test_image.py b/tests/components/collection_image/test_image.py index 0a554a2700be..1bd470d93f86 100644 --- a/tests/components/collection_image/test_image.py +++ b/tests/components/collection_image/test_image.py @@ -22,10 +22,15 @@ from homeassistant.exceptions import HomeAssistantError from .conftest import MediaSourceMocks, MediaSourceState from .const import ( DEFAULT_ENTITY_ID, + MOCK_MEDIA_DIR_URI_2, MOCK_MEDIA_DIR_URI_BROWSE_ERROR, MOCK_MEDIA_DIR_URI_EMPTY, MOCK_MEDIA_IMAGE_URI_1, + MOCK_MEDIA_IMAGE_URI_2, + MOCK_MEDIA_IMAGE_URI_3, + MOCK_MEDIA_IMAGE_URI_4, TEST_IMAGE, + TEST_IMAGE_2, ) from .helpers import config_entry_from_uri @@ -33,6 +38,7 @@ from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator TEST_TIME = "2025-11-08T12:00:00+00:00" +TEST_TIME_NEXT = "2025-11-08T13:00:00+00:00" async def _verify_path_image( @@ -284,3 +290,83 @@ async def test_image_file_read_error( client = await hass_client() resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}") assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.usefixtures("mock_media_source") +async def test_multi_shuffle( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + media_source_state: MediaSourceState, +) -> None: + """Check that shuffling random image does not return the same image.""" + media_source_state.resolve_results[MOCK_MEDIA_IMAGE_URI_4] = PlayMedia( + url="", + mime_type="image/png", + path=TEST_IMAGE_2, + ) + + config_entry = config_entry_from_uri(MOCK_MEDIA_DIR_URI_2) + with ( + freeze_time(TEST_TIME), + patch( + "homeassistant.components.collection_image.image.random.choice", + return_value=media_source_state.browse_results[ + MOCK_MEDIA_DIR_URI_2 + ].children[1], + ) as mock_choice, + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert [image.media_content_id for image in mock_choice.call_args.args[0]] == [ + MOCK_MEDIA_IMAGE_URI_2, + MOCK_MEDIA_IMAGE_URI_3, + MOCK_MEDIA_IMAGE_URI_4, + ] + + state = hass.states.get(DEFAULT_ENTITY_ID) + + assert state and state.state == TEST_TIME + + client = await hass_client() + + resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/png" + expected_data = await hass.async_add_executor_job(TEST_IMAGE.read_bytes) + body = await resp.read() + assert body == expected_data + + with ( + freeze_time(TEST_TIME_NEXT), + patch( + "homeassistant.components.collection_image.image.random.choice", + return_value=media_source_state.browse_results[ + MOCK_MEDIA_DIR_URI_2 + ].children[2], + ) as mock_choice, + ): + await hass.services.async_call( + DOMAIN, + "shuffle", + {ATTR_ENTITY_ID: DEFAULT_ENTITY_ID}, + blocking=True, + ) + + # On the second call, URI_3 will not be included as it is the current image. + assert [image.media_content_id for image in mock_choice.call_args.args[0]] == [ + MOCK_MEDIA_IMAGE_URI_2, + MOCK_MEDIA_IMAGE_URI_4, + ] + + state = hass.states.get(DEFAULT_ENTITY_ID) + + assert state and state.state == TEST_TIME_NEXT + + resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/png" + expected_data = await hass.async_add_executor_job(TEST_IMAGE_2.read_bytes) + body = await resp.read() + assert body == expected_data