Add media dirs core configuration (#40071)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>pull/40179/head
parent
ce86112612
commit
5d518b5365
|
@ -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>(?!\/).+))?)?$"
|
||||
)
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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]))
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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, {})
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in New Issue