diff --git a/homeassistant/components/collection_image/config_flow.py b/homeassistant/components/collection_image/config_flow.py index bb529de913f5..35f29d58f881 100644 --- a/homeassistant/components/collection_image/config_flow.py +++ b/homeassistant/components/collection_image/config_flow.py @@ -4,16 +4,21 @@ from typing import Any, override import voluptuous as vol +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.components.media_player import BrowseError, MediaClass -from homeassistant.components.media_source import async_browse_media +from homeassistant.components.media_source import URI_SCHEME, async_browse_media from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.helpers.selector import MediaSelector from .const import CONF_MEDIA, DOMAIN +IMAGE_MEDIA_URI = f"{URI_SCHEME}{IMAGE_DOMAIN}" + STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_MEDIA): MediaSelector({"accept": ["directory"]}), + vol.Required(CONF_MEDIA): MediaSelector( + {"accept": ["directory"], "multiple": True} + ), } ) @@ -28,24 +33,41 @@ class CollectionImageConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} placeholders: dict[str, str] = {} + found_pictures = False + title = "Unnamed collection" if user_input is not None: - user_media = user_input[CONF_MEDIA] - try: - browse = await async_browse_media( - self.hass, user_media["media_content_id"] - ) - except BrowseError as err: - errors["media"] = "failed_browse" - placeholders["error"] = str(err) - else: - if browse.children and any( - item.media_class == MediaClass.IMAGE for item in browse.children - ): + user_media_list = user_input[CONF_MEDIA] + for user_media in user_media_list: + if user_media["media_content_id"] == IMAGE_MEDIA_URI: + errors["media"] = "invalid_selection" + placeholders["error"] = IMAGE_MEDIA_URI + break + try: + browse = await async_browse_media( + self.hass, user_media["media_content_id"] + ) + except BrowseError as err: + errors["media"] = "failed_browse" + placeholders["error"] = str(err) + break + else: + if ( + not found_pictures + and browse.children + and any( + item.media_class == MediaClass.IMAGE + for item in browse.children + ) + ): + found_pictures = True + if browse.title: + title = f"{browse.title} collection" + if "media" not in errors: + if found_pictures: return self.async_create_entry( - title=f"{browse.title or 'Unnamed'} collection", + title=title, data=user_input, ) - errors["media"] = "selected_media_no_images" return self.async_show_form( diff --git a/homeassistant/components/collection_image/image.py b/homeassistant/components/collection_image/image.py index 7284083ec6fd..ad601054ee9f 100644 --- a/homeassistant/components/collection_image/image.py +++ b/homeassistant/components/collection_image/image.py @@ -37,11 +37,15 @@ async def async_setup_entry( ) -> None: """Set up the Collection Image image entities.""" media = entry.data[CONF_MEDIA] + if isinstance(media, dict): + content_ids = [media["media_content_id"]] + else: + content_ids = [item["media_content_id"] for item in media] async_add_entities( [ CollectionImageImageEntity( name=entry.title, - media_content_id=media["media_content_id"], + media_content_ids=content_ids, unique_id=entry.entry_id, hass=hass, ) @@ -58,7 +62,7 @@ class CollectionImageImageEntity(ImageEntity): def __init__( self, name: str, - media_content_id: str, + media_content_ids: list[str], unique_id: str, hass: HomeAssistant, ) -> None: @@ -67,7 +71,7 @@ class CollectionImageImageEntity(ImageEntity): self.path = None self._attr_unique_id = unique_id self._attr_name = name - self.media_content_id = media_content_id + self.media_content_ids = media_content_ids def set_unavailable(self) -> None: """Set the entity to unavailable state.""" @@ -79,23 +83,30 @@ class CollectionImageImageEntity(ImageEntity): async def get_valid_images(self) -> list[BrowseMedia]: """Given the configured media directory for the entity, get a list of all child images.""" - try: - media = await async_browse_media(self.hass, self.media_content_id) - except BrowseError as err: - _LOGGER.warning("%s: %s", self.entity_id, str(err)) - return [] - images = [ - item - for item in (media.children or []) - if item.media_class == MediaClass.IMAGE - ] - if not images: - _LOGGER.warning( - "%s: No valid images in %s", - self.entity_id, - self.media_content_id, - ) + images: list[BrowseMedia] = [] + + for media_content_id in self.media_content_ids: + try: + media = await async_browse_media(self.hass, media_content_id) + except BrowseError as err: + _LOGGER.warning("%s: %s", self.entity_id, str(err)) + continue + + directory_images = [ + item + for item in (media.children or []) + if item.media_class == MediaClass.IMAGE + ] + if directory_images: + images.extend(directory_images) + else: + _LOGGER.warning( + "%s: No valid images in %s", + self.entity_id, + media_content_id, + ) + return images async def get_random_image(self) -> None: diff --git a/homeassistant/components/collection_image/strings.json b/homeassistant/components/collection_image/strings.json index 2dfab4ea68e7..df62a7351561 100644 --- a/homeassistant/components/collection_image/strings.json +++ b/homeassistant/components/collection_image/strings.json @@ -2,6 +2,7 @@ "config": { "error": { "failed_browse": "Failed to browse media: {error}", + "invalid_selection": "Invalid media selected: {error}", "selected_media_no_images": "The selected media has no images. Please select a media directory with images." }, "step": { @@ -10,9 +11,9 @@ "media": "Media" }, "data_description": { - "media": "The media directory where images will be retrieved from." + "media": "The media where images will be retrieved from." }, - "description": "The Collection Image integration creates a single image entity by selecting an image from the selected media folder.", + "description": "The Collection Image integration creates an image entity which renders an image from the selected media.", "submit": "Create" } } diff --git a/tests/components/collection_image/helpers.py b/tests/components/collection_image/helpers.py index e13741052584..9e14f8d1d531 100644 --- a/tests/components/collection_image/helpers.py +++ b/tests/components/collection_image/helpers.py @@ -7,15 +7,23 @@ from homeassistant.components.media_source import BrowseMediaSource from tests.common import MockConfigEntry -def config_entry_from_uri(uri: str) -> MockConfigEntry: - """From a uri, construct a config entry.""" +def config_entry_from_uri(uri: str | list[str]) -> MockConfigEntry: + """Construct a mock config entry from one URI or a list of URIs.""" + + def media_item(content_id: str) -> dict[str, str]: + return { + "media_content_id": content_id, + "media_content_type": "", + } + + media: dict[str, str] | list[dict[str, str]] + if isinstance(uri, str): + media = media_item(uri) + else: + media = [media_item(item) for item in uri] + return MockConfigEntry( - data={ - "media": { - "media_content_id": uri, - "media_content_type": "", - }, - }, + data={"media": media}, domain=DOMAIN, title="Random Image", ) diff --git a/tests/components/collection_image/test_config_flow.py b/tests/components/collection_image/test_config_flow.py index fdc365fc7812..f832ec33c99d 100644 --- a/tests/components/collection_image/test_config_flow.py +++ b/tests/components/collection_image/test_config_flow.py @@ -5,12 +5,14 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant import config_entries +from homeassistant.components.collection_image.config_flow import IMAGE_MEDIA_URI from homeassistant.components.collection_image.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .const import ( MOCK_MEDIA_DIR_URI_1, + MOCK_MEDIA_DIR_URI_2, MOCK_MEDIA_DIR_URI_BROWSE_ERROR, MOCK_MEDIA_DIR_URI_EMPTY, ) @@ -27,18 +29,31 @@ def mock_setup_entry(): yield mock_setup -def _data_from_uri(uri: str) -> dict: +def _data_from_uris(uris: list[str]) -> dict: return { - "media": { - "media_content_id": uri, - "media_content_type": "", - "metadata": {"a": "b"}, - } + "media": [ + { + "media_content_id": uri, + "media_content_type": "", + "metadata": {"a": "b"}, + } + for uri in uris + ] } +@pytest.mark.parametrize( + ("uris", "expected_title"), + [ + ([MOCK_MEDIA_DIR_URI_1], "My pictures collection"), + ([MOCK_MEDIA_DIR_URI_1, MOCK_MEDIA_DIR_URI_2], "My pictures collection"), + ([MOCK_MEDIA_DIR_URI_2, MOCK_MEDIA_DIR_URI_1], "Three pictures collection"), + ], +) @pytest.mark.usefixtures("mock_media_source") -async def test_config_flow(hass: HomeAssistant, mock_setup_entry) -> None: +async def test_config_flow( + hass: HomeAssistant, mock_setup_entry, uris: list[str], expected_title: str +) -> None: """Test the config flow.""" result = await hass.config_entries.flow.async_init( @@ -47,8 +62,7 @@ async def test_config_flow(hass: HomeAssistant, mock_setup_entry) -> None: assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} - data = _data_from_uri(MOCK_MEDIA_DIR_URI_1) - expected_title = "My pictures collection" + data = _data_from_uris(uris) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) @@ -59,25 +73,44 @@ async def test_config_flow(hass: HomeAssistant, mock_setup_entry) -> None: @pytest.mark.parametrize( - ("uri", "error", "placeholders"), + ("uris", "error", "placeholders"), [ ( - MOCK_MEDIA_DIR_URI_EMPTY, + [MOCK_MEDIA_DIR_URI_EMPTY], "selected_media_no_images", {}, ), ( - MOCK_MEDIA_DIR_URI_BROWSE_ERROR, + [MOCK_MEDIA_DIR_URI_EMPTY, MOCK_MEDIA_DIR_URI_EMPTY], + "selected_media_no_images", + {}, + ), + ( + [MOCK_MEDIA_DIR_URI_BROWSE_ERROR], "failed_browse", {"error": "Mock directory failed to browse"}, ), + ( + [ + MOCK_MEDIA_DIR_URI_1, + MOCK_MEDIA_DIR_URI_EMPTY, + MOCK_MEDIA_DIR_URI_BROWSE_ERROR, + ], + "failed_browse", + {"error": "Mock directory failed to browse"}, + ), + ( + [MOCK_MEDIA_DIR_URI_1, IMAGE_MEDIA_URI], + "invalid_selection", + {"error": IMAGE_MEDIA_URI}, + ), ], ) @pytest.mark.usefixtures("mock_media_source") async def test_config_flow_error( hass: HomeAssistant, mock_setup_entry, - uri: str, + uris: list[str], error: str, placeholders: dict, ) -> None: @@ -89,7 +122,7 @@ async def test_config_flow_error( assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} - data = _data_from_uri(uri) + data = _data_from_uris(uris) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) await hass.async_block_till_done() @@ -102,18 +135,19 @@ async def test_config_flow_error( for key in result["data_schema"].schema if getattr(key, "schema", key) == "media" ) - assert media_key.description["suggested_value"]["media_content_id"] == uri - assert ( - media_key.description["suggested_value"]["metadata"] - == data["media"]["metadata"] - ) + for idx, uri in enumerate(uris): + assert media_key.description["suggested_value"][idx]["media_content_id"] == uri + assert ( + media_key.description["suggested_value"][idx]["metadata"] + == data["media"][idx]["metadata"] + ) assert result.get("errors") == {"media": error} assert result.get("description_placeholders") == placeholders assert len(mock_setup_entry.mock_calls) == 0 # Try again successfully to ensure we can recover from errors - data = _data_from_uri(MOCK_MEDIA_DIR_URI_1) + data = _data_from_uris([MOCK_MEDIA_DIR_URI_1]) expected_title = "My pictures collection" result = await hass.config_entries.flow.async_configure(result["flow_id"], data) diff --git a/tests/components/collection_image/test_image.py b/tests/components/collection_image/test_image.py index 1bd470d93f86..05a3a6206059 100644 --- a/tests/components/collection_image/test_image.py +++ b/tests/components/collection_image/test_image.py @@ -22,6 +22,7 @@ from homeassistant.exceptions import HomeAssistantError from .conftest import MediaSourceMocks, MediaSourceState from .const import ( DEFAULT_ENTITY_ID, + MOCK_MEDIA_DIR_URI_1, MOCK_MEDIA_DIR_URI_2, MOCK_MEDIA_DIR_URI_BROWSE_ERROR, MOCK_MEDIA_DIR_URI_EMPTY, @@ -76,6 +77,80 @@ async def test_image( await _verify_path_image(hass, hass_client) +@pytest.mark.usefixtures("mock_media_source") +@pytest.mark.parametrize( + ("uris", "expected_images"), + [ + ( + [MOCK_MEDIA_DIR_URI_1, MOCK_MEDIA_DIR_URI_2], + [ + MOCK_MEDIA_IMAGE_URI_1, + MOCK_MEDIA_IMAGE_URI_2, + MOCK_MEDIA_IMAGE_URI_3, + MOCK_MEDIA_IMAGE_URI_4, + ], + ), + ( + [MOCK_MEDIA_DIR_URI_2, MOCK_MEDIA_DIR_URI_1], + [ + MOCK_MEDIA_IMAGE_URI_2, + MOCK_MEDIA_IMAGE_URI_3, + MOCK_MEDIA_IMAGE_URI_4, + MOCK_MEDIA_IMAGE_URI_1, + ], + ), + ( + [MOCK_MEDIA_DIR_URI_1, MOCK_MEDIA_DIR_URI_BROWSE_ERROR], + [MOCK_MEDIA_IMAGE_URI_1], + ), + ( + [ + MOCK_MEDIA_DIR_URI_BROWSE_ERROR, + MOCK_MEDIA_DIR_URI_1, + MOCK_MEDIA_DIR_URI_EMPTY, + ], + [MOCK_MEDIA_IMAGE_URI_1], + ), + ( + [ + MOCK_MEDIA_DIR_URI_EMPTY, + MOCK_MEDIA_DIR_URI_1, + ], + [MOCK_MEDIA_IMAGE_URI_1], + ), + ], +) +async def test_image_multi( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + media_source_state: MediaSourceState, + uris: list[str], + expected_images: list[str], +) -> None: + """Test multiple media sources.""" + 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[2], + ) as mock_choice, + ): + config_entry = config_entry_from_uri(uris) + 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] + ] == expected_images + state = hass.states.get(DEFAULT_ENTITY_ID) + assert state and state.state == TEST_TIME + await _verify_path_image(hass, hass_client) + + async def test_image_during_startup( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -152,42 +227,36 @@ async def test_image_url( @pytest.mark.usefixtures("mock_media_source") -async def test_no_images( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test when there are no images in the media folder.""" - config_entry = config_entry_from_uri(MOCK_MEDIA_DIR_URI_EMPTY) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get(DEFAULT_ENTITY_ID) - - assert state and state.state == STATE_UNAVAILABLE - - await hass.async_block_till_done(wait_background_tasks=True) - - assert ( - f"image.random_image: No valid images in {MOCK_MEDIA_DIR_URI_EMPTY}" - in caplog.text - ) - - 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") +@pytest.mark.parametrize( + ("uris", "error_messages"), + [ + ( + MOCK_MEDIA_DIR_URI_EMPTY, + [f"image.random_image: No valid images in {MOCK_MEDIA_DIR_URI_EMPTY}"], + ), + ( + [MOCK_MEDIA_DIR_URI_BROWSE_ERROR], + ["image.random_image: Mock directory failed to browse"], + ), + ( + [MOCK_MEDIA_DIR_URI_EMPTY, MOCK_MEDIA_DIR_URI_BROWSE_ERROR], + [ + f"image.random_image: No valid images in {MOCK_MEDIA_DIR_URI_EMPTY}", + "image.random_image: Mock directory failed to browse", + ], + ), + ], +) async def test_media_error( hass: HomeAssistant, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, + uris: str | list[str], + error_messages: list[str], ) -> None: - """Test when media browse throws an error.""" + """Test various cases where media fails to browse images.""" - config_entry = config_entry_from_uri(MOCK_MEDIA_DIR_URI_BROWSE_ERROR) + config_entry = config_entry_from_uri(uris) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -198,7 +267,8 @@ async def test_media_error( await hass.async_block_till_done(wait_background_tasks=True) - assert "image.random_image: Mock directory failed to browse" in caplog.text + for err in error_messages: + assert err in caplog.text client = await hass_client() resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}")