mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 01:11:51 -04:00
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:
co-authored by
Copilot Autofix powered by AI
parent
a2f072c217
commit
c1d94be425
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user