From 5d518b5365cbacb70367f113f42c43ffa2cb153a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 16 Sep 2020 15:28:25 +0200 Subject: [PATCH] Add media dirs core configuration (#40071) Co-authored-by: Paulus Schoutsen --- .../components/media_source/const.py | 4 +- .../components/media_source/local_source.py | 98 +++++++++++++------ homeassistant/config.py | 13 ++- homeassistant/const.py | 1 + homeassistant/core.py | 3 + tests/common.py | 1 + tests/components/media_source/test_init.py | 19 +++- .../media_source/test_local_source.py | 39 +++++++- tests/test_config.py | 19 ++++ 9 files changed, 157 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/media_source/const.py b/homeassistant/components/media_source/const.py index 68a8244c3ce..739af47e653 100644 --- a/homeassistant/components/media_source/const.py +++ b/homeassistant/components/media_source/const.py @@ -15,4 +15,6 @@ MEDIA_CLASS_MAP = { "image": MEDIA_CLASS_IMAGE, } URI_SCHEME = "media-source://" -URI_SCHEME_REGEX = re.compile(r"^media-source://(?P[^/]+)?(?P.+)?") +URI_SCHEME_REGEX = re.compile( + r"^media-source:\/\/(?:(?P(?!.+__)(?!_)[\da-z_]+(?(?!\/).+))?)?$" +) diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index a558de775f8..e14735fb60b 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -21,26 +21,7 @@ def async_setup(hass: HomeAssistant): """Set up local media source.""" source = LocalSource(hass) hass.data[DOMAIN][DOMAIN] = source - hass.http.register_view(LocalMediaView(hass)) - - -@callback -def async_parse_identifier(item: MediaSourceItem) -> Tuple[str, str]: - """Parse identifier.""" - if not item.identifier: - source_dir_id = "media" - location = "" - - else: - source_dir_id, location = item.identifier.lstrip("/").split("/", 1) - - if source_dir_id != "media": - raise Unresolvable("Unknown source directory.") - - if location != sanitize_path(location): - raise Unresolvable("Invalid path.") - - return source_dir_id, location + hass.http.register_view(LocalMediaView(hass, source)) class LocalSource(MediaSource): @@ -56,22 +37,41 @@ class LocalSource(MediaSource): @callback def async_full_path(self, source_dir_id, location) -> Path: """Return full path.""" - return self.hass.config.path("media", location) + return Path(self.hass.config.media_dirs[source_dir_id], location) + + @callback + def async_parse_identifier(self, item: MediaSourceItem) -> Tuple[str, str]: + """Parse identifier.""" + if not item.identifier: + # Empty source_dir_id and location + return "", "" + + source_dir_id, location = item.identifier.split("/", 1) + if source_dir_id not in self.hass.config.media_dirs: + raise Unresolvable("Unknown source directory.") + + if location != sanitize_path(location): + raise Unresolvable("Invalid path.") + + return source_dir_id, location async def async_resolve_media(self, item: MediaSourceItem) -> str: """Resolve media to a url.""" - source_dir_id, location = async_parse_identifier(item) + source_dir_id, location = self.async_parse_identifier(item) + if source_dir_id == "" or source_dir_id not in self.hass.config.media_dirs: + raise Unresolvable("Unknown source directory.") + mime_type, _ = mimetypes.guess_type( - self.async_full_path(source_dir_id, location) + str(self.async_full_path(source_dir_id, location)) ) - return PlayMedia(item.identifier, mime_type) + return PlayMedia(f"/local_source/{item.identifier}", mime_type) async def async_browse_media( self, item: MediaSourceItem, media_types: Tuple[str] = MEDIA_MIME_TYPES ) -> BrowseMediaSource: """Return media.""" try: - source_dir_id, location = async_parse_identifier(item) + source_dir_id, location = self.async_parse_identifier(item) except Unresolvable as err: raise BrowseError(str(err)) from err @@ -79,9 +79,37 @@ class LocalSource(MediaSource): self._browse_media, source_dir_id, location ) - def _browse_media(self, source_dir_id, location): + def _browse_media(self, source_dir_id: str, location: Path): """Browse media.""" - full_path = Path(self.hass.config.path("media", location)) + + # If only one media dir is configured, use that as the local media root + if source_dir_id == "" and len(self.hass.config.media_dirs) == 1: + source_dir_id = list(self.hass.config.media_dirs)[0] + + # Multiple folder, root is requested + if source_dir_id == "": + if location: + raise BrowseError("Folder not found.") + + base = BrowseMediaSource( + domain=DOMAIN, + identifier="", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=None, + title=self.name, + can_play=False, + can_expand=True, + children_media_class=MEDIA_CLASS_DIRECTORY, + ) + + base.children = [ + self._browse_media(source_dir_id, "") + for source_dir_id in self.hass.config.media_dirs + ] + + return base + + full_path = Path(self.hass.config.media_dirs[source_dir_id], location) if not full_path.exists(): if location == "": @@ -118,7 +146,7 @@ class LocalSource(MediaSource): media = BrowseMediaSource( domain=DOMAIN, - identifier=f"{source_dir_id}/{path.relative_to(self.hass.config.path('media'))}", + identifier=f"{source_dir_id}/{path.relative_to(self.hass.config.media_dirs[source_dir_id])}", media_class=media_class, media_content_type=mime_type or "", title=title, @@ -149,19 +177,25 @@ class LocalMediaView(HomeAssistantView): Returns media files in config/media. """ - url = "/media/{location:.*}" + url = "/local_source/{source_dir_id}/{location:.*}" name = "media" - def __init__(self, hass: HomeAssistant): + def __init__(self, hass: HomeAssistant, source: LocalSource): """Initialize the media view.""" self.hass = hass + self.source = source - async def get(self, request: web.Request, location: str) -> web.FileResponse: + async def get( + self, request: web.Request, source_dir_id: str, location: str + ) -> web.FileResponse: """Start a GET request.""" if location != sanitize_path(location): return web.HTTPNotFound() - media_path = Path(self.hass.config.path("media", location)) + if source_dir_id not in self.hass.config.media_dirs: + return web.HTTPNotFound() + + media_path = self.source.async_full_path(source_dir_id, location) # Check that the file exists if not media_path.is_file(): diff --git a/homeassistant/config.py b/homeassistant/config.py index 36a81f98fa3..3d6e3fb041c 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -33,6 +33,7 @@ from homeassistant.const import ( CONF_INTERNAL_URL, CONF_LATITUDE, CONF_LONGITUDE, + CONF_MEDIA_DIRS, CONF_NAME, CONF_PACKAGES, CONF_TEMPERATURE_UNIT, @@ -221,6 +222,8 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend( ], _no_duplicate_auth_mfa_module, ), + # pylint: disable=no-value-for-parameter + vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()), } ) @@ -485,6 +488,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non CONF_UNIT_SYSTEM, CONF_EXTERNAL_URL, CONF_INTERNAL_URL, + CONF_MEDIA_DIRS, ] ): hac.config_source = SOURCE_YAML @@ -496,6 +500,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non (CONF_ELEVATION, "elevation"), (CONF_INTERNAL_URL, "internal_url"), (CONF_EXTERNAL_URL, "external_url"), + (CONF_MEDIA_DIRS, "media_dirs"), ): if key in config: setattr(hac, attr, config[key]) @@ -503,8 +508,14 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non if CONF_TIME_ZONE in config: hac.set_time_zone(config[CONF_TIME_ZONE]) + if CONF_MEDIA_DIRS not in config: + if is_docker_env(): + hac.media_dirs = {"media": "/media"} + else: + hac.media_dirs = {"media": hass.config.path("media")} + # Init whitelist external dir - hac.allowlist_external_dirs = {hass.config.path("www"), hass.config.path("media")} + hac.allowlist_external_dirs = {hass.config.path("www"), *hac.media_dirs.values()} if CONF_ALLOWLIST_EXTERNAL_DIRS in config: hac.allowlist_external_dirs.update(set(config[CONF_ALLOWLIST_EXTERNAL_DIRS])) diff --git a/homeassistant/const.py b/homeassistant/const.py index 291bb110a99..ec1f366fabe 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -116,6 +116,7 @@ CONF_LIGHTS = "lights" CONF_LONGITUDE = "longitude" CONF_MAC = "mac" CONF_MAXIMUM = "maximum" +CONF_MEDIA_DIRS = "media_dirs" CONF_METHOD = "method" CONF_MINIMUM = "minimum" CONF_MODE = "mode" diff --git a/homeassistant/core.py b/homeassistant/core.py index 8f3809bbd4c..f230fef01eb 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1390,6 +1390,9 @@ class Config: # List of allowed external URLs that integrations may use self.allowlist_external_urls: Set[str] = set() + # Dictionary of Media folders that integrations may use + self.media_dirs: Dict[str, str] = {} + # If Home Assistant is running in safe mode self.safe_mode: bool = False diff --git a/tests/common.py b/tests/common.py index 1cba478767f..b36439d4110 100644 --- a/tests/common.py +++ b/tests/common.py @@ -205,6 +205,7 @@ async def async_test_home_assistant(loop): hass.config.elevation = 0 hass.config.time_zone = date_util.get_time_zone("US/Pacific") hass.config.units = METRIC_SYSTEM + hass.config.media_dirs = {"media": get_test_config_dir("media")} hass.config.skip_pip = True hass.config_entries = config_entries.ConfigEntries(hass, {}) diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index 68e0fcda1d8..c7fc2dd6338 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -5,6 +5,7 @@ from homeassistant.components import media_source from homeassistant.components.media_player.const import MEDIA_CLASS_DIRECTORY from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.media_source import const +from homeassistant.components.media_source.error import Unresolvable from homeassistant.setup import async_setup_component from tests.async_mock import patch @@ -62,11 +63,23 @@ async def test_async_resolve_media(hass): assert await async_setup_component(hass, const.DOMAIN, {}) await hass.async_block_till_done() - # Test no media content - media = await media_source.async_resolve_media(hass, "") + media = await media_source.async_resolve_media( + hass, + media_source.generate_media_source_id(const.DOMAIN, "media/test.mp3"), + ) assert isinstance(media, media_source.models.PlayMedia) +async def test_async_unresolve_media(hass): + """Test browse media.""" + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + # Test no media content + with pytest.raises(Unresolvable): + await media_source.async_resolve_media(hass, "") + + async def test_websocket_browse_media(hass, hass_ws_client): """Test browse media websocket.""" assert await async_setup_component(hass, const.DOMAIN, {}) @@ -127,7 +140,7 @@ async def test_websocket_resolve_media(hass, hass_ws_client): client = await hass_ws_client(hass) - media = media_source.models.PlayMedia("/media/test.mp3", "audio/mpeg") + media = media_source.models.PlayMedia("/local_source/media/test.mp3", "audio/mpeg") with patch( "homeassistant.components.media_source.async_resolve_media", diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index 44d38107949..bd0a1435eef 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -3,11 +3,18 @@ import pytest from homeassistant.components import media_source from homeassistant.components.media_source import const +from homeassistant.config import async_process_ha_core_config from homeassistant.setup import async_setup_component async def test_async_browse_media(hass): """Test browse media.""" + local_media = hass.config.path("media") + await async_process_ha_core_config( + hass, {"media_dirs": {"media": local_media, "recordings": local_media}} + ) + await hass.async_block_till_done() + assert await async_setup_component(hass, const.DOMAIN, {}) await hass.async_block_till_done() @@ -40,27 +47,53 @@ async def test_async_browse_media(hass): assert str(excinfo.value) == "Invalid path." # Test successful listing + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{const.DOMAIN}" + ) + assert media + media = await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{const.DOMAIN}/media/." ) assert media + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{const.DOMAIN}/recordings/." + ) + assert media + async def test_media_view(hass, hass_client): """Test media view.""" + local_media = hass.config.path("media") + await async_process_ha_core_config( + hass, {"media_dirs": {"media": local_media, "recordings": local_media}} + ) + await hass.async_block_till_done() + assert await async_setup_component(hass, const.DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() # Protects against non-existent files - resp = await client.get("/media/invalid.txt") + resp = await client.get("/local_source/media/invalid.txt") + assert resp.status == 404 + + resp = await client.get("/local_source/recordings/invalid.txt") assert resp.status == 404 # Protects against non-media files - resp = await client.get("/media/not_media.txt") + resp = await client.get("/local_source/media/not_media.txt") + assert resp.status == 404 + + # Protects against unknown local media sources + resp = await client.get("/local_source/unknown_source/not_media.txt") assert resp.status == 404 # Fetch available media - resp = await client.get("/media/test.mp3") + resp = await client.get("/local_source/media/test.mp3") + assert resp.status == 200 + + resp = await client.get("/local_source/recordings/test.mp3") assert resp.status == 200 diff --git a/tests/test_config.py b/tests/test_config.py index c5443666bf5..a6c6ee86acc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -440,6 +440,7 @@ async def test_loading_configuration(hass): "allowlist_external_dirs": "/etc", "external_url": "https://www.example.com", "internal_url": "http://example.local", + "media_dirs": {"mymedia": "/usr"}, }, ) @@ -453,6 +454,8 @@ async def test_loading_configuration(hass): assert hass.config.internal_url == "http://example.local" assert len(hass.config.allowlist_external_dirs) == 3 assert "/etc" in hass.config.allowlist_external_dirs + assert "/usr" in hass.config.allowlist_external_dirs + assert hass.config.media_dirs == {"mymedia": "/usr"} assert hass.config.config_source == config_util.SOURCE_YAML @@ -483,6 +486,22 @@ async def test_loading_configuration_temperature_unit(hass): assert hass.config.config_source == config_util.SOURCE_YAML +async def test_loading_configuration_default_media_dirs_docker(hass): + """Test loading core config onto hass object.""" + with patch("homeassistant.config.is_docker_env", return_value=True): + await config_util.async_process_ha_core_config( + hass, + { + "name": "Huis", + }, + ) + + assert hass.config.location_name == "Huis" + assert len(hass.config.allowlist_external_dirs) == 2 + assert "/media" in hass.config.allowlist_external_dirs + assert hass.config.media_dirs == {"media": "/media"} + + async def test_loading_configuration_from_packages(hass): """Test loading packages config onto hass object config.""" await config_util.async_process_ha_core_config(