From 9abd3a82cf7eb0999784c1f407373c5819ac718a Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:10:31 +0800 Subject: [PATCH] Always ensure a new image when shuffling (#181564) --- .../components/collection_image/image.py | 14 +++ tests/components/collection_image/conftest.py | 24 ++++- tests/components/collection_image/const.py | 5 + tests/components/collection_image/test2.png | Bin 0 -> 120 bytes .../components/collection_image/test_image.py | 86 ++++++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 tests/components/collection_image/test2.png 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 0000000000000000000000000000000000000000..ae83ddc6c5cb93ce602e409cd40f1d0147970512 GIT binary patch literal 120 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}K#X;^)4C~IxyaaMs(j9#r85lP9 zbN@+X1@buyJR*x382FBWFymBhK53vJucwP+h(vgDh4$i4@(ipYOuENxC(Q*)FnGH9 KxvX 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