Allow multiple media directories for collection_image (#180337)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
karwosts
2026-09-12 10:11:15 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent a2f072c217
commit c1d94be425
6 changed files with 242 additions and 96 deletions
@@ -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(
@@ -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:
@@ -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"
}
}
+16 -8
View File
@@ -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",
)
@@ -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)
+101 -31
View File
@@ -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}")