Add media dirs core configuration (#40071)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Franck Nijhof 2020-09-16 15:28:25 +02:00 committed by GitHub
parent 80764261c3
commit ff0562ad1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 157 additions and 40 deletions

View file

@ -15,4 +15,6 @@ MEDIA_CLASS_MAP = {
"image": MEDIA_CLASS_IMAGE,
}
URI_SCHEME = "media-source://"
URI_SCHEME_REGEX = re.compile(r"^media-source://(?P<domain>[^/]+)?(?P<identifier>.+)?")
URI_SCHEME_REGEX = re.compile(
r"^media-source:\/\/(?:(?P<domain>(?!.+__)(?!_)[\da-z_]+(?<!_))(?:\/(?P<identifier>(?!\/).+))?)?$"
)

View file

@ -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():

View file

@ -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]))

View file

@ -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"

View file

@ -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

View file

@ -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, {})

View file

@ -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",

View file

@ -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

View file

@ -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(